feat: MCP update 3 (#5864)

## Summary

This update adds new MCP and CLI capabilities for project auditing,
structured data, project-specific agent instructions, focused project
search, safer bulk editing, image descriptions, font discovery, visual
verification, and external registry inspection.

## New Capabilities

### Audit projects

- Run a single `audit` command across accessibility, SEO, security,
assets, styles, and performance.
- Limit an audit to selected scopes, pages, or severity levels.
- Receive stable findings with locations and suggested fixes.
- Run rendered audits across desktop, mobile, and Builder breakpoint
widths.
- Detect horizontal overflow, broken images, oversized images, eager
below-fold images, render-blocking resources, and legacy font formats.
- Identify unused assets, tokens, CSS variables, local styles, and
breakpoints.
- Identify duplicate token declarations.
- Detect accessibility issues involving names, labels, headings,
landmarks, IDs, keyboard focus, ARIA, iframes, and autoplaying media.
- Detect SEO issues involving titles, descriptions, language, social
images, and structured data.
- Detect unsafe new-tab links and write resources exposed during page
rendering.

### Search an entire project

- Search project text, labels, props, resources, assets, and styles
through `search-project`.
- Find relevant records without downloading and reparsing the full
project snapshot.
- Scope searches to the relevant project-data areas.

### Add validated JSON-LD

- Add structured data with a dedicated JSON-LD component.
- Place JSON-LD inside the page Head Slot.
- Edit JSON in a purpose-built code editor.
- Prevent structurally invalid JSON-LD from being saved.
- Validate Schema.org types, properties, and values.
- Find invalid, missing, unknown, deprecated, or incompatible structured
data through the SEO audit.
- Let agents discover the correct JSON-LD workflow without using page
custom metadata or a generic HTML embed.

### Configure project-specific agent instructions

- Add AI-agent instructions from the new **Agents** section in Project
Settings.
- Synchronize those instructions into a managed root `AGENTS.md`.
- Update or remove the managed file automatically during project sync.
- Protect existing user-owned `AGENTS.md` files from being overwritten.
- Share the instructions with authenticated CLI and MCP agents.
- Keep agent instructions out of publicly readable production bundles.

### Generate and manage image descriptions

- Audit image assets for missing descriptions.
- Let a vision-capable agent inspect each image in its rendered context.
- Save generated descriptions to multiple image assets in one operation.
- Mark images as intentionally decorative.
- Reuse the saved asset description wherever the image is used.
- Re-run the audit to verify that missing-description findings are
resolved.

### Perform bounded bulk replacements

- Replace matching literal text across selected pages with
`replace-text`.
- Replace matching fixed prop text with `replace-prop-text`.
- Replace matching resource names or URLs with `replace-resource-text`.
- Restrict replacements by page, instance, prop, resource, field, match
mode, and maximum change count.
- Leave dynamic expressions unchanged.

### Inspect element ancestry

- Return an element’s direct parent in instance listings.
- Request the complete ancestor chain when inspecting an element.
- Understand tree placement without rebuilding the instance hierarchy
from raw project data.

### Discover available fonts

- List uploaded font families through `list-fonts`.
- Discover available system font stacks.
- Inspect font family, style, weight, format, and associated asset
information before applying typography.

### Inspect external component registries

- Inspect a local or remote shadcn-style registry item.
- Read its description, files, dependencies, targets, and documentation.
- Receive an explicit Webstudio compatibility report.
- Inspect registry metadata without installing or executing external
code.

### Capture generated project routes directly

- Run `webstudio screenshot --path /route` against the configured
project.
- Generate a temporary production-style preview automatically.
- Capture the requested route.
- Stop the temporary preview after capture.
- Avoid manually building and starting a preview for a single
screenshot.

### Assert visual changes

- Add expected text to screenshot comparisons.
- Set maximum pixel mismatch limits.
- Set minimum or maximum changed-region counts.
- Assert whether overall brightness or a color channel moved in the
expected direction.
- Use these assertions as pass/fail evidence for visual changes.

### Require compatible CLI versions

- Send the project-bundle contract version with CLI synchronization
requests.
- Stop incompatible CLI versions before they receive incomplete project
data.
- Tell users exactly how to update the CLI or run the latest version
once.
- Prevent older clients from silently dropping project settings.

## Upgrade Note

This branch introduces a new synchronized project-bundle contract. Older
CLI versions must update before synchronizing against the new API.

```sh
npm install -g webstudio@latest
```

Or run the latest version once:

```sh
npx webstudio@latest <command>
```

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oleg IsonenandClaude Fable 5 authored and GitHub committed 2026-07-12 11:27:58 +02:00
1 parent 68b7458720
commit 071a4d9a85
236 files changed
+25349 -1659

No files matched your search

+6
View File
@@ -9,6 +9,12 @@ declare class ScrollTimeline extends AnimationTimeline {
constructor(options?: ScrollTimelineOptions);
}
// https://drafts.csswg.org/scroll-animations-1/#dictdef-keyframeanimationoptions
interface KeyframeAnimationOptions {
rangeStart?: string;
rangeEnd?: string;
}
interface ViewTimelineOptions {
subject?: Element | Document | null;
axis?: ScrollAxis;
@@ -21,7 +21,7 @@ import type {
PageSettingsValues,
} from "@webstudio-is/project-build/runtime/pages";
import { useStore } from "@nanostores/react";
import { $assets, $pages } from "~/shared/sync/data-stores";
import { $assets, $projectSettings } from "~/shared/sync/data-stores";
import { $pageRootScope } from "../page-utils";
import { SearchPreview } from "../search-preview";
import { usePageUrl, type OnChange } from "./shared";
@@ -96,9 +96,9 @@ export const SearchSection = ({
const excludePageFromSearchId = useId();
const { variableValues, scope, aliases } = useStore($pageRootScope);
const assets = useStore($assets);
const pages = useStore($pages);
const projectSettings = useStore($projectSettings);
const pageUrl = usePageUrl(values);
const faviconAsset = assets.get(pages?.meta?.faviconAssetId ?? "");
const faviconAsset = assets.get(projectSettings?.meta.faviconAssetId ?? "");
const faviconUrl = faviconAsset?.type === "image" ? faviconAsset.name : "";
const title = String(computeExpression(values.title, variableValues));
const description = String(
@@ -132,7 +132,7 @@ export const SearchSection = ({
}}
>
<SearchPreview
siteName={pages?.meta?.siteName ?? ""}
siteName={projectSettings?.meta.siteName ?? ""}
faviconUrl={faviconUrl}
pageUrl={pageUrl}
titleLink={title}
@@ -78,6 +78,7 @@ import {
$dataSources,
$instances,
$pages,
$projectSettings,
$props,
$resources,
} from "~/shared/sync/data-stores";
@@ -323,9 +324,15 @@ const ChangeProjectDomain = ({
};
const $restrictedFeatures = computed(
[$pages, $dataSources, $instances, $permissions],
(pages, dataSources, instances, permissions) =>
getRestrictedFeatures({ pages, dataSources, instances, permissions })
[$pages, $projectSettings, $dataSources, $instances, $permissions],
(pages, projectSettings, dataSources, instances, permissions) =>
getRestrictedFeatures({
pages,
projectSettings,
dataSources,
instances,
permissions,
})
);
const usePublishCountdown = (isPublishing: boolean) => {
@@ -11,6 +11,7 @@ import {
getRestrictedFeatures,
type RestrictedFeaturesPermissions,
} from "./restricted-features";
import type { ProjectSettings } from "@webstudio-is/project-build";
const createPage = (page: Partial<Page> & Pick<Page, "id">): Page => ({
name: page.id,
@@ -64,13 +65,16 @@ const defaultPermissions: RestrictedFeaturesPermissions = {
const getFeatures = ({
pages,
projectSettings,
permissions,
}: {
pages: Pages | undefined;
projectSettings?: ProjectSettings;
permissions: Partial<RestrictedFeaturesPermissions>;
}) =>
getRestrictedFeatures({
pages,
projectSettings,
dataSources: new Map<string, DataSource>(),
instances: new Map<string, Instance>(),
permissions: { ...defaultPermissions, ...permissions },
@@ -79,7 +83,9 @@ const getFeatures = ({
describe("getRestrictedFeatures", () => {
test("restricts project authentication when auth is not allowed", () => {
const features = getFeatures({
pages: createPages({
pages: createPages(),
projectSettings: {
compiler: {},
meta: {
auth: JSON.stringify({
version: 1,
@@ -92,7 +98,7 @@ describe("getRestrictedFeatures", () => {
},
}),
},
}),
},
permissions: { allowAuth: false },
});
@@ -7,6 +7,7 @@ import {
type Pages,
} from "@webstudio-is/sdk";
import { findPageAndSelectorByInstanceId } from "@webstudio-is/project-build/runtime/lookup";
import type { ProjectSettings } from "@webstudio-is/project-build";
export type RestrictedFeature =
| undefined
@@ -24,11 +25,13 @@ export type RestrictedFeaturesPermissions = {
export const getRestrictedFeatures = ({
pages,
projectSettings,
dataSources,
instances,
permissions,
}: {
pages: Pages | undefined;
projectSettings?: ProjectSettings;
dataSources: Map<string, DataSource>;
instances: Map<string, Instance>;
permissions: RestrictedFeaturesPermissions;
@@ -37,14 +40,15 @@ export const getRestrictedFeatures = ({
if (pages === undefined) {
return features;
}
const projectMeta = projectSettings?.meta;
if (
permissions.maxContactEmailsPerProject === 0 &&
(pages.meta?.contactEmail ?? "").trim()
(projectMeta?.contactEmail ?? "").trim()
) {
features.set("Custom contact email", undefined);
}
if (permissions.allowAuth === false) {
if ((pages.meta?.auth ?? "").trim()) {
if ((projectMeta?.auth ?? "").trim()) {
features.set("Project auth", undefined);
}
for (const page of getAllPages(pages)) {
@@ -34,12 +34,30 @@ import {
humanizeAttribute,
} from "../shared";
import { PropertyLabel } from "../property-label";
export type CodeIssue = HtmlEmbedCodeError & {
severity?: "error" | "warning";
};
export type CodeControlBehavior = {
autoSave?: boolean;
formatValue: (value: unknown) => string;
processValue: (
value: string
) =>
| { success: false; issue: CodeIssue }
| { success: true; value: string; issue?: CodeIssue };
validateBinding: (value: unknown, label: string) => string | undefined;
getFixedValue: (
value: unknown,
label: string
) => { success: true; value: string } | { success: false; message: string };
};
const ErrorInfo = ({
error,
onAutoFix,
}: {
error?: HtmlEmbedCodeError;
error?: CodeIssue;
onAutoFix: () => void;
}) => {
if (error === undefined) {
@@ -66,7 +84,15 @@ const ErrorInfo = ({
return (
<Tooltip content={errorContent} delayDuration={0}>
<SmallIconButton
icon={<InfoCircleIcon color={rawTheme.colors.foregroundDestructive} />}
icon={
<InfoCircleIcon
color={
error.severity === "warning"
? rawTheme.colors.foregroundSubtle
: rawTheme.colors.foregroundDestructive
}
/>
}
/>
</Tooltip>
);
@@ -78,15 +104,35 @@ export const CodeControl = ({
propName,
computedValue,
onChange,
}: ControlProps<"code"> | ControlProps<"codetext">) => {
const [error, setError] = useState<HtmlEmbedCodeError>();
behavior,
}: (ControlProps<"code"> | ControlProps<"codetext">) & {
behavior?: CodeControlBehavior;
}) => {
const [error, setError] = useState<CodeIssue>();
const metaOverride = {
...meta,
control: "text" as const,
};
const lang = meta.control === "code" ? meta.language : undefined;
const localValue = useDraftValue(String(computedValue ?? ""), (value) => {
if (lang === "html") {
const label = humanizeAttribute(metaOverride.label || propName);
const editorValue = behavior
? behavior.formatValue(computedValue)
: String(computedValue ?? "");
const localValue = useDraftValue(
editorValue,
(value) => {
let storedValue = value;
if (behavior) {
const result = behavior.processValue(value);
setError(result.issue);
if (result.success === false) {
return;
}
storedValue = result.value;
}
if (behavior === undefined && lang === "html") {
const error = validateHtmlEmbedCode(value);
setError(error);
@@ -96,12 +142,13 @@ export const CodeControl = ({
}
if (prop?.type === "expression") {
updateExpressionValue(prop.value, value);
updateExpressionValue(prop.value, storedValue);
} else {
onChange({ type: "string", value });
onChange({ type: "string", value: storedValue });
}
});
const label = humanizeAttribute(metaOverride.label || propName);
},
{ autoSave: behavior?.autoSave ?? true }
);
const { scope, aliases } = useStore($selectedInstanceScope);
const expression =
@@ -150,7 +197,7 @@ export const CodeControl = ({
</DialogTitle>
}
readOnly={overwritable === false}
invalid={error !== undefined}
invalid={error !== undefined && error.severity !== "warning"}
value={localValue.value}
onChange={(value) => {
setError(undefined);
@@ -161,15 +208,31 @@ export const CodeControl = ({
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) => validatePrimitiveValue(value, label)}
validate={(value) =>
behavior
? behavior.validateBinding(value, label)
: validatePrimitiveValue(value, label)
}
variant={variant}
value={expression}
onChange={(newExpression) =>
onChange({ type: "expression", value: newExpression })
}
onRemove={(evaluatedValue) =>
onChange({ type: "string", value: String(evaluatedValue) })
onRemove={(evaluatedValue) => {
if (behavior) {
const fixedValue = behavior.getFixedValue(evaluatedValue, label);
if (fixedValue.success === false) {
setError({
message: fixedValue.message,
value: String(evaluatedValue),
});
return;
}
onChange({ type: "string", value: fixedValue.value });
return;
}
onChange({ type: "string", value: String(evaluatedValue) });
}}
/>
</BindingControl>
</VerticalLayout>
@@ -9,6 +9,7 @@ import { FileControl } from "./file";
import { UrlControl } from "./url";
import type { ControlProps } from "../shared";
import { JsonControl } from "./json";
import { JsonCodeControl } from "./json-code";
import { TextContent } from "./text-content";
import { ResourceControl } from "./resource-control";
import { TagControl } from "./tag-control";
@@ -50,6 +51,10 @@ export const renderControl = ({
return <JsonControl key={key} meta={meta} prop={prop} {...rest} />;
}
if (meta.control === "json-code") {
return <JsonCodeControl key={key} meta={meta} prop={prop} {...rest} />;
}
if (meta.control === "text") {
return <TextControl key={key} meta={meta} prop={prop} {...rest} />;
}
@@ -0,0 +1,95 @@
import { expect, test } from "vitest";
import {
formatJsonCode,
getFixedJsonCodeValue,
parseJsonCode,
serializeJsonCode,
validateJsonCode,
validateJsonCodeValue,
} from "./json-code";
test("formats stored JSON with two-space indentation", () => {
expect(formatJsonCode({ name: "Acme", types: ["Organization"] })).toBe(
'{\n "name": "Acme",\n "types": [\n "Organization"\n ]\n}'
);
});
test("formats legacy string JSON as structured JSON", () => {
expect(formatJsonCode('{"a":1}')).toBe('{\n "a": 1\n}');
});
test("keeps invalid persisted JSON source editable without quoting it", () => {
expect(formatJsonCode("not json")).toBe("not json");
});
test("parses editor text into structured JSON", () => {
expect(parseJsonCode('{ "name": "Acme" }')).toEqual({
success: true,
value: { name: "Acme" },
});
});
test("serializes JSON for storage without formatting", () => {
expect(serializeJsonCode({ name: "Acme", enabled: true })).toBe(
'{"name":"Acme","enabled":true}'
);
});
test("validates and serializes bindings when converting them to fixed code", () => {
expect(getFixedJsonCodeValue({ name: "Acme" }, "Code")).toEqual({
success: true,
value: '{"name":"Acme"}',
});
expect(getFixedJsonCodeValue('{"name":"Acme"}', "Code")).toEqual({
success: true,
value: '{"name":"Acme"}',
});
expect(getFixedJsonCodeValue("not JSON", "Code")).toEqual({
success: false,
message: "Code: $ JSON-LD must be a valid JSON object or array.",
});
});
test.each(["", "invalid", '"text"', "1", "null"])(
"rejects non-object JSON %j",
(source) => {
expect(parseJsonCode(source)).toEqual({ success: false });
}
);
test("accepts object, array, and JSON string binding values", () => {
expect(validateJsonCodeValue({ a: 1 }, "Code")).toBeUndefined();
expect(validateJsonCodeValue([{ a: 1 }], "Code")).toBeUndefined();
expect(validateJsonCodeValue('{"a":1}', "Code")).toBeUndefined();
});
test("rejects non-JSON binding values", () => {
expect(validateJsonCodeValue(1, "Code")).toBe(
"Code: $ JSON-LD must be a valid JSON object or array."
);
});
test("validates JSON-LD structure and keeps vocabulary findings as warnings", () => {
expect(
validateJsonCode('{"@context":"https://schema.org","@type":1}')
).toMatchObject({
success: false,
diagnostics: [
expect.objectContaining({
severity: "error",
code: "invalid-keyword-value",
}),
],
});
expect(
validateJsonCode('{"@context":"https://schema.org","@type":"MadeUpType"}')
).toMatchObject({
success: true,
diagnostics: [
expect.objectContaining({
severity: "warning",
code: "unknown-schema-org-type",
}),
],
});
});
@@ -0,0 +1,105 @@
import { createElement } from "react";
import { parseJsonLd } from "@webstudio-is/sdk/runtime";
import { validateJsonLdWithSchemaOrg } from "@webstudio-is/sdk/schema-org";
import type { ControlProps } from "../shared";
import { CodeControl, type CodeControlBehavior, type CodeIssue } from "./code";
export const formatJsonCode = (value: unknown) => {
if (typeof value === "string") {
const parsed = parseJsonCode(value);
if (parsed.success) {
value = parsed.value;
} else {
return value;
}
}
return JSON.stringify(value ?? {}, undefined, 2) ?? "{}";
};
export const parseJsonCode = (
source: string
): { success: true; value: object } | { success: false } => {
return parseJsonLd(source);
};
export const serializeJsonCode = (value: object) => JSON.stringify(value);
export const validateJsonCode = (value: unknown) =>
validateJsonLdWithSchemaOrg(value);
export const getFixedJsonCodeValue = (value: unknown, label: string) => {
const result = validateJsonCode(value);
if (result.success) {
return { success: true as const, value: serializeJsonCode(result.value) };
}
const error = result.diagnostics.find(({ severity }) => severity === "error");
return {
success: false as const,
message:
error === undefined
? `${label} expects a JSON object or array`
: `${label}: ${error.path} ${error.message}`,
};
};
export const validateJsonCodeValue = (value: unknown, label: string) => {
const result = getFixedJsonCodeValue(value, label);
return result.success ? undefined : result.message;
};
const processJsonCodeValue = (
value: string
): ReturnType<CodeControlBehavior["processValue"]> => {
const result = validateJsonCode(value);
const structuralError = result.diagnostics.find(
({ severity }) => severity === "error"
);
if (result.success === false || structuralError !== undefined) {
return {
success: false,
issue: {
message:
structuralError === undefined
? "Enter a JSON object or array."
: `${structuralError.path} ${structuralError.message}`,
value,
},
};
}
const warnings = result.diagnostics.filter(
({ severity }) => severity === "warning"
);
const issue: CodeIssue | undefined =
warnings.length === 0
? undefined
: {
severity: "warning",
message: warnings
.map(({ path, message }) => `${path} ${message}`)
.join("\n"),
value,
};
return {
success: true,
value: serializeJsonCode(result.value),
issue,
};
};
const jsonCodeBehavior: CodeControlBehavior = {
autoSave: false,
formatValue: formatJsonCode,
processValue: processJsonCodeValue,
validateBinding: validateJsonCodeValue,
getFixedValue: getFixedJsonCodeValue,
};
export const JsonCodeControl = ({
meta,
...props
}: ControlProps<"json-code">) =>
createElement(CodeControl, {
...props,
meta: { ...meta, control: "code", language: "json" },
behavior: jsonCodeBehavior,
});
@@ -171,7 +171,7 @@ const Toolbar = ({ state, scale }: ToolbarProps) => {
</IconButton>
</Tooltip>
{isWithinLink === false && (
{(isWithinLink === false || state.isLink) && (
<Tooltip content="Inline link">
<IconButton
aria-label="Inline link"
@@ -64,7 +64,12 @@ import {
$activeInspectorPanel,
setActiveSidebarPanel,
} from "~/builder/shared/nano-states";
import { $instances, $pages, $props } from "~/shared/sync/data-stores";
import {
$instances,
$pages,
$projectSettings,
$props,
} from "~/shared/sync/data-stores";
import {
formatAssetName,
parseAssetName,
@@ -78,9 +83,15 @@ import {
} from "@webstudio-is/project-build/runtime/assets";
const $usagesByAssetId = computed(
[$pages, $props, $styles, $assets],
(pages, props, styles, assets) => {
return calculateUsagesByAssetId({ pages, props, styles, assets });
[$pages, $projectSettings, $props, $styles, $assets],
(pages, projectSettings, props, styles, assets) => {
return calculateUsagesByAssetId({
pages,
projectSettings,
props,
styles,
assets,
});
}
);
@@ -15,7 +15,13 @@ import {
Box,
} from "@webstudio-is/design-system";
import type { Asset } from "@webstudio-is/sdk";
import { $assets, $pages, $props, $styles } from "~/shared/sync/data-stores";
import {
$assets,
$pages,
$projectSettings,
$props,
$styles,
} from "~/shared/sync/data-stores";
import { deleteAssets } from "~/builder/shared/assets";
import { formatAssetName } from "@webstudio-is/project-build/runtime/assets";
import { calculateUsagesByAssetId } from "@webstudio-is/project-build/runtime/assets";
@@ -30,6 +36,7 @@ const getUnusedAssets = () => {
const assets = $assets.get();
const usagesByAssetId = calculateUsagesByAssetId({
pages: $pages.get(),
projectSettings: $projectSettings.get(),
props: $props.get(),
styles: $styles.get(),
assets,
@@ -89,6 +89,7 @@ const createBuildRow = (overrides: Record<string, unknown> = {}) => ({
]),
deployment: null,
marketplaceProduct: JSON.stringify({}),
projectSettings: JSON.stringify({ meta: {}, compiler: {} }),
...overrides,
});
@@ -284,8 +285,6 @@ describe("api build snapshot", () => {
test("returns page folders and data namespaces when requested", () => {
const pages = createDefaultPages({ rootInstanceId: "root-1" });
pages.meta = { siteName: "Acme" };
pages.compiler = { atomicStyles: true };
pages.redirects = [{ old: "/old", new: "/new", status: "301" }];
const build = {
id: "build-1",
@@ -301,6 +300,10 @@ describe("api build snapshot", () => {
dataSources: [{ id: "variable-1" }],
breakpoints: [{ id: "breakpoint-1" }],
marketplaceProduct: { categories: ["template"] },
projectSettings: {
meta: { siteName: "Acme" },
compiler: { atomicStyles: true },
},
} as unknown as SnapshotBuildInput;
expect(
@@ -312,6 +315,7 @@ describe("api build snapshot", () => {
"resources",
"variables",
"breakpoints",
"projectSettings",
"marketplaceProduct",
]),
projectId: "project-1",
@@ -323,8 +327,10 @@ describe("api build snapshot", () => {
version: 7,
homePageId: build.pages.homePageId,
rootFolderId: build.pages.rootFolderId,
projectSettings: {
meta: { siteName: "Acme" },
compiler: { atomicStyles: true },
},
redirects: [{ old: "/old", new: "/new", status: "301" }],
resources: [{ id: "resource-1" }],
variables: [{ id: "variable-1" }],
@@ -58,6 +58,7 @@ const buildInclude = z.enum([
"resources",
"variables",
"breakpoints",
"projectSettings",
"marketplaceProduct",
]);
@@ -100,13 +101,12 @@ export const createBuildSnapshot = ({
snapshot.homePageId = pages.homePageId;
snapshot.rootFolderId = pages.rootFolderId;
if (include.has("pages")) {
snapshot.meta = pages.meta;
snapshot.compiler = pages.compiler;
snapshot.redirects = pages.redirects;
}
add("pages", pages.pages);
add("folders", pages.folders);
}
add("projectSettings", build.projectSettings);
add("instances", build.instances);
add("props", build.props);
add("styles", build.styles);
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { getApiCompatibilityPayload } from "@webstudio-is/trpc-interface/api-compatibility";
import { assertCliProjectSettingsContract } from "./api-compatibility.server";
describe("API client compatibility", () => {
test("requires updated CLI clients when pages move to project settings", () => {
let error: unknown;
try {
assertCliProjectSettingsContract("cli", new Set(["pages"]));
} catch (caught) {
error = caught;
}
expect(getApiCompatibilityPayload(error)).toMatchObject({
reason: "clientVersionUnsupported",
target: "cli",
action: { type: "updateCli" },
});
});
test("accepts current CLI and non-CLI snapshot contracts", () => {
expect(() =>
assertCliProjectSettingsContract(
"cli",
new Set(["pages", "projectSettings"])
)
).not.toThrow();
expect(() =>
assertCliProjectSettingsContract("browser", new Set(["pages"]))
).not.toThrow();
});
});
@@ -0,0 +1,32 @@
import { TRPCError } from "@trpc/server";
import {
createApiCompatibilityPayload,
type ApiClient,
type ApiCompatibilityTarget,
} from "@webstudio-is/trpc-interface/api-compatibility";
export const throwApiClientUpdateRequired = (
target: ApiCompatibilityTarget
): never => {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "The API client must be updated before continuing.",
cause: createApiCompatibilityPayload({
reason: "clientVersionUnsupported",
target,
}),
});
};
export const assertCliProjectSettingsContract = (
client: ApiClient | "unknown" | undefined,
include: ReadonlySet<string>
) => {
if (
client === "cli" &&
include.has("pages") &&
include.has("projectSettings") === false
) {
throwApiClientUpdateRequired("cli");
}
};
@@ -698,6 +698,8 @@ describe("api instance queries", () => {
tag: "section",
depth: 1,
childCount: 1,
parentId: "root-1",
indexWithinParent: 0,
},
],
});
@@ -844,7 +846,8 @@ describe("api instance queries", () => {
{
id: "token-1",
name: "Primary",
styles: { color: { type: "keyword", value: "red" } },
declarationCount: 1,
styles: undefined,
usageCount: 1,
},
],
@@ -55,6 +55,7 @@ import {
serializeProjectSummary,
} from "./api-build.server";
import { throwApiError } from "./api-errors.server";
import { assertCliProjectSettingsContract } from "./api-compatibility.server";
import {
createBuilderRuntimeState,
executeApiRuntimeMutation,
@@ -594,6 +595,7 @@ export const apiRouter = router({
}
const include = new Set(input.include ?? []);
assertCliProjectSettingsContract(ctx.apiClient?.type, include);
const snapshot = createBuildSnapshot({
build,
include,
@@ -2,6 +2,7 @@ import type { CompactBuild } from "@webstudio-is/project-build";
import type { RuntimeOperationId } from "@webstudio-is/project-build/contracts/builder-runtime";
import { executeBuilderRuntimeOperation } from "@webstudio-is/project-build/runtime/registry";
import { BuilderRuntimeError } from "@webstudio-is/project-build/runtime/errors";
import { builderRuntimeContext } from "@webstudio-is/project-build/runtime/context";
import { type BuilderRuntimeMutation } from "@webstudio-is/project-build/runtime/mutation";
import type { BuilderState } from "@webstudio-is/project-build/state/builder-state";
import { createBuilderStateFromCompactBuild } from "@webstudio-is/project-build/state/adapters";
@@ -32,6 +33,11 @@ export const executeApiRuntimeOperation = async <Result>({
id,
state: createBuilderRuntimeState(build, assets),
input,
context: {
createId: builderRuntimeContext.createId,
projectId: build.projectId,
projectVersion: build.version,
},
});
} catch (error) {
if (error instanceof BuilderRuntimeError) {
@@ -1,14 +1,80 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { bundleVersion } from "@webstudio-is/protocol";
import { createPublishedProjectBundleFixture } from "@webstudio-is/protocol/fixtures";
import { buildRouter, __testing__ } from "./build-router.server";
import { authorizeProject } from "@webstudio-is/trpc-interface/index.server";
import { getApiCompatibilityPayload } from "@webstudio-is/trpc-interface/api-compatibility";
const { createImportProjectBundleHandler } = __testing__;
const {
assertCliBundleVersion,
createImportProjectBundleHandler,
prepareProjectBundleForClient,
} = __testing__;
afterEach(() => {
vi.restoreAllMocks();
});
describe("build router project bundle compatibility", () => {
test("requires the current bundle contract from CLI clients", () => {
const ctx = { apiClient: { type: "cli" } } as never;
let error: unknown;
try {
assertCliBundleVersion(ctx, undefined);
} catch (caught) {
error = caught;
}
expect(getApiCompatibilityPayload(error)).toMatchObject({
reason: "clientVersionUnsupported",
target: "cli",
action: { type: "updateCli" },
});
expect(() => assertCliBundleVersion(ctx, bundleVersion)).not.toThrow();
});
test("removes agent instructions from non-CLI bundles", async () => {
const bundle = createPublishedProjectBundleFixture({
build: {
projectSettings: {
meta: { siteName: "Acme", agentInstructions: "Internal guidance" },
compiler: {},
},
},
});
const result = await prepareProjectBundleForClient(
{ apiClient: { type: "browser" } } as never,
bundle
);
expect(result.build.projectSettings?.meta).toEqual({ siteName: "Acme" });
expect(bundle.build.projectSettings?.meta.agentInstructions).toBe(
"Internal guidance"
);
});
test("keeps agent instructions for authorized CLI bundles", async () => {
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
const bundle = createPublishedProjectBundleFixture({
build: {
projectSettings: {
meta: { agentInstructions: "Internal guidance" },
compiler: {},
},
},
});
const result = await prepareProjectBundleForClient(
{ apiClient: { type: "cli" } } as never,
bundle
);
expect(result.build.projectSettings?.meta.agentInstructions).toBe(
"Internal guidance"
);
});
});
describe("build router jsx fragment conversion", () => {
test("converts Webstudio JSX through the shared runtime converter", async () => {
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
@@ -26,8 +26,10 @@ import { loadAssetsByProject } from "@webstudio-is/asset-uploader/index.server";
import {
checkProjectBuildPermissionInput,
importProjectBundleInput,
bundleVersion,
publishedProjectBundle,
} from "@webstudio-is/protocol";
import { removeAgentInstructionsFromProjectSettings } from "@webstudio-is/project-build/shared/project-settings";
import {
loadProjectBundleByBuildId,
loadPublishedProjectBundleByProjectId,
@@ -40,6 +42,54 @@ import {
readStagedUploadText,
removeStagedUpload,
} from "./staged-upload.server";
import { throwApiClientUpdateRequired } from "./api-compatibility.server";
const projectBundleInput = z.object({
projectId: z.string(),
bundleVersion: z.union([z.string(), z.number()]).optional(),
});
const buildBundleInput = z.object({
buildId: z.string(),
bundleVersion: z.union([z.string(), z.number()]).optional(),
});
const assertCliBundleVersion = (
ctx: AppContext,
clientBundleVersion: string | number | undefined
) => {
if (ctx.apiClient?.type === "cli" && clientBundleVersion !== bundleVersion) {
throwApiClientUpdateRequired("cli");
}
};
const prepareProjectBundleForClient = async (
ctx: AppContext,
bundle: z.infer<typeof publishedProjectBundle>
) => {
if (ctx.apiClient?.type === "cli") {
const canView = await authorizeProject.hasProjectPermit(
{ projectId: bundle.build.projectId, permit: "view" },
ctx
);
if (canView === false) {
throw new AuthorizationError("You don't have access to this project");
}
return bundle;
}
return {
...bundle,
build: {
...bundle.build,
projectSettings:
bundle.build.projectSettings === undefined
? undefined
: removeAgentInstructionsFromProjectSettings(
bundle.build.projectSettings
),
},
};
};
type ImportProjectBundleDependencies = {
importPublishedProjectBundle: typeof importPublishedProjectBundle;
@@ -207,15 +257,23 @@ export const buildRouter = router({
}),
loadProjectBundleByBuildId: procedure
.input(z.object({ buildId: z.string() }))
.input(buildBundleInput)
.query(async ({ ctx, input }) => {
return await loadProjectBundleByBuildId(input.buildId, ctx);
assertCliBundleVersion(ctx, input.bundleVersion);
return await prepareProjectBundleForClient(
ctx,
await loadProjectBundleByBuildId(input.buildId, ctx)
);
}),
loadProjectBundleByProjectId: procedure
.input(z.object({ projectId: z.string() }))
.input(projectBundleInput)
.query(async ({ ctx, input }) => {
return await loadPublishedProjectBundleByProjectId(input.projectId, ctx);
assertCliBundleVersion(ctx, input.bundleVersion);
return await prepareProjectBundleForClient(
ctx,
await loadPublishedProjectBundleByProjectId(input.projectId, ctx)
);
}),
checkProjectBuildPermission: procedure
@@ -330,4 +388,6 @@ export const buildRouter = router({
export const __testing__ = {
createImportProjectBundleHandler,
assertCliBundleVersion,
prepareProjectBundleForClient,
};
@@ -5,9 +5,17 @@ import {
} from "@webstudio-is/trpc-interface/index.server";
import {
loadDevBuildByProjectId,
serializeConfig,
serializeData,
} from "@webstudio-is/project-build/index.server";
import { migratePages } from "@webstudio-is/project-migrations/pages";
import {
migratePages,
serializePages,
} from "@webstudio-is/project-migrations/pages";
import {
createProjectSettingsFromPages,
removeLegacyProjectSettingsFromPages,
} from "@webstudio-is/project-build/shared/project-settings";
import { serializeStyles } from "@webstudio-is/project-build/styles.server";
import { serializeStyleSourceSelections } from "@webstudio-is/project-build/style-source-selections.server";
import {
@@ -73,11 +81,23 @@ const createBuildImportUpdate = ({
lastTransactionId: string;
updatedAt: string;
version: number;
}) => ({
}) => {
const pages = migratePages(data.build.pages);
const projectSettings =
data.build.projectSettings ?? createProjectSettingsFromPages(pages);
return {
version,
lastTransactionId,
updatedAt,
pages: JSON.stringify(data.build.pages),
pages: JSON.stringify(
serializePages(removeLegacyProjectSettingsFromPages(pages))
),
projectSettings: serializeConfig(projectSettings),
...(data.build.marketplaceProduct === undefined
? {}
: {
marketplaceProduct: serializeConfig(data.build.marketplaceProduct),
}),
breakpoints: serializeData<Breakpoint>(toMap(data.build.breakpoints)),
styles: serializeStyles(toMap(data.build.styles)),
styleSources: serializeData<StyleSource>(toMap(data.build.styleSources)),
@@ -88,7 +108,8 @@ const createBuildImportUpdate = ({
dataSources: serializeData<DataSource>(toMap(data.build.dataSources)),
resources: serializeData<Resource>(toMap(data.build.resources)),
instances: serializeData<Instance>(toMap(data.build.instances)),
});
};
};
const getImportedPreviewImageAssetId = (data: ProjectBundle) => {
const socialImageAssetId = getHomePage(migratePages(data.build.pages)).meta
+14 -2
View File
@@ -1,6 +1,9 @@
import { getStyleDeclKey, type WebstudioData } from "@webstudio-is/sdk";
import { migratePages } from "@webstudio-is/project-migrations/pages";
import type { MarketplaceProduct } from "@webstudio-is/project-build";
import {
type MarketplaceProduct,
type ProjectSettings,
} from "@webstudio-is/project-build";
import type { Project } from "@webstudio-is/project";
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "~/services/trcp-router.server";
@@ -11,6 +14,7 @@ import {
$dataSources,
$instances,
$marketplaceProduct,
$projectSettings,
$pages,
$props,
$resources,
@@ -22,6 +26,7 @@ import { nativeClient } from "~/shared/trpc/trpc-client";
export type BuilderData = WebstudioData & {
marketplaceProduct: undefined | MarketplaceProduct;
projectSettings: ProjectSettings;
project: Project;
};
@@ -39,6 +44,10 @@ export const getBuilderData = (): BuilderData => {
if (project === undefined) {
throw Error(`Cannot get webstudio data with empty project`);
}
const projectSettings = $projectSettings.get();
if (projectSettings === undefined) {
throw Error(`Cannot get webstudio data with empty project settings`);
}
return {
pages,
project,
@@ -52,6 +61,7 @@ export const getBuilderData = (): BuilderData => {
styles: $styles.get(),
assets: $assets.get(),
marketplaceProduct: $marketplaceProduct.get(),
projectSettings,
};
};
@@ -76,6 +86,7 @@ export const loadBuilderData = async ({
{ projectId },
{ signal }
);
const pages = migratePages(data.pages);
return {
id: data.id,
version: data.version,
@@ -87,7 +98,7 @@ export const loadBuilderData = async ({
dataSources: hydrateIdMap(data.dataSources),
resources: hydrateIdMap(data.resources),
props: hydrateIdMap(data.props),
pages: migratePages(data.pages),
pages,
breakpoints: hydrateIdMap(data.breakpoints),
styleSources: hydrateIdMap(data.styleSources),
styleSourceSelections: hydrateMap(
@@ -96,6 +107,7 @@ export const loadBuilderData = async ({
),
styles: hydrateMap(data.styles, (item) => getStyleDeclKey(item)),
marketplaceProduct: data.marketplaceProduct,
projectSettings: data.projectSettings,
};
} catch (error) {
const message =
+24 -2
View File
@@ -25,6 +25,7 @@ import {
completionKeymap,
} from "@codemirror/autocomplete";
import { html } from "@codemirror/lang-html";
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";
import { css } from "@webstudio-is/design-system";
import {
@@ -44,6 +45,13 @@ const wrapperStyle = css({
size: {
default: getCodeEditorCssVars({ minHeight: "160px", maxHeight: "320px" }),
small: getCodeEditorCssVars({ minHeight: "16px", maxHeight: "120px" }),
full: {
...getCodeEditorCssVars({ minHeight: "100%", maxHeight: "100%" }),
height: "100%",
"& > div": { height: "100%" },
"& > div > div:first-child": { height: "100%" },
"& .cm-editor, & .cm-scroller": { height: "100%" },
},
},
},
defaultVariants: {
@@ -93,6 +101,16 @@ const getMarkdownExtensions = () => [
keymap.of(closeBracketsKeymap),
];
const getJsonExtensions = () => [
highlightActiveLine(),
highlightSpecialChars(),
indentOnInput(),
javascript(),
bracketMatching(),
closeBrackets(),
keymap.of(closeBracketsKeymap),
];
const cssPropertiesLanguage = LRLanguage.define({
name: "css",
parser: cssLanguage.configure({ top: "Styles" }).parser,
@@ -118,9 +136,9 @@ const getCssPropertiesExtensions = () => [
export const CodeEditor = forwardRef<
HTMLDivElement,
Omit<ComponentProps<typeof EditorContent>, "extensions"> & {
lang?: "html" | "markdown" | "css-properties";
lang?: "html" | "json" | "markdown" | "css-properties";
title?: ReactNode;
size?: "default" | "small";
size?: "default" | "small" | "full";
}
>(({ lang, title, size, ...editorContentProps }, ref) => {
const extensions = useMemo(() => {
@@ -132,6 +150,10 @@ export const CodeEditor = forwardRef<
return getMarkdownExtensions();
}
if (lang === "json") {
return getJsonExtensions();
}
if (lang === "css-properties") {
return getCssPropertiesExtensions();
}
@@ -37,6 +37,7 @@ const createBundleBuild = (
email: "author@example.com",
description: "Description",
},
projectSettings: { meta: {}, compiler: {} },
...overrides,
};
};
@@ -74,6 +74,8 @@ const serializeProjectBundle = ({
dataSources: build.dataSources.map(getPair),
resources: build.resources.map(getPair),
instances: build.instances.map(getPair),
marketplaceProduct: build.marketplaceProduct,
projectSettings: build.projectSettings,
deployment: build.deployment,
},
page,
@@ -15,6 +15,7 @@ import {
$dataSources,
$instances,
$pages,
$projectSettings,
$props,
$resources,
$styleSourceSelections,
@@ -52,6 +53,7 @@ const setBaseStores = () => {
$dataSources.set(new Map());
$resources.set(new Map());
$assets.set(new Map());
$projectSettings.set({ meta: {}, compiler: {} });
return { pages };
};
@@ -89,6 +91,7 @@ describe("data store helpers", () => {
$dataSources.set(new Map());
$resources.set(new Map());
$assets.set(new Map());
$projectSettings.set({ meta: {}, compiler: {} });
expect(getWebstudioData()).toMatchObject({
pages,
@@ -916,7 +919,9 @@ describe("data store helpers", () => {
});
test("executes project settings runtime mutations against builder stores", () => {
setBaseStores();
const { pages } = setBaseStores();
const legacyMeta = pages.meta;
const legacyCompiler = pages.compiler;
executeRuntimeMutation({
id: "projectSettings.update",
@@ -930,16 +935,18 @@ describe("data store helpers", () => {
},
});
expect($pages.get()?.meta).toEqual(
expect($projectSettings.get()?.meta).toEqual(
expect.objectContaining({
contactEmail: "hello@example.com",
})
);
expect($pages.get()?.compiler).toEqual(
expect($projectSettings.get()?.compiler).toEqual(
expect.objectContaining({
atomicStyles: true,
})
);
expect($pages.get()?.meta).toEqual(legacyMeta);
expect($pages.get()?.compiler).toEqual(legacyCompiler);
executeRuntimeMutation({
id: "projectSettings.update",
@@ -950,7 +957,8 @@ describe("data store helpers", () => {
},
});
expect($pages.get()?.meta).not.toHaveProperty("contactEmail");
expect($projectSettings.get()?.meta).not.toHaveProperty("contactEmail");
expect($pages.get()?.meta).toEqual(legacyMeta);
});
test("runtime bridge skips page templates without build access", () => {
@@ -18,7 +18,9 @@ import {
$breakpoints,
$dataSources,
$instances,
$marketplaceProduct,
$pages,
$projectSettings,
$project,
$props,
$resources,
@@ -124,11 +126,12 @@ export const executeRuntimeMutationAsync = async <
return commitRuntimeMutation(result);
};
export const getWebstudioData = (): WebstudioData => {
export const getWebstudioData = () => {
const pages = $pages.get();
if (pages === undefined) {
throw Error(`Cannot get webstudio data with empty pages`);
}
const projectSettings = $projectSettings.get() ?? { meta: {}, compiler: {} };
return {
pages,
instances: $instances.get(),
@@ -140,5 +143,7 @@ export const getWebstudioData = (): WebstudioData => {
styleSources: $styleSources.get(),
styles: $styles.get(),
assets: $assets.get(),
marketplaceProduct: $marketplaceProduct.get(),
projectSettings,
};
};
@@ -2,6 +2,7 @@ import { atom } from "nanostores";
export type SectionName =
| "general"
| "agents"
| "auth"
| "redirects"
| "publish"
@@ -6,7 +6,7 @@ import {
DialogTitle,
Grid,
theme,
ScrollArea,
ScrollAreaNative,
Flex,
List,
ListItem,
@@ -21,6 +21,7 @@ import {
import { $isDesignMode } from "~/shared/nano-states";
import { leftPanelWidth, rightPanelWidth } from "./utils";
import { SectionGeneral } from "./section-general";
import { SectionAgents } from "./section-agents";
import { SectionAuth } from "./section-auth";
import { SectionRedirects } from "./section-redirects";
import { SectionPublish } from "./section-publish";
@@ -33,6 +34,7 @@ const sections = new Map<
FunctionComponent<{ projectId?: string }>
>([
["general", SectionGeneral],
["agents", SectionAgents],
["redirects", SectionRedirects],
["publish", SectionPublish],
["marketplace", SectionMarketplace],
@@ -123,19 +125,25 @@ export const ProjectSettingsDialog = ({
})}
</Flex>
</List>
<ScrollArea css={{ width: "100%" }}>
<ScrollAreaNative css={{ width: "100%" }}>
{status === "loading" ? (
<Flex justify="center" align="center" css={{ minHeight: 400 }}>
<SpinnerIcon size={rawTheme.spacing[15]} />
</Flex>
) : (
<Grid gap={2} css={{ paddingBlock: theme.spacing[5] }}>
<Grid
gap={2}
css={{
paddingBlock: theme.spacing[5],
minHeight: currentSection === "agents" ? "100%" : undefined,
}}
>
{SectionComponent && (
<SectionComponent projectId={projectId} />
)}
</Grid>
)}
</ScrollArea>
</ScrollAreaNative>
</Flex>
{/* Title is at the end intentionally,
* to make the close button last in the tab order
@@ -0,0 +1,48 @@
import { useEffect, useState } from "react";
import { useStore } from "@nanostores/react";
import { Flex, Grid, Text, Tooltip } from "@webstudio-is/design-system";
import { InfoCircleIcon } from "@webstudio-is/icons";
import { CodeEditor } from "~/shared/code-editor";
import { $projectSettings } from "~/shared/sync/data-stores";
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
import { sectionSpacing } from "./utils";
export const SectionAgents = () => {
const projectSettings = useStore($projectSettings);
const instructions = projectSettings?.meta.agentInstructions ?? "";
const [value, setValue] = useState(instructions);
useEffect(() => setValue(instructions), [instructions]);
return (
<Grid
gap={2}
css={{ height: "100%", gridTemplateRows: "auto minmax(0, 1fr)" }}
>
<Flex align="center" gap={1} css={sectionSpacing}>
<Text variant="titles">Agents</Text>
<Tooltip
variant="wrapped"
content="Give AI coding agents project-specific guidance. When you sync the project locally, Webstudio writes these instructions to a managed AGENTS.md in the project root. An existing user-owned AGENTS.md is never overwritten."
>
<InfoCircleIcon tabIndex={0} />
</Tooltip>
</Flex>
<Grid gap={1} css={{ ...sectionSpacing, minHeight: 0 }}>
<CodeEditor
title="Instructions"
lang="markdown"
size="full"
value={value}
onChange={setValue}
onChangeComplete={(agentInstructions) => {
executeRuntimeMutation({
id: "projectSettings.update",
input: { meta: { agentInstructions } },
});
}}
/>
</Grid>
</Grid>
);
};
@@ -28,7 +28,7 @@ import {
} from "@webstudio-is/wsauth";
import { validateBasicAuthCredentials } from "@webstudio-is/project-build/runtime/auth";
import { $permissions } from "~/shared/nano-states";
import { $pages } from "~/shared/sync/data-stores";
import { $pages, $projectSettings } from "~/shared/sync/data-stores";
import { getExistingRoutePaths, sectionSpacing } from "./utils";
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
import {
@@ -50,9 +50,10 @@ const saveAuthRoutes = (authRoutes: WsAuthRoute[]) => {
export const SectionAuth = () => {
const { allowAuth } = useStore($permissions);
const pages = useStore($pages);
const projectSettings = useStore($projectSettings);
const routeRef = useRef<HTMLInputElement>(null);
const [authRoutes, setAuthRoutes] = useState(() => {
return parseProjectAuthRoutes($pages.get()?.meta?.auth).routes;
return parseProjectAuthRoutes($projectSettings.get()?.meta.auth).routes;
});
const [route, setRoute] = useState("");
const [login, setLogin] = useState("");
@@ -62,7 +63,7 @@ export const SectionAuth = () => {
const [passwordErrors, setPasswordErrors] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const authContent = pages?.meta?.auth;
const authContent = projectSettings?.meta.auth;
const parseResult = useMemo(() => {
return parseProjectAuthRoutes(authContent);
}, [authContent]);
@@ -23,7 +23,7 @@ import { validateContactEmail } from "@webstudio-is/project-build/runtime/projec
import { ImageControl } from "./image-control";
import { $assets, $project } from "~/shared/sync/data-stores";
import { $permissions } from "~/shared/nano-states";
import { $pages } from "~/shared/sync/data-stores";
import { $projectSettings } from "~/shared/sync/data-stores";
import { sectionSpacing } from "./utils";
import { CodeEditor } from "~/shared/code-editor";
import { CopyToClipboard } from "~/shared/copy-to-clipboard";
@@ -59,19 +59,21 @@ const saveSetting = <Name extends keyof ProjectMeta>(
export const SectionGeneral = ({ projectId }: { projectId?: string }) => {
const { maxContactEmailsPerProject } = useStore($permissions);
const allowContactEmail = maxContactEmailsPerProject > 0;
const pages = useStore($pages);
const projectSettings = useStore($projectSettings);
const project = useStore($project);
const assets = useStore($assets);
const [meta, setMeta] = useState(() => pages?.meta ?? defaultMetaSettings);
const [meta, setMeta] = useState(
() => projectSettings?.meta ?? defaultMetaSettings
);
const siteNameId = useId();
const contactEmailId = useId();
// Update meta when pages data loads (important for dashboard mode)
// Update meta when project settings load (important for dashboard mode)
useEffect(() => {
if (pages?.meta) {
setMeta(pages.meta);
if (projectSettings?.meta) {
setMeta(projectSettings.meta);
}
}, [pages?.meta]);
}, [projectSettings?.meta]);
const contactEmailError = validateContactEmail(
meta.contactEmail ?? "",
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useStore } from "@nanostores/react";
import {
Grid,
Label,
@@ -7,7 +7,7 @@ import {
Text,
} from "@webstudio-is/design-system";
import type { CompilerSettings } from "@webstudio-is/sdk";
import { $pages } from "~/shared/sync/data-stores";
import { $projectSettings } from "~/shared/sync/data-stores";
import { useIds } from "~/shared/form-utils";
import { sectionSpacing } from "./utils";
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
@@ -18,9 +18,8 @@ const defaultPublishSettings: CompilerSettings = {
export const SectionPublish = () => {
const ids = useIds(["atomicStyles"]);
const [settings, setSettings] = useState(
() => $pages.get()?.compiler ?? defaultPublishSettings
);
const settings =
useStore($projectSettings)?.compiler ?? defaultPublishSettings;
const handleSave = (settings: CompilerSettings) => {
executeRuntimeMutation({
@@ -42,7 +41,6 @@ export const SectionPublish = () => {
onCheckedChange={(atomicStyles) => {
if (typeof atomicStyles === "boolean") {
const nextSettings = { ...settings, atomicStyles };
setSettings(nextSettings);
handleSave(nextSettings);
}
}}
@@ -1,6 +1,7 @@
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/patch";
import {
createSyncChangesFromBuilderPatchPayload,
@@ -13,6 +14,7 @@ import {
$dataSources,
$instances,
$pages,
$projectSettings,
$props,
$resources,
$styleSourceSelections,
@@ -33,7 +35,9 @@ const createInstance = (
children,
});
const createData = (): WebstudioData => ({
type TestData = WebstudioData & { projectSettings: ProjectSettings };
const createData = (): TestData => ({
pages: createDefaultPages({ rootInstanceId: "body" }),
instances: new Map([
["body", createInstance("body")],
@@ -48,9 +52,10 @@ const createData = (): WebstudioData => ({
styleSources: new Map(),
styles: new Map(),
assets: new Map(),
projectSettings: { meta: {}, compiler: {} },
});
const setupStores = (data: WebstudioData) => {
const setupStores = (data: TestData) => {
serverSyncStore.transactionManager.currentStack = [];
serverSyncStore.transactionManager.undoneStack = [];
serverSyncStore.popAll();
@@ -64,6 +69,7 @@ const setupStores = (data: WebstudioData) => {
$styleSources.set(data.styleSources);
$styles.set(data.styles);
$assets.set(data.assets);
$projectSettings.set(data.projectSettings);
};
describe("builder patch sync adapter", () => {
@@ -265,6 +271,36 @@ describe("builder patch sync adapter", () => {
]);
});
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", {
+14 -5
View File
@@ -5,14 +5,23 @@ import type {
BuilderPatchChange,
} from "@webstudio-is/project-build/contracts/patch";
import type { WebstudioData } from "@webstudio-is/sdk";
import type {
MarketplaceProduct,
ProjectSettings,
} from "@webstudio-is/project-build";
import * as builderStatePatch from "@webstudio-is/project-build/state/patch";
import { serverSyncStore } from "./sync-stores";
type BuilderPatchData = WebstudioData & {
marketplaceProduct?: MarketplaceProduct;
projectSettings?: ProjectSettings;
};
const getNamespaceData = (
data: WebstudioData,
data: BuilderPatchData,
namespace: BuilderPatchChange["namespace"]
) => {
const namespaceData = data[namespace as keyof WebstudioData];
const namespaceData = data[namespace];
if (namespaceData !== undefined) {
return namespaceData;
}
@@ -20,7 +29,7 @@ const getNamespaceData = (
};
const applyBuilderPatchPayloadMutable = (
data: WebstudioData,
data: BuilderPatchData,
payload: readonly BuilderPatchChange[]
) =>
builderStatePatch.applyBuilderPatchPayloadMutable(
@@ -91,7 +100,7 @@ export const createSyncChangesFromBuilderPatchPayload = ({
data,
payload,
}: {
data: WebstudioData;
data: BuilderPatchData;
payload: BuilderPatchChange[];
}): Change[] => {
const payloadForReversePatches = payload.map((change) => ({
@@ -108,7 +117,7 @@ export const createTransactionFromBuilderPatchPayload = ({
data,
payload,
}: {
data: WebstudioData;
data: BuilderPatchData;
payload: BuilderPatchChange[];
}) => {
serverSyncStore.createTransactionFromChanges(
+8 -1
View File
@@ -17,7 +17,10 @@ import type {
StyleSourceSelections,
} from "@webstudio-is/sdk";
import type { Project } from "@webstudio-is/project";
import type { MarketplaceProduct } from "@webstudio-is/project-build";
import type {
MarketplaceProduct,
ProjectSettings,
} from "@webstudio-is/project-build";
export const $project = atom<Project | undefined>();
@@ -58,6 +61,8 @@ export const $styles = atom<Styles>(new Map());
export const $marketplaceProduct = atom<undefined | MarketplaceProduct>();
export const $projectSettings = atom<undefined | ProjectSettings>();
export const $publisherHost = atom<string>("wstd.work");
/**
@@ -77,6 +82,7 @@ const getInitialDataStoreValues = () => ({
styleSourceSelections: new Map(),
styles: new Map(),
marketplaceProduct: undefined,
projectSettings: undefined,
publisherHost: "wstd.work",
});
@@ -98,5 +104,6 @@ export const resetDataStores = () => {
$styleSourceSelections.set(initial.styleSourceSelections);
$styles.set(initial.styles);
$marketplaceProduct.set(initial.marketplaceProduct);
$projectSettings.set(initial.projectSettings);
$publisherHost.set(initial.publisherHost);
};
@@ -36,6 +36,7 @@ const contentModeBuildColumns = [
const namespaceBuildColumns = {
pages: ["pages"],
projectSettings: ["projectSettings"],
breakpoints: ["breakpoints"],
instances: ["instances"],
props: ["props"],
@@ -24,6 +24,7 @@ describe("getServerSyncState", () => {
resources: "resources",
assets: "assets",
marketplaceProduct: "marketplaceProduct",
projectSettings: "projectSettings",
} as unknown as LoadedBuilderData;
const serverSyncState = getServerSyncState(data);
@@ -41,6 +42,7 @@ describe("getServerSyncState", () => {
["dataSources", "dataSources"],
["resources", "resources"],
["assets", "assets"],
["projectSettings", "projectSettings"],
["marketplaceProduct", "marketplaceProduct"],
])
);
@@ -26,6 +26,7 @@ import {
$styleSourceSelections,
$styles,
$marketplaceProduct,
$projectSettings,
$publisherHost,
resetDataStores,
} from "./data-stores";
@@ -78,6 +79,7 @@ const applyBuilderData = (data: LoadedBuilderData) => {
$styleSourceSelections.set(data.styleSourceSelections);
$styles.set(data.styles);
$marketplaceProduct.set(data.marketplaceProduct);
$projectSettings.set(data.projectSettings);
};
const applyBuilderMetadata = (data: LoadedBuilderData) => {
+5 -1
View File
@@ -39,7 +39,10 @@ import {
$selectedInstanceOutlines,
$hoveredInstanceOutline,
} from "~/shared/nano-states";
import { $marketplaceProduct } from "~/shared/sync/data-stores";
import {
$marketplaceProduct,
$projectSettings,
} from "~/shared/sync/data-stores";
import { $ephemeralStyles } from "~/canvas/stores";
import {
ImmerhinSyncObject,
@@ -96,6 +99,7 @@ const serverSyncStores = {
dataSources: $dataSources,
resources: $resources,
assets: $assets,
projectSettings: $projectSettings,
marketplaceProduct: $marketplaceProduct,
} as const;
+3 -1
View File
@@ -73,7 +73,9 @@ absorb occasional browser/backend flakes.
`pnpm e2e:builder:dev` defaults to `E2E_BUILDER_URL=https://127.0.0.1:3000`,
so it runs against the already-running Vite dev server instead of building and
serving the production bundle. Override `E2E_BUILDER_URL` when the dev server is
running on another port.
running on another port. The E2E harness maps `wstd.dev` and project subdomains
to loopback for Chromium when this URL is loopback, so focused local runs do not
require machine-level host-file edits.
The dev backend uses `E2E_DB_BOOTSTRAP=if-empty` and keeps Docker containers
running. The e2e runner still resets test data before each run.
@@ -17,6 +17,7 @@ export type SeededContentModeProject = {
linkInstanceId: string;
imageInstanceId: string;
videoInstanceId: string;
assetTemplateImageAssetId: string;
assetTemplateImageName: string;
assetTemplateName: string;
assetTemplateImageAlt: string;
@@ -446,7 +447,7 @@ const createContentModeBuildData = ({
{ type: "text", value: "System resource variable configured" },
{
type: "expression",
value: encodeDataSourceVariable(dataResourceSystemDataSourceId),
value: `${encodeDataSourceVariable(dataResourceSystemDataSourceId)}.data.year`,
},
],
},
@@ -968,6 +969,7 @@ export const prepareExistingContentModeProject = async ({
linkInstanceId,
imageInstanceId,
videoInstanceId,
assetTemplateImageAssetId: seededAssetTemplateImageAssetId,
assetTemplateImageName: seededAssetTemplateImageName,
assetTemplateName,
assetTemplateImageAlt,
+21
View File
@@ -15,6 +15,27 @@ export const waitForCanvasImage = async ({
});
};
export const waitForCanvasImageCount = async ({
page,
alt,
count,
}: {
page: Page;
alt: string;
count: number;
}) => {
const canvas = await waitForCanvasFrame({ page });
const images = canvas.getByRole("img", { name: alt });
const startedAt = Date.now();
while (Date.now() - startedAt < 10_000) {
if ((await images.count()) === count) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Expected ${count} images named ${JSON.stringify(alt)}.`);
};
export const waitForCanvasImageSourceName = async ({
page,
sourceName,
+27
View File
@@ -184,3 +184,30 @@ export const replaceCanvasTextAndApplyInlineFormats = async ({
throw error;
}
};
export const removeCanvasInlineLink = async ({
page,
text,
}: {
page: Page;
text: string;
}) => {
const { canvas, editable } = await startCanvasTextEditingByText({
page,
currentText: text,
});
await page.keyboard.press("ControlOrMeta+A");
await page.getByRole("button", { name: "Inline link", exact: true }).click();
const save = waitForChangeToBeSaved({ page });
try {
await canvas.locator("body").click({ position: { x: 1, y: 1 } });
await editable.waitFor({ state: "hidden", timeout: 1_000 });
await waitForCanvasText({ page, text });
await save;
} catch (error) {
await save.catch(() => undefined);
throw error;
}
};
+212
View File
@@ -0,0 +1,212 @@
import { createServer } from "node:http";
import type { Page } from "playwright";
import { selectCanvasTextInstance } from "./canvas-selection";
import { waitForChangeToBeSaved, waitForSyncStatus } from "./sync-status";
const getVariableForm = (page: Page) =>
page
.locator("form")
.filter({ has: page.locator('input[name="name"]') })
.last();
const closeVariablePanelAndWaitForSave = async ({ page }: { page: Page }) => {
const save = waitForChangeToBeSaved({ page });
await page.getByRole("button", { name: "Close" }).last().click();
await save;
await page
.getByText("New Variable", { exact: true })
.waitFor({ state: "hidden" });
await waitForSyncStatus({ page, status: "idle" });
};
const openNewVariablePanel = async ({ page }: { page: Page }) => {
await page.getByRole("tab", { name: "Settings" }).click();
await page.getByRole("button", { name: "Add data variable" }).click();
await page.getByText("New Variable", { exact: true }).waitFor();
};
const selectVariableType = async ({
page,
next,
}: {
page: Page;
next: string;
}) => {
await page
.getByText("Type", { exact: true })
.locator("xpath=following::button[1]")
.click();
await page
.getByRole("option", { name: new RegExp(`^${next}( Pro)?$`) })
.click();
};
const fillVariableName = async ({
page,
name,
}: {
page: Page;
name: string;
}) => {
await getVariableForm(page).locator('input[name="name"]').fill(name);
};
export const selectContentInstance = async ({ page }: { page: Page }) => {
await selectCanvasTextInstance({ page, text: "Initial content" });
await page
.getByText("No instance selected", { exact: true })
.waitFor({ state: "hidden" });
await page.getByRole("tab", { name: "Settings" }).click();
await page
.getByRole("button", { name: "Add data variable" })
.waitFor({ state: "visible" });
};
export const bindSelectedTextContentToExpression = async ({
page,
expression,
}: {
page: Page;
expression: string;
}) => {
await page.getByText("Text Content", { exact: true }).waitFor();
await page.getByText("Text Content", { exact: true }).hover();
await page
.getByText("Text Content", { exact: true })
.locator("xpath=following::button[@data-variant][1]")
.click();
const bindingDialog = page.getByRole("dialog", { name: "Binding" });
await bindingDialog.waitFor();
const expressionEditor = bindingDialog.locator(".cm-content").last();
await expressionEditor.click();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.insertText(expression);
const save = waitForChangeToBeSaved({ page });
try {
await page.keyboard.press("ControlOrMeta+Enter");
await save;
} catch (error) {
const dialogText = await bindingDialog.innerText().catch(() => "");
throw new Error(
`Expected Text Content binding ${JSON.stringify(expression)} to save. Binding dialog: ${dialogText}`,
{ cause: error }
);
}
await waitForSyncStatus({ page, status: "idle" });
await page.keyboard.press("Escape");
};
export const createHttpResourceVariable = async ({
page,
name,
url,
}: {
page: Page;
name: string;
url: string;
}) => {
await openNewVariablePanel({ page });
await fillVariableName({ page, name });
await selectVariableType({ page, next: "Resource" });
await page.getByRole("textbox", { name: "URL" }).fill(url);
await closeVariablePanelAndWaitForSave({ page });
};
export const createGraphqlResourceVariable = async ({
page,
name,
url,
query,
}: {
page: Page;
name: string;
url: string;
query: string;
}) => {
await openNewVariablePanel({ page });
await fillVariableName({ page, name });
await selectVariableType({ page, next: "GraphQL" });
await page.getByRole("textbox", { name: "URL" }).fill(url);
await page.getByRole("textbox", { name: "Query" }).fill(query);
await closeVariablePanelAndWaitForSave({ page });
};
export const createSystemResourceVariable = async ({
page,
name,
resource = "Sitemap",
}: {
page: Page;
name: string;
resource?: "Sitemap" | "Current Date" | "Assets";
}) => {
await openNewVariablePanel({ page });
await fillVariableName({ page, name });
await selectVariableType({ page, next: "System Resource" });
if (resource !== "Sitemap") {
await getVariableForm(page)
.getByText("Resource", { exact: true })
.locator("xpath=following::button[1]")
.click();
await page.getByRole("option", { name: resource, exact: true }).click();
}
await closeVariablePanelAndWaitForSave({ page });
};
export const startJsonResourceServer = async ({ title }: { title: string }) => {
const server = createServer((_request, response) => {
response.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
});
response.end(JSON.stringify({ title }));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (address === null || typeof address === "string") {
await new Promise<void>((resolve) => server.close(() => resolve()));
throw new Error("Expected a numeric local resource server port.");
}
return {
url: `http://127.0.0.1:${address.port}/posts`,
close: async () =>
await new Promise<void>((resolve) => server.close(() => resolve())),
};
};
export const startGraphqlResourceServer = async ({
title,
}: {
title: string;
}) => {
const server = createServer((request, response) => {
if (request.method !== "POST") {
response.writeHead(405).end();
return;
}
response.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
});
response.end(JSON.stringify({ data: { post: { title } } }));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (address === null || typeof address === "string") {
await new Promise<void>((resolve) => server.close(() => resolve()));
throw new Error("Expected a numeric local GraphQL server port.");
}
return {
url: `http://127.0.0.1:${address.port}/graphql`,
close: async () =>
await new Promise<void>((resolve) => server.close(() => resolve())),
};
};
+211 -2
View File
@@ -1,12 +1,15 @@
import { execFile } from "node:child_process";
import { execFile, spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import type { Page as PlaywrightPage } from "playwright";
import { bundleVersion } from "@webstudio-is/protocol";
import { getStyleDeclKey, type Page } from "@webstudio-is/sdk";
import { loadDevBuild } from "../db";
import { stopChildProcess } from "../process";
const execFileAsync = promisify(execFile);
const cliLocalPath = fileURLToPath(
@@ -15,6 +18,55 @@ const cliLocalPath = fileURLToPath(
type BuildRow = Awaited<ReturnType<typeof loadDevBuild>>;
const getAvailablePort = async () => {
const server = createServer();
server.unref();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (address === null || typeof address === "string") {
await new Promise<void>((resolve) => server.close(() => resolve()));
throw new Error("Expected a numeric port for generated-app preview.");
}
await new Promise<void>((resolve) => server.close(() => resolve()));
return address.port;
};
const waitForGeneratedPreview = async ({
process,
url,
output,
}: {
process: ChildProcess;
url: string;
output: () => string;
}) => {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
if (process.exitCode !== null || process.signalCode !== null) {
throw new Error(
`Generated preview exited before it became ready at ${url}. Output:\n${output()}`
);
}
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(2_000),
});
if (response.ok) {
return await response.text();
}
} catch {
// The generated production server is still starting.
}
await new Promise<void>((resolve) => setTimeout(resolve, 250));
}
throw new Error(
`Generated preview did not become ready at ${url}. Output:\n${output()}`
);
};
const parseBuildField = <Value>(build: BuildRow, field: keyof BuildRow) =>
JSON.parse(String(build[field])) as Value;
@@ -84,7 +136,7 @@ export const expectGeneratedAppBuild = async ({
expectedText,
}: {
projectId: string;
expectedText: string;
expectedText?: string;
}) => {
const tempDir = await mkdtemp(join(tmpdir(), "webstudio-e2e-generated-"));
try {
@@ -105,6 +157,7 @@ export const expectGeneratedAppBuild = async ({
}
);
if (expectedText !== undefined) {
const generatedHome = await readFile(
join(tempDir, "app", "__generated__", "_index.tsx"),
"utf8"
@@ -114,11 +167,167 @@ export const expectGeneratedAppBuild = async ({
`Expected generated home route to include "${expectedText}".`
);
}
}
} finally {
await rm(tempDir, { recursive: true, force: true });
}
};
const withGeneratedPreview = async <Result>({
projectId,
callback,
}: {
projectId: string;
callback: ({ url, html }: { url: string; html: string }) => Promise<Result>;
}) => {
const tempDir = await mkdtemp(join(tmpdir(), "webstudio-e2e-generated-"));
let preview: ChildProcess | undefined;
try {
const build = await loadDevBuild({ projectId });
await mkdir(join(tempDir, ".webstudio"), { recursive: true });
await writeFile(
join(tempDir, ".webstudio", "data.json"),
`${JSON.stringify(createLocalBundleFromDevBuild(build), undefined, 2)}\n`
);
const port = await getAvailablePort();
const output: string[] = [];
preview = spawn(
process.execPath,
[
cliLocalPath,
"preview",
"--assets",
"false",
"--template",
"defaults",
"--template",
"react-router",
"--host",
"127.0.0.1",
"--port",
String(port),
],
{
cwd: tempDir,
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, PWD: tempDir },
}
);
const appendOutput = (chunk: unknown) => {
output.push(String(chunk));
while (output.join("").length > 8_000) {
output.shift();
}
};
preview.stdout?.on("data", appendOutput);
preview.stderr?.on("data", appendOutput);
const url = `http://127.0.0.1:${port}/`;
const html = await waitForGeneratedPreview({
process: preview,
url,
output: () => output.join(""),
});
return await callback({ url, html });
} finally {
if (preview !== undefined) {
// The CLI starts npm and the generated server below it.
await stopChildProcess(preview, { killGroup: true });
}
await rm(tempDir, { recursive: true, force: true });
}
};
export const expectGeneratedAppToRender = async ({
projectId,
expectedText,
path = "/",
}: {
projectId: string;
expectedText: string;
path?: string;
}) => {
await withGeneratedPreview({
projectId,
callback: async ({ url, html }) => {
const response =
path === "/"
? undefined
: await fetch(new URL(path, url), { redirect: "manual" });
const routeHtml = response === undefined ? html : await response.text();
if (
(response !== undefined && response.ok === false) ||
routeHtml.includes(expectedText) === false
) {
throw new Error(
`Expected generated preview route ${JSON.stringify(path)} at ${url} to render ${JSON.stringify(expectedText)}.`
);
}
},
});
};
export const expectGeneratedAppRedirect = async ({
projectId,
path,
status,
location,
}: {
projectId: string;
path: string;
status: number;
location: string;
}) => {
await withGeneratedPreview({
projectId,
callback: async ({ url }) => {
const response = await fetch(new URL(path, url), {
redirect: "manual",
});
const actualLocation = response.headers.get("location");
if (response.status !== status || actualLocation !== location) {
throw new Error(
`Expected generated preview redirect ${JSON.stringify(path)} to return ${status} with location ${JSON.stringify(location)}. Received ${response.status} with location ${JSON.stringify(actualLocation)}.`
);
}
},
});
};
export const expectGeneratedAppNavigation = async ({
projectId,
page,
linkText,
targetPath,
}: {
projectId: string;
page: PlaywrightPage;
linkText: string;
targetPath: string;
}) => {
await withGeneratedPreview({
projectId,
callback: async ({ url }) => {
const targetUrl = new URL(targetPath, url).toString();
await page.goto(url);
await page.getByRole("link", { name: linkText, exact: true }).click();
await page.waitForURL(targetUrl);
const activeLink = page.getByRole("link", {
name: linkText,
exact: true,
});
const ariaCurrent = await activeLink.getAttribute("aria-current");
if (ariaCurrent !== "page") {
throw new Error(
`Expected generated preview link ${JSON.stringify(linkText)} to be active after navigating to ${JSON.stringify(targetPath)}. Received aria-current=${JSON.stringify(ariaCurrent)}.`
);
}
},
});
};
export const expectGeneratedRedirects = async ({
projectId,
expectedRedirects,
+131
View File
@@ -33,6 +33,26 @@ export const fillSelectedStringProperty = async ({
await save;
};
export const fillSelectedNumberProperty = async ({
page,
label,
value,
}: {
page: Page;
label: string;
value: number;
}) => {
const propertyLabel = getPropertyLabel({ page, label });
await propertyLabel.waitFor({ state: "visible", timeout: 10_000 });
const input = getNumberPropertyInput({ page, label });
await input.waitFor({ state: "visible", timeout: 10_000 });
const save = waitForChangeToBeSaved({ page });
await input.fill(String(value));
await input.blur();
await save;
};
export const chooseSelectedAssetProperty = async ({
page,
label,
@@ -85,6 +105,28 @@ const getPropertyInput = ({
? page.getByPlaceholder("https://www.url.com").first()
: page.locator("textarea").first();
const getNumberPropertyInput = ({
page,
label,
}: {
page: Page;
label: string;
}) =>
getPropertyLabel({ page, label }).locator(
"xpath=ancestor::*[.//input[@type='number']][1]//input[@type='number']"
);
const getBooleanPropertySwitch = ({
page,
label,
}: {
page: Page;
label: string;
}) =>
getPropertyLabel({ page, label }).locator(
"xpath=ancestor::*[.//*[@role='switch']][1]//*[@role='switch']"
);
export const waitForSelectedStringPropertyValue = async ({
page,
label,
@@ -116,3 +158,92 @@ export const waitForSelectedStringPropertyValue = async ({
`Expected selected property "${label}" to have value "${value}", received "${await input.inputValue()}"`
);
};
export const waitForSelectedNumberPropertyValue = async ({
page,
label,
value,
}: {
page: Page;
label: string;
value: number;
}) => {
const propertyLabel = getPropertyLabel({ page, label });
await propertyLabel.waitFor({ state: "visible", timeout: 10_000 });
const input = getNumberPropertyInput({ page, label });
await input.waitFor({ state: "visible", timeout: 10_000 });
const expected = String(value);
const startedAt = Date.now();
while (Date.now() - startedAt < 10_000) {
if ((await input.inputValue()) === expected) {
return;
}
await new Promise<void>((resolve) => setTimeout(resolve, 100));
}
throw new Error(
`Expected selected number property "${label}" to have value "${expected}", received "${await input.inputValue()}"`
);
};
export const setSelectedBooleanProperty = async ({
page,
label,
checked,
}: {
page: Page;
label: string;
checked: boolean;
}) => {
const propertyLabel = getPropertyLabel({ page, label });
await propertyLabel.waitFor({ state: "visible", timeout: 10_000 });
const control = getBooleanPropertySwitch({ page, label });
await control.waitFor({ state: "visible", timeout: 10_000 });
if ((await control.getAttribute("aria-checked")) === String(checked)) {
return;
}
const save = waitForChangeToBeSaved({ page });
await control.click();
await save;
};
export const waitForSelectedBooleanPropertyValue = async ({
page,
label,
checked,
}: {
page: Page;
label: string;
checked: boolean;
}) => {
const propertyLabel = getPropertyLabel({ page, label });
await propertyLabel.waitFor({ state: "visible", timeout: 10_000 });
const control = getBooleanPropertySwitch({ page, label });
await control.waitFor({ state: "visible", timeout: 10_000 });
const expected = String(checked);
const startedAt = Date.now();
while (Date.now() - startedAt < 10_000) {
if ((await control.getAttribute("aria-checked")) === expected) {
return;
}
await new Promise<void>((resolve) => setTimeout(resolve, 100));
}
throw new Error(
`Expected selected boolean property "${label}" to be ${expected}, received "${await control.getAttribute("aria-checked")}"`
);
};
export const resetSelectedProperty = async ({
page,
label,
}: {
page: Page;
label: string;
}) => {
const propertyLabel = getPropertyLabel({ page, label });
const save = waitForChangeToBeSaved({ page });
await propertyLabel.click({ modifiers: ["Alt"] });
await save;
};
+65
View File
@@ -0,0 +1,65 @@
import type { Browser, BrowserContext, Page } from "playwright";
import { expect, test, vi } from "vitest";
import {
createBrowserScopeForBrowser,
getBrowserLaunchOptions,
newPage,
} from "./harness";
test("maps Builder and project wstd.dev hosts to loopback for local browser runs", () => {
expect(getBrowserLaunchOptions("https://127.0.0.1:5174")).toMatchObject({
args: [
"--host-resolver-rules=MAP wstd.dev 127.0.0.1,MAP *.wstd.dev 127.0.0.1",
],
});
});
test("does not override host resolution for non-loopback Builder URLs", () => {
expect(getBrowserLaunchOptions("https://builder.example.com")).toEqual({});
});
test("keeps timed-out work on its closed context after a scope reset", async () => {
const contexts: Array<{
closed: boolean;
context: BrowserContext;
page: Page;
}> = [];
const browser = {
newContext: vi.fn(async () => {
const record = {
closed: false,
page: {} as Page,
context: undefined as unknown as BrowserContext,
};
record.context = {
newPage: vi.fn(async () => {
if (record.closed) {
throw new Error("context closed");
}
return record.page;
}),
close: vi.fn(async () => {
record.closed = true;
}),
} as unknown as BrowserContext;
contexts.push(record);
return record.context;
}),
} as Pick<Browser, "newContext">;
const scope = await createBrowserScopeForBrowser(browser);
let continueTimedOutWork: (() => void) | undefined;
const timedOutWork = scope.run(async () => {
await new Promise<void>((resolve) => {
continueTimedOutWork = resolve;
});
return await newPage();
});
await scope.reset();
continueTimedOutWork?.();
await expect(timedOutWork).rejects.toThrow("context closed");
await expect(scope.run(newPage)).resolves.toBe(contexts[1]?.page);
expect(contexts[0]?.closed).toBe(true);
await scope.close();
});
+78 -3
View File
@@ -1,10 +1,12 @@
import { parseBuilderUrl } from "@webstudio-is/protocol";
import { basename } from "node:path";
import { AsyncLocalStorage } from "node:async_hooks";
import {
chromium,
type Browser,
type BrowserContext,
type BrowserContextOptions,
type LaunchOptions,
} from "playwright";
import env from "../app/env/env.server";
@@ -106,13 +108,42 @@ export const getSuites = () => suites;
let browser: Browser | undefined;
let context: BrowserContext | undefined;
export type BrowserScope = {
run: <Result>(task: () => Promise<Result>) => Promise<Result>;
reset: () => Promise<void>;
close: () => Promise<void>;
};
type BrowserScopeState = {
context: BrowserContext;
isolatedContexts: Set<BrowserContext>;
};
const browserScopeStorage = new AsyncLocalStorage<BrowserScopeState>();
const browserContextOptions: BrowserContextOptions = {
ignoreHTTPSErrors: true,
permissions: ["clipboard-read", "clipboard-write"],
};
const isLoopbackHost = (hostname: string) =>
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
export const getBrowserLaunchOptions = (url = builderUrl): LaunchOptions => {
const hostname = new URL(url).hostname;
if (isLoopbackHost(hostname) === false) {
return {};
}
return {
// Builder tests use project subdomains such as p-project-id.wstd.dev.
args: [
"--host-resolver-rules=MAP wstd.dev 127.0.0.1,MAP *.wstd.dev 127.0.0.1",
],
};
};
export const startBrowser = async () => {
browser = await chromium.launch();
browser = await chromium.launch(getBrowserLaunchOptions());
context = await browser.newContext(browserContextOptions);
};
@@ -123,12 +154,53 @@ export const stopBrowser = async () => {
browser = undefined;
};
export const createBrowserScopeForBrowser = async (
browserInstance: Pick<Browser, "newContext">
): Promise<BrowserScope> => {
const createState = async (): Promise<BrowserScopeState> => {
return {
context: await browserInstance.newContext(browserContextOptions),
isolatedContexts: new Set(),
};
};
let state = await createState();
const closeState = async (target: BrowserScopeState) => {
await Promise.allSettled(
[...target.isolatedContexts].map((isolatedContext) =>
isolatedContext.close()
)
);
target.isolatedContexts.clear();
await target.context.close().catch(() => undefined);
};
return {
run: async (task) => {
const activeState = state;
return await browserScopeStorage.run(activeState, task);
},
reset: async () => {
const previousState = state;
await closeState(previousState);
state = await createState();
},
close: async () => await closeState(state),
};
};
export const createBrowserScope = async (): Promise<BrowserScope> => {
if (browser === undefined) {
throw new Error("Browser is not initialized");
}
return await createBrowserScopeForBrowser(browser);
};
export const newPage = async () => {
if (context === undefined) {
const activeContext = browserScopeStorage.getStore()?.context ?? context;
if (activeContext === undefined) {
throw new Error("Browser context is not initialized");
}
return await context.newPage();
return await activeContext.newPage();
};
export const newIsolatedPage = async () => {
@@ -137,10 +209,13 @@ export const newIsolatedPage = async () => {
}
const isolatedContext = await browser.newContext(browserContextOptions);
const scope = browserScopeStorage.getStore();
scope?.isolatedContexts.add(isolatedContext);
return {
page: await isolatedContext.newPage(),
close: async () => {
scope?.isolatedContexts.delete(isolatedContext);
await isolatedContext.close();
},
};
+99
View File
@@ -0,0 +1,99 @@
import { EventEmitter } from "node:events";
import type { ChildProcess } from "node:child_process";
import { expect, test, vi } from "vitest";
import { stopChildProcess } from "./process";
const createChild = () => {
const child = new EventEmitter() as ChildProcess;
Object.defineProperties(child, {
exitCode: { value: null, writable: true },
signalCode: { value: null, writable: true },
});
child.kill = vi.fn(() => true);
return child;
};
test("stops a child gracefully without escalating", async () => {
const child = createChild();
vi.mocked(child.kill).mockImplementation((signal) => {
Object.defineProperty(child, "signalCode", {
value: signal,
writable: true,
});
child.emit("exit", null, signal);
return true;
});
await stopChildProcess(child, { timeoutMs: 1 });
expect(child.kill).toHaveBeenCalledTimes(1);
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
});
test("escalates a child that ignores SIGTERM", async () => {
const child = createChild();
vi.mocked(child.kill).mockImplementation((signal) => {
if (signal === "SIGKILL") {
Object.defineProperty(child, "signalCode", {
value: signal,
writable: true,
});
child.emit("exit", null, signal);
}
return true;
});
await stopChildProcess(child, { timeoutMs: 1 });
expect(child.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
});
test("rejects when a child remains alive after SIGKILL", async () => {
const child = createChild();
Object.defineProperty(child, "pid", { value: 42 });
await expect(stopChildProcess(child, { timeoutMs: 1 })).rejects.toThrow(
"Child process 42 did not exit after SIGKILL"
);
expect(child.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
});
test.runIf(process.platform !== "win32")(
"signals the detached process group used by generated previews",
async () => {
const child = createChild();
Object.defineProperty(child, "pid", { value: 42 });
let groupRunning = true;
const kill = vi
.spyOn(process, "kill")
.mockImplementation((_pid, signal) => {
if (signal === 0) {
if (groupRunning) {
return true;
}
throw Object.assign(new Error("Process group not found"), {
code: "ESRCH",
});
}
if (signal === "SIGKILL") {
groupRunning = false;
Object.defineProperty(child, "signalCode", {
value: signal,
writable: true,
});
child.emit("exit", null, signal);
}
return true;
});
await stopChildProcess(child, { killGroup: true, timeoutMs: 1 });
expect(kill).toHaveBeenCalledWith(-42, "SIGTERM");
expect(kill).toHaveBeenCalledWith(-42, "SIGKILL");
expect(child.kill).not.toHaveBeenCalled();
kill.mockRestore();
}
);
+70
View File
@@ -0,0 +1,70 @@
import type { ChildProcess } from "node:child_process";
const delay = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
const hasExited = (child: ChildProcess) =>
child.exitCode !== null || child.signalCode !== null;
const isProcessGroupRunning = (pid: number) => {
try {
process.kill(-pid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ESRCH") {
return false;
}
throw error;
}
};
export const stopChildProcess = async (
child: ChildProcess,
{
killGroup = false,
timeoutMs = 5_000,
}: { killGroup?: boolean; timeoutMs?: number } = {}
) => {
const usesProcessGroup =
killGroup && process.platform !== "win32" && child.pid !== undefined;
const isRunning = () =>
usesProcessGroup
? isProcessGroupRunning(child.pid as number)
: hasExited(child) === false;
const waitUntilStopped = async () => {
const deadline = Date.now() + timeoutMs;
while (isRunning() && Date.now() < deadline) {
await delay(Math.min(25, Math.max(1, deadline - Date.now())));
}
return isRunning() === false;
};
if (isRunning() === false) {
return;
}
const sendSignal = (signal: NodeJS.Signals) => {
if (usesProcessGroup) {
try {
process.kill(-(child.pid as number), signal);
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ESRCH") {
return;
}
}
}
child.kill(signal);
};
sendSignal("SIGTERM");
if (await waitUntilStopped()) {
return;
}
sendSignal("SIGKILL");
if ((await waitUntilStopped()) === false) {
throw new Error(
`Child process${child.pid === undefined ? "" : ` ${child.pid}`} did not exit after SIGKILL`
);
}
};
+4 -3
View File
@@ -106,8 +106,9 @@ install_playwright_chromium() {
esac
}
build_builder_app() {
build_e2e_apps() {
pnpm --dir "$ROOT_DIR" --filter=@webstudio-is/builder build
pnpm --dir "$ROOT_DIR" --filter=@webstudio-is/sdk-components-react-router build
}
run_builder_e2e_tests() {
@@ -154,8 +155,8 @@ if [ "$E2E_RUN_TESTS" = "true" ]; then
install_playwright_chromium
if [ "${E2E_BUILDER_URL:-}" = "" ]; then
run_step "build builder app" "$E2E_BUILDER_BUILD_TIMEOUT_SECONDS" \
build_builder_app
run_step "build builder and generated preview dependencies" "$E2E_BUILDER_BUILD_TIMEOUT_SECONDS" \
build_e2e_apps
fi
run_step "run builder e2e tests" "$E2E_TEST_COMMAND_TIMEOUT_SECONDS" \
+73 -60
View File
@@ -1,31 +1,23 @@
import { spawn, type ChildProcess } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
import { readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import {
builderUrl,
createBrowserScope,
dashboardUrl,
getSuites,
newPage,
postgrestUrl,
startBrowser,
stopBrowser,
type BrowserScope,
} from "./harness";
import { resetDatabase } from "./db";
import { logPerf, measure, printPerfSummary } from "./perf";
import "./tests/animation-runtime.[shard-1].e2e";
import "./tests/content-mode-editing.[shard-1].e2e";
import "./tests/content-mode-editing.[shard-2].e2e";
import "./tests/content-mode-editing.[shard-3].e2e";
import "./tests/data-variables-runtime.[shard-2].e2e";
import "./tests/pages-actions.[shard-1].e2e";
import "./tests/pages-actions.[shard-2].e2e";
import "./tests/pages-actions.[shard-3].e2e";
import "./tests/preview-links.[shard-1].e2e";
import "./tests/project-settings-runtime.[shard-2].e2e";
import "./tests/props-runtime.[shard-2].e2e";
import "./tests/share-link-permissions.[shard-1].e2e";
import "./tests/slot-keyboard.[shard-3].e2e";
import "./tests/style-panel-runtime.[shard-1].e2e";
import "./tests/style-panel-runtime.[shard-2].e2e";
import "./tests/style-panel-runtime.[shard-3].e2e";
import { getE2eTestModules } from "./test-modules";
import { stopChildProcess } from "./process";
import { runWithTimeout } from "./timeout";
const testTimeoutMs =
Number.parseInt(process.env.E2E_TEST_TIMEOUT_MS ?? "", 10) || 120_000;
@@ -40,6 +32,17 @@ const testFilters = [
: [process.env.E2E_TEST_FILTER]),
];
// Shard ownership lives in filenames, so adding a test file requires no second
// registry update and focused shards avoid unrelated fixture setup.
const testModules = getE2eTestModules(
await readdir(fileURLToPath(new URL("./tests", import.meta.url))),
testShard
);
for (const module of testModules) {
await import(module);
}
process.env.NODE_TLS_REJECT_UNAUTHORIZED ??= "0";
const waitForHttp = async (url: string, timeoutMs: number) => {
@@ -88,7 +91,7 @@ const waitForPostgrest = async () => {
const waitForBuilder = async (child: ChildProcess) => {
await Promise.race([
waitForHttp(dashboardUrl, 60_000),
waitForHttp(builderUrl, 60_000),
new Promise<never>((_, reject) => {
child.once("exit", (code, signal) => {
reject(
@@ -113,27 +116,6 @@ const warmLoginRoute = async () => {
}
};
const withTimeout = async <Result>(
name: string,
timeoutMs: number,
task: () => Promise<Result>
) => {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
task(),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`Timed out after ${timeoutMs}ms: ${name}`));
}, timeoutMs);
}),
]);
} finally {
clearTimeout(timeout);
}
};
const pipeBuilderOutput = (child: ChildProcess) => {
child.stdout?.on("data", (chunk) => {
process.stdout.write(`[builder] ${chunk}`);
@@ -146,28 +128,39 @@ const pipeBuilderOutput = (child: ChildProcess) => {
const runSuiteTests = async ({
suite,
tests,
browserScope,
onTimeout,
}: {
suite: ReturnType<typeof getSuites>[number];
tests: ReturnType<typeof getSuites>[number]["tests"];
browserScope: BrowserScope;
onTimeout: () => Promise<void>;
}) => {
try {
for (const test of tests) {
const startedAt = Date.now();
await withTimeout(
`${suite.name} › ${test.name}`,
testTimeoutMs,
async () => {
await runWithTimeout({
name: `${suite.name} › ${test.name}`,
timeoutMs: testTimeoutMs,
onTimeout,
task: async () =>
await browserScope.run(async () => {
await suite.beforeEach?.();
await test.run();
}
);
}),
});
const duration = Date.now() - startedAt;
console.info(`✓ ${suite.name} › ${test.name} (${duration}ms)`);
}
} finally {
const afterAllStartedAt = Date.now();
await withTimeout(`${suite.name} afterAll`, testTimeoutMs, async () => {
await suite.afterAll?.();
await runWithTimeout({
name: `${suite.name} afterAll`,
timeoutMs: testTimeoutMs,
onTimeout,
task: async () => {
await browserScope.run(async () => await suite.afterAll?.());
},
});
logPerf(`${suite.name} afterAll`, afterAllStartedAt);
}
@@ -204,17 +197,10 @@ const startBuilder = async (): Promise<ChildProcess | undefined> => {
};
const stopBuilder = async (child: ChildProcess | undefined) => {
if (child === undefined || child.killed) {
if (child === undefined) {
return;
}
child.kill("SIGTERM");
await Promise.race([
new Promise<void>((resolve) => child.once("exit", () => resolve())),
delay(5_000).then(() => {
child.kill("SIGKILL");
}),
]);
await stopChildProcess(child);
};
const getRunnableSuites = () => {
@@ -271,6 +257,7 @@ const run = async () => {
const totalStartedAt = Date.now();
const bootStartedAt = Date.now();
let builder: ChildProcess | undefined;
const browserScopes: BrowserScope[] = [];
try {
const runnableSuites = getRunnableSuites();
@@ -291,24 +278,50 @@ const run = async () => {
await measure("reset database", resetDatabase);
for (const { suite } of runnableSuites) {
const scopedSuites = await Promise.all(
runnableSuites.map(async (runnableSuite) => {
const browserScope = await createBrowserScope();
browserScopes.push(browserScope);
return { ...runnableSuite, browserScope };
})
);
let resetAllBrowserScopesPromise: Promise<void> | undefined;
const resetAllBrowserScopes = () => {
resetAllBrowserScopesPromise ??= Promise.allSettled(
browserScopes.map((scope) => scope.reset())
).then(() => undefined);
return resetAllBrowserScopesPromise;
};
for (const { suite, browserScope } of scopedSuites) {
const beforeAllStartedAt = Date.now();
await withTimeout(`${suite.name} beforeAll`, testTimeoutMs, async () => {
await suite.beforeAll?.();
await runWithTimeout({
name: `${suite.name} beforeAll`,
timeoutMs: testTimeoutMs,
onTimeout: resetAllBrowserScopes,
task: async () => {
await browserScope.run(async () => await suite.beforeAll?.());
},
});
logPerf(`${suite.name} beforeAll`, beforeAllStartedAt);
}
await Promise.all(
runnableSuites.map(async ({ suite, tests }) => {
scopedSuites.map(async ({ suite, tests, browserScope }) => {
const suiteStartedAt = Date.now();
await runSuiteTests({ suite, tests });
await runSuiteTests({
suite,
tests,
browserScope,
onTimeout: resetAllBrowserScopes,
});
const suiteDuration = Date.now() - suiteStartedAt;
console.info(`✓ ${suite.name} completed (${suiteDuration}ms)`);
})
);
logPerf("tests", testsStartedAt);
} finally {
await Promise.allSettled(browserScopes.map((scope) => scope.close()));
await stopBrowser();
await stopBuilder(builder);
logPerf("runner total", totalStartedAt);
+6 -1
View File
@@ -207,6 +207,7 @@ BEGIN
resources,
instances,
"marketplaceProduct",
"projectSettings",
"publishStatus",
"projectId",
id,
@@ -225,6 +226,7 @@ BEGIN
resources,
instances,
"marketplaceProduct",
"projectSettings",
"publishStatus",
"projectId",
extensions.uuid_generate_v4() as id,
@@ -265,6 +267,7 @@ BEGIN
"dataSources" = '[]'::text,
resources = '[]'::text,
"marketplaceProduct" = '{}'::text,
"projectSettings" = '{"meta":{},"compiler":{}}'::text,
"isCleaned" = TRUE
WHERE deployment IS NOT NULL
AND id NOT IN (SELECT "buildId" FROM latest_builds)
@@ -436,6 +439,7 @@ CREATE TABLE public."Build" (
"lastTransactionId" text,
resources text DEFAULT '[]'::text NOT NULL,
"marketplaceProduct" text DEFAULT '{}'::text NOT NULL,
"projectSettings" text DEFAULT '{"meta":{},"compiler":{}}'::text NOT NULL,
"isCleaned" boolean DEFAULT false
);
@@ -679,7 +683,8 @@ BEGIN
"dataSources" = source."dataSources",
"resources" = source."resources",
"instances" = source."instances",
"marketplaceProduct" = source."marketplaceProduct"
"marketplaceProduct" = source."marketplaceProduct",
"projectSettings" = source."projectSettings"
FROM (
SELECT * FROM "Build"
WHERE "projectId" = project_id AND "id" = from_build_id
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "vitest";
import { getE2eTestModules } from "./test-modules";
test("discovers every e2e module and filters ownership by shard tag", () => {
const files = [
"pages-drag.[shard-3].e2e.ts",
"navigator-drag.[shard-1].e2e.ts",
"helper.ts",
];
expect(getE2eTestModules(files)).toEqual([
"./tests/navigator-drag.[shard-1].e2e.ts",
"./tests/pages-drag.[shard-3].e2e.ts",
]);
expect(getE2eTestModules(files, "shard-3")).toEqual([
"./tests/pages-drag.[shard-3].e2e.ts",
]);
});
+12
View File
@@ -0,0 +1,12 @@
export const getE2eTestModules = (
fileNames: readonly string[],
shard?: string
) =>
fileNames
.filter((fileName) => fileName.endsWith(".e2e.ts"))
.filter(
(fileName) =>
shard === undefined || shard === "" || fileName.includes(`[${shard}]`)
)
.sort()
.map((fileName) => `./tests/${fileName}`);
@@ -9,6 +9,7 @@ import { openProjectBuilder, waitForCanvasText } from "../flows/builder";
import {
selectCanvasImage,
waitForCanvasImage,
waitForCanvasImageCount,
waitForCanvasImageSourceName,
waitForCanvasVideoSource,
} from "../flows/canvas-media";
@@ -17,14 +18,19 @@ import {
waitForCanvasTextStyleCount,
} from "../flows/canvas-style";
import { chooseSelectedAssetProperty } from "../flows/props-panel";
import { waitForSyncStatus } from "../flows/sync-status";
import {
waitForChangeToBeSaved,
waitForSyncStatus,
} from "../flows/sync-status";
import { insertTemplateAfterCanvasText } from "../flows/template-insertion";
import {
getSharedContentModeProject,
setupSharedContentModeProject,
createContentModeProject,
} from "../fixtures/content-mode-suite";
import { newIsolatedPage, test } from "../harness";
import { measure } from "../perf";
import { loadDevBuild } from "../db";
test.beforeAll(async () => {
await setupSharedContentModeProject();
@@ -188,6 +194,13 @@ test("Editor can upload, replace, and delete asset in content mode", async () =>
let replacementAssetTitle = "";
await measure("content mode replace asset", async () => {
await openAssetDetails({ page, filename: uploadedAssetTitle });
const assetId = await page.locator("#asset-manager-id").inputValue();
if (assetId.length === 0) {
throw new Error(
"Expected Asset details to expose the uploaded asset id"
);
}
await page.getByText("0 uses", { exact: true }).waitFor();
replacementAssetTitle = await replaceSelectedAsset({
page,
filename: replacementFilename,
@@ -315,3 +328,109 @@ test("Editor can replace image source with asset in content mode", async () => {
await close();
}
});
test("Builder copy/paste preserves image asset references after reload", async () => {
const fixture = await createContentModeProject({
email: "asset-copy-e2e@webstudio.test",
title: "Asset Copy E2E",
assetNamePrefix: "asset-copy-",
editorToken: "asset-copy-e2e-editor-token",
builderToken: "asset-copy-e2e-builder-token",
});
const { page, close } = await newIsolatedPage();
const getImageAssetReferenceCount = async () => {
const build = await loadDevBuild({ projectId: fixture.projectId });
const props = JSON.parse(build.props) as Array<{
name: string;
type: string;
value: string;
}>;
const references = props.filter(
(prop) =>
prop.name === "src" &&
prop.type === "asset" &&
prop.value === fixture.assetTemplateImageAssetId
);
return references.length;
};
try {
await measure("asset copy open builder", async () => {
await openProjectBuilder({
page,
projectId: fixture.projectId,
authToken: fixture.editorToken,
mode: "content",
});
});
await waitForCanvasText({ page, text: "Initial content" });
const initialAssetReferenceCount = await getImageAssetReferenceCount();
await measure("asset copy insert template", async () => {
await insertTemplateAfterCanvasText({
page,
anchorText: "Initial content",
templateName: fixture.assetTemplateName,
});
});
await waitForCanvasImage({
page,
alt: fixture.assetTemplateImageAlt,
});
await measure("asset copy open build mode", async () => {
await openProjectBuilder({
page,
projectId: fixture.projectId,
authToken: fixture.builderToken,
});
});
await waitForCanvasImage({
page,
alt: fixture.assetTemplateImageAlt,
});
await selectCanvasImage({
page,
alt: fixture.assetTemplateImageAlt,
});
await page.keyboard.press("ControlOrMeta+C");
const save = waitForChangeToBeSaved({ page });
await page.keyboard.press("ControlOrMeta+V");
await save;
await waitForSyncStatus({ page, status: "idle" });
await waitForCanvasImageCount({
page,
alt: fixture.assetTemplateImageAlt,
count: 2,
});
if (
(await getImageAssetReferenceCount()) !==
initialAssetReferenceCount + 2
) {
throw new Error(
"Expected insertion and paste to add two asset references."
);
}
await measure("asset copy reload builder", async () => {
await openProjectBuilder({
page,
projectId: fixture.projectId,
authToken: fixture.builderToken,
});
});
await waitForCanvasImageCount({
page,
alt: fixture.assetTemplateImageAlt,
count: 2,
});
if (
(await getImageAssetReferenceCount()) !==
initialAssetReferenceCount + 2
) {
throw new Error("Expected reload to preserve copied asset references.");
}
} finally {
await close();
}
});
@@ -16,11 +16,14 @@ import {
} from "../flows/canvas-media";
import {
pasteCanvasText,
removeCanvasInlineLink,
replaceCanvasText,
replaceCanvasTextAndApplyInlineFormats,
} from "../flows/content-editing";
import {
fillSelectedNumberProperty,
fillSelectedStringProperty,
waitForSelectedNumberPropertyValue,
waitForSelectedStringPropertyValue,
} from "../flows/props-panel";
import {
@@ -83,6 +86,63 @@ const getElementTagWithTextCount = async ({
).length;
};
const getElementHrefWithText = async ({
projectId,
text,
}: {
projectId: string;
text: string;
}) => {
const build = await loadDevBuild({ projectId });
const instances = JSON.parse(build.instances) as Array<{
id: string;
tag?: string;
children?: Array<{ type: string; value?: string }>;
}>;
const props = JSON.parse(build.props) as Array<{
instanceId: string;
name: string;
type: string;
value: unknown;
}>;
const instancesById = new Map(
instances.map((instance) => [instance.id, instance])
);
const instanceContainsText = (instanceId: string): boolean => {
const instance = instancesById.get(instanceId);
if (instance === undefined) {
return false;
}
return (
instance.children?.some((child) => {
if (child.type === "text") {
return child.value?.includes(text);
}
return (
child.type === "id" &&
child.value !== undefined &&
instanceContainsText(child.value)
);
}) ?? false
);
};
const link = instances.find(
(instance) => instance.tag === "a" && instanceContainsText(instance.id)
);
if (link === undefined) {
throw new Error(`Expected a link containing "${text}"`);
}
const href = props.find(
(prop) => prop.instanceId === link.id && prop.name === "href"
);
if (href?.type !== "string" || typeof href.value !== "string") {
throw new Error(`Expected a static href for link containing "${text}"`);
}
return href.value;
};
const undoShortcut = async ({ page }: { page: Page }) => {
await page.keyboard.press("ControlOrMeta+Z");
await waitForSyncStatus({ page, status: "idle" });
@@ -207,6 +267,7 @@ test("Editor can edit text and content props but not design props", async () =>
const editedImageSrc =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='80'%3E%3Crect width='120' height='80' fill='%23dff7e8'/%3E%3C/svg%3E";
const editedImageAlt = "Edited image alt";
const editedImageWidth = 240;
const editedVideoSrc = "https://example.com/edited-video.mp4";
const editedContent = "Edited isolated content";
const { page, close } = await newIsolatedPage();
@@ -299,6 +360,11 @@ test("Editor can edit text and content props but not design props", async () =>
control: "text",
value: editedImageAlt,
});
await fillSelectedNumberProperty({
page,
label: "Width",
value: editedImageWidth,
});
await selectCanvasVideoSource({
page,
@@ -354,6 +420,11 @@ test("Editor can edit text and content props but not design props", async () =>
control: "text",
value: editedImageAlt,
});
await waitForSelectedNumberPropertyValue({
page,
label: "Width",
value: editedImageWidth,
});
await selectCanvasVideoSource({
page,
@@ -414,6 +485,7 @@ test("Editor can paste text while editing canvas content", async () => {
test("Editor rich text formatting persists after reload", async () => {
const fixture = getSharedContentModeProject();
const formattedText = "Rich editor formatted copy";
const editedHref = "/rich-text-editor-link";
const { page, close } = await newIsolatedPage();
try {
@@ -436,7 +508,7 @@ test("Editor rich text formatting persists after reload", async () => {
page,
currentText: fixture.shareLinkEditableText,
text: formattedText,
formats: ["Bold", "Italic"],
formats: ["Bold", "Italic", "Inline link"],
});
});
await waitForCanvasText({ page, text: formattedText });
@@ -451,9 +523,14 @@ test("Editor rich text formatting persists after reload", async () => {
tag: "i",
text: formattedText,
});
if (boldCount !== 1 || italicCount !== 1) {
const linkCount = await getElementTagWithTextCount({
projectId: fixture.projectId,
tag: "a",
text: formattedText,
});
if (boldCount !== 1 || italicCount !== 1 || linkCount !== 1) {
throw new Error(
`Expected rich text editor to persist one bold and one italic formatted subtree. bold=${boldCount}, italic=${italicCount}`
`Expected rich text editor to persist one bold, italic, and inline link subtree. bold=${boldCount}, italic=${italicCount}, link=${linkCount}`
);
}
@@ -467,6 +544,27 @@ test("Editor rich text formatting persists after reload", async () => {
});
await waitForCanvasText({ page, text: formattedText });
await selectCanvasTextInstanceForProps({
page,
text: formattedText,
propertyLabel: "Href",
});
await fillSelectedStringProperty({
page,
label: "Href",
control: "url",
value: editedHref,
});
const savedHref = await getElementHrefWithText({
projectId: fixture.projectId,
text: formattedText,
});
if (savedHref !== editedHref) {
throw new Error(
`Expected rich-text link href to be saved as "${editedHref}", received "${savedHref}"`
);
}
await measure("content mode reload rich text formatting", async () => {
await openProjectBuilder({
page,
@@ -476,6 +574,15 @@ test("Editor rich text formatting persists after reload", async () => {
});
});
await waitForCanvasText({ page, text: formattedText });
const reloadedHref = await getElementHrefWithText({
projectId: fixture.projectId,
text: formattedText,
});
if (reloadedHref !== editedHref) {
throw new Error(
`Expected reloaded rich-text link href to be "${editedHref}", received "${reloadedHref}"`
);
}
const reloadedBoldCount = await getElementTagWithTextCount({
projectId: fixture.projectId,
@@ -487,9 +594,50 @@ test("Editor rich text formatting persists after reload", async () => {
tag: "i",
text: formattedText,
});
if (reloadedBoldCount !== 1 || reloadedItalicCount !== 1) {
const reloadedLinkCount = await getElementTagWithTextCount({
projectId: fixture.projectId,
tag: "a",
text: formattedText,
});
if (
reloadedBoldCount !== 1 ||
reloadedItalicCount !== 1 ||
reloadedLinkCount !== 1
) {
throw new Error(
`Expected reloaded rich text formatting to persist. bold=${reloadedBoldCount}, italic=${reloadedItalicCount}`
`Expected reloaded rich text formatting to persist. bold=${reloadedBoldCount}, italic=${reloadedItalicCount}, link=${reloadedLinkCount}`
);
}
await removeCanvasInlineLink({ page, text: formattedText });
const removedLinkCount = await getElementTagWithTextCount({
projectId: fixture.projectId,
tag: "a",
text: formattedText,
});
if (removedLinkCount !== 0) {
throw new Error(
`Expected rich-text link removal to delete the inline link subtree, received ${removedLinkCount} link instances`
);
}
await measure("content mode reload rich text link removal", async () => {
await openProjectBuilder({
page,
projectId: fixture.projectId,
authToken: fixture.editorToken,
mode: "content",
});
});
await waitForCanvasText({ page, text: formattedText });
const reloadedRemovedLinkCount = await getElementTagWithTextCount({
projectId: fixture.projectId,
tag: "a",
text: formattedText,
});
if (reloadedRemovedLinkCount !== 0) {
throw new Error(
`Expected rich-text link removal to persist after reload, received ${reloadedRemovedLinkCount} link instances`
);
}
} finally {
@@ -1,13 +1,19 @@
import type { Page } from "playwright";
import { openProjectBuilder, waitForCanvasText } from "../flows/builder";
import { selectCanvasTextInstance } from "../flows/canvas-selection";
import {
bindSelectedTextContentToExpression,
createGraphqlResourceVariable,
createHttpResourceVariable,
createSystemResourceVariable,
selectContentInstance,
} from "../flows/data-variables";
import {
waitForChangeToBeSaved,
waitForSyncStatus,
} from "../flows/sync-status";
import { createContentModeProject } from "../fixtures/content-mode-suite";
import { loginWithSecret } from "../flows/dashboard";
import { expectGeneratedAppBuild } from "../flows/generated-app";
import { expectGeneratedAppToRender } from "../flows/generated-app";
import { newIsolatedPage, test } from "../harness";
import { measure } from "../perf";
import { loadDevBuild } from "../db";
@@ -45,22 +51,6 @@ const getDataVariableRuntimeState = async (fixture: { projectId: string }) => {
};
};
const selectVariableType = async ({
page,
next,
}: {
page: Page;
next: string;
}) => {
await page
.getByText("Type", { exact: true })
.locator("xpath=following::button[1]")
.click();
await page
.getByRole("option", { name: new RegExp(`^${next}( Pro)?$`) })
.click();
};
const getVariableForm = (page: Page) =>
page
.locator("form")
@@ -108,17 +98,6 @@ const openNewVariablePanel = async ({ page }: { page: Page }) => {
await page.getByText("New Variable", { exact: true }).waitFor();
};
const selectContentInstance = async ({ page }: { page: Page }) => {
await selectCanvasTextInstance({ page, text: "Initial content" });
await page
.getByText("No instance selected", { exact: true })
.waitFor({ state: "hidden" });
await page.getByRole("tab", { name: "Settings" }).click();
await page
.getByRole("button", { name: "Add data variable" })
.waitFor({ state: "visible" });
};
const createStringVariable = async ({
page,
name,
@@ -175,50 +154,6 @@ const deleteVariable = async ({
await waitForSyncStatus({ page, status: "idle" });
};
const bindSelectedTextContentToExpression = async ({
page,
expression,
}: {
page: Page;
expression: string;
}) => {
await page.getByText("Text Content", { exact: true }).waitFor();
await page.getByText("Text Content", { exact: true }).hover();
await page
.getByText("Text Content", { exact: true })
.locator("xpath=following::button[@data-variant][1]")
.click();
const bindingDialog = page.getByRole("dialog", { name: "Binding" });
await bindingDialog.waitFor();
const expressionEditor = bindingDialog.locator(".cm-content").last();
await expressionEditor.click();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.type(expression);
const save = waitForChangeToBeSaved({ page });
await page.keyboard.press("ControlOrMeta+Enter");
await save;
await waitForSyncStatus({ page, status: "idle" });
await page.keyboard.press("Escape");
};
const createHttpResourceVariable = async ({
page,
name,
url,
}: {
page: Page;
name: string;
url: string;
}) => {
await openNewVariablePanel({ page });
await fillVariableName({ page, name });
await selectVariableType({ page, next: "Resource" });
await page.getByRole("textbox", { name: "URL" }).fill(url);
await closeVariablePanelAndWaitForSave({ page });
};
const editHttpResourceVariableWithCurl = async ({
page,
fixture,
@@ -237,41 +172,6 @@ const editHttpResourceVariableWithCurl = async ({
await closeVariablePanelAndWaitForSave({ page });
};
const createGraphqlResourceVariable = async ({
page,
name,
url,
query,
}: {
page: Page;
name: string;
url: string;
query: string;
}) => {
await openNewVariablePanel({ page });
await fillVariableName({ page, name });
await selectVariableType({ page, next: "GraphQL" });
await page.getByRole("textbox", { name: "URL" }).fill(url);
await page.getByRole("textbox", { name: "Query" }).fill(query);
await closeVariablePanelAndWaitForSave({ page });
};
const createSystemResourceVariable = async ({
page,
name,
}: {
page: Page;
name: string;
}) => {
await openNewVariablePanel({ page });
await fillVariableName({ page, name });
await selectVariableType({
page,
next: "System Resource",
});
await closeVariablePanelAndWaitForSave({ page });
};
const expectVariableListItem = async ({
page,
name,
@@ -281,8 +181,14 @@ const expectVariableListItem = async ({
name: string;
badge: "Static variable" | "Dynamic data variable";
}) => {
await page.getByText(name, { exact: true }).waitFor({ state: "visible" });
await page.getByLabel(badge).first().waitFor({ state: "visible" });
const itemButton = page
.getByText(name, { exact: true })
.locator("xpath=ancestor::button[@data-id][1]");
await itemButton.waitFor({ state: "visible" });
await itemButton
.locator("xpath=parent::*")
.getByLabel(badge)
.waitFor({ state: "attached" });
};
const expectPersistedDataVariables = async ({
@@ -505,11 +411,6 @@ test("Builder-created data variables and resources persist after reload", async
name: httpName,
curl: `curl '${httpCurlUrl}?${httpSearchParamName}=${httpSearchParamValue}' --request POST --header '${httpHeaderName}: ${httpHeaderValue}' --header 'Content-Type: application/json' --data '{"${httpBodyValue}":true}'`,
});
await deleteVariable({
page,
fixture,
name: graphqlName,
});
}
);
@@ -529,7 +430,7 @@ test("Builder-created data variables and resources persist after reload", async
graphqlUrl,
graphqlQuery,
systemName,
deletedName: graphqlName,
deletedName: undefined,
});
await expectVariableListItem({
@@ -542,8 +443,10 @@ test("Builder-created data variables and resources persist after reload", async
name: httpName,
badge: "Dynamic data variable",
});
await page.getByText(graphqlName, { exact: true }).waitFor({
state: "hidden",
await expectVariableListItem({
page,
name: graphqlName,
badge: "Dynamic data variable",
});
await expectVariableListItem({
page,
@@ -571,8 +474,10 @@ test("Builder-created data variables and resources persist after reload", async
name: httpName,
badge: "Dynamic data variable",
});
await page.getByText(graphqlName, { exact: true }).waitFor({
state: "hidden",
await expectVariableListItem({
page,
name: graphqlName,
badge: "Dynamic data variable",
});
await expectVariableListItem({
page,
@@ -580,6 +485,38 @@ test("Builder-created data variables and resources persist after reload", async
badge: "Dynamic data variable",
});
await expectPersistedDataVariables({
fixture,
staticName,
staticValue: editedStaticValue,
httpName,
httpUrl,
httpCurlUrl,
httpSearchParamName,
httpSearchParamValue,
httpHeaderName,
httpHeaderValue,
httpBodyValue,
graphqlName,
graphqlUrl,
graphqlQuery,
systemName,
deletedName: undefined,
});
await measure(
"data variables runtime delete graphql variable",
async () => {
await deleteVariable({
page,
fixture,
name: graphqlName,
});
}
);
await page.getByText(graphqlName, { exact: true }).waitFor({
state: "hidden",
});
await expectPersistedDataVariables({
fixture,
staticName,
@@ -654,8 +591,8 @@ test("Text Content can bind to a Builder-created variable and persist after relo
});
await waitForCanvasText({ page, text: staticValue });
await measure("text content binding generated app build", async () => {
await expectGeneratedAppBuild({
await measure("text content binding generated app renders", async () => {
await expectGeneratedAppToRender({
projectId: fixture.projectId,
expectedText: staticValue,
});
@@ -0,0 +1,189 @@
import { openProjectBuilder, waitForCanvasText } from "../flows/builder";
import {
bindSelectedTextContentToExpression,
createGraphqlResourceVariable,
createHttpResourceVariable,
createSystemResourceVariable,
selectContentInstance,
startGraphqlResourceServer,
startJsonResourceServer,
} from "../flows/data-variables";
import { loginWithSecret } from "../flows/dashboard";
import { expectGeneratedAppToRender } from "../flows/generated-app";
import { createContentModeProject } from "../fixtures/content-mode-suite";
import { newIsolatedPage, test } from "../harness";
import { measure } from "../perf";
const openGeneratedResourceProject = async ({
page,
email,
title,
assetNamePrefix,
}: {
page: Awaited<ReturnType<typeof newIsolatedPage>>["page"];
email: string;
title: string;
assetNamePrefix: string;
}) => {
const fixture = await createContentModeProject({
email,
title,
assetNamePrefix,
editorToken: `${assetNamePrefix}editor-token`,
builderToken: `${assetNamePrefix}builder-token`,
});
await loginWithSecret({ page, email });
await measure(`${title} open builder`, async () => {
await openProjectBuilder({
page,
projectId: fixture.projectId,
authToken: fixture.builderToken,
});
await waitForCanvasText({ page, text: "Initial content" });
});
await selectContentInstance({ page });
return fixture;
};
const reloadAndSelectContent = async ({
page,
fixture,
phase,
}: {
page: Awaited<ReturnType<typeof newIsolatedPage>>["page"];
fixture: { projectId: string; builderToken: string };
phase: string;
}) => {
await measure(phase, async () => {
await openProjectBuilder({
page,
projectId: fixture.projectId,
authToken: fixture.builderToken,
});
await waitForCanvasText({ page, text: "Initial content" });
await selectContentInstance({ page });
});
};
test("Generated app fetches and renders a Builder-created HTTP resource", async () => {
const { page, close } = await newIsolatedPage();
const resource = await startJsonResourceServer({
title: "Rendered from local HTTP resource",
});
const resourceName = "e2eGeneratedPosts";
try {
const fixture = await openGeneratedResourceProject({
page,
email: "generated-resource@webstudio.test",
title: "Generated HTTP Resource",
assetNamePrefix: "generated-resource-",
});
await measure("generated HTTP resource create variable", async () => {
await createHttpResourceVariable({
page,
name: resourceName,
url: resource.url,
});
});
await reloadAndSelectContent({
page,
fixture,
phase: "generated HTTP resource reload Builder",
});
await bindSelectedTextContentToExpression({
page,
expression: `${resourceName}.data.title`,
});
await waitForCanvasText({
page,
text: "Rendered from local HTTP resource",
});
await expectGeneratedAppToRender({
projectId: fixture.projectId,
expectedText: "Rendered from local HTTP resource",
});
} finally {
await resource.close();
await close();
}
});
test("Generated app fetches and renders a Builder-created GraphQL resource", async () => {
const { page, close } = await newIsolatedPage();
const resource = await startGraphqlResourceServer({
title: "Rendered from local GraphQL resource",
});
const resourceName = "e2eGeneratedPost";
try {
const fixture = await openGeneratedResourceProject({
page,
email: "generated-graphql-resource@webstudio.test",
title: "Generated GraphQL Resource",
assetNamePrefix: "generated-graphql-resource-",
});
await createGraphqlResourceVariable({
page,
name: resourceName,
url: resource.url,
query: "query Post { post { title } }",
});
await reloadAndSelectContent({
page,
fixture,
phase: "generated GraphQL resource reload Builder",
});
await bindSelectedTextContentToExpression({
page,
expression: `${resourceName}.data.data.post.title`,
});
await waitForCanvasText({
page,
text: "Rendered from local GraphQL resource",
});
await expectGeneratedAppToRender({
projectId: fixture.projectId,
expectedText: "Rendered from local GraphQL resource",
});
} finally {
await resource.close();
await close();
}
});
test("Generated app renders a Builder-created current-date system resource", async () => {
const { page, close } = await newIsolatedPage();
const resourceName = "e2eGeneratedCurrentDate";
const expectedYear = String(new Date().getUTCFullYear());
try {
const fixture = await openGeneratedResourceProject({
page,
email: "generated-system-resource@webstudio.test",
title: "Generated System Resource",
assetNamePrefix: "generated-system-resource-",
});
await createSystemResourceVariable({
page,
name: resourceName,
resource: "Current Date",
});
await reloadAndSelectContent({
page,
fixture,
phase: "generated system resource reload Builder",
});
await bindSelectedTextContentToExpression({
page,
expression: `${resourceName}.data.year`,
});
await waitForCanvasText({ page, text: expectedYear });
await expectGeneratedAppToRender({
projectId: fixture.projectId,
expectedText: expectedYear,
});
} finally {
await close();
}
});
@@ -229,16 +229,6 @@ const getElementTagWithTextCount = async ({
return (await inspectBuild(fixture)).countElementTagWithText({ tag, text });
};
const getInstanceWithTextCount = async ({
fixture,
text,
}: {
fixture: SeededContentModeProject;
text: string;
}) => {
return (await inspectBuild(fixture)).countInstancesWithText(text);
};
const getExactTextChildCount = async ({
fixture,
text,
@@ -1292,136 +1282,3 @@ test("Builder allows native paste inside focused builder inputs", async () => {
await close();
}
});
test("Builder runs paste plugins through generic browser paste events", async () => {
const { page, close } = await newIsolatedPage();
const instanceJsonText = "Instance JSON paste heading";
const instanceTextText = "Instance text paste heading";
const jsxText = "JSX paste heading";
const htmlText = "HTML paste heading";
const markdownText = "Markdown paste heading";
const webflowText = "Webflow paste heading";
const webflowData = JSON.stringify({
type: "@webflow/XscpData",
payload: {
nodes: [
{
_id: "webflow-heading",
type: "Heading",
tag: "h1",
children: ["webflow-heading-text"],
classes: [],
},
{
_id: "webflow-heading-text",
v: webflowText,
text: true,
},
],
styles: [],
assets: [],
},
});
try {
await measure(
"pages actions open builder for generic paste plugins",
async () => {
await openProjectBuilder({
page,
projectId: fixture.projectId,
authToken: fixture.builderToken,
});
}
);
await waitForCanvasText({ page, text: "Initial content" });
await selectBodyInNavigator({ page });
await pasteClipboardData({
page,
data: {
"application/json": createInstanceTransfer({
id: "instance-json-paste",
text: instanceJsonText,
}),
},
});
await waitForCanvasText({ page, text: instanceJsonText });
await selectBodyInNavigator({ page });
await pasteClipboardData({
page,
data: {
"text/plain": createInstanceTransfer({
id: "instance-text-paste",
text: instanceTextText,
}),
},
});
await waitForCanvasText({ page, text: instanceTextText });
await selectBodyInNavigator({ page });
await pasteClipboardData({
page,
data: {
"text/plain": `<ws.element ws:tag="h2">${jsxText}</ws.element>`,
},
});
await waitForCanvasText({ page, text: jsxText });
await selectBodyInNavigator({ page });
await pasteClipboardData({
page,
data: {
"text/plain": `<section><h2>${htmlText}</h2></section>`,
},
});
await waitForCanvasText({ page, text: htmlText });
await selectBodyInNavigator({ page });
await pasteClipboardData({
page,
data: {
"text/plain": `## ${markdownText}`,
},
});
await waitForCanvasText({ page, text: markdownText });
await selectBodyInNavigator({ page });
await pasteClipboardData({
page,
data: {
"application/json": webflowData,
},
});
await waitForCanvasText({ page, text: webflowText });
const expectedPastes = [
["p", instanceJsonText],
["p", instanceTextText],
["h2", jsxText],
["h2", htmlText],
["h2", markdownText],
] as const;
for (const [tag, text] of expectedPastes) {
const count = await getElementTagWithTextCount({ fixture, tag, text });
if (count !== 1) {
throw new Error(
`Expected generic paste to create one ${tag} containing "${text}", found ${count}`
);
}
}
const webflowTextCount = await getInstanceWithTextCount({
fixture,
text: webflowText,
});
if (webflowTextCount === 0) {
throw new Error(
`Expected generic Webflow paste to persist text "${webflowText}"`
);
}
} finally {
await close();
}
});
@@ -1,6 +1,7 @@
import type { Page } from "playwright";
import { expectTextHidden } from "../flows/assertions";
import { openProjectBuilder, waitForCanvasText } from "../flows/builder";
import { expectGeneratedAppBuild } from "../flows/generated-app";
import {
createFolder,
createPageFromTemplate,
@@ -21,6 +22,7 @@ import { measure } from "../perf";
import { loadDevBuild } from "../db";
let fixture: SeededContentModeProject;
let pasteFixture: SeededContentModeProject;
const copiedPageTransferMarker = "@webstudio/page/v0.1";
@@ -199,6 +201,13 @@ test.beforeAll(async () => {
editorToken: "pages-actions-e2e-editor-token",
builderToken: "pages-actions-e2e-builder-token",
});
pasteFixture = await createContentModeProject({
email: "pages-actions-paste-e2e@webstudio.test",
title: "Pages Actions Paste E2E",
assetNamePrefix: "pages-actions-paste-",
editorToken: "pages-actions-paste-e2e-editor-token",
builderToken: "pages-actions-paste-e2e-builder-token",
});
});
test("Builder can copy, duplicate, and delete a page from the header menu", async () => {
@@ -238,6 +247,12 @@ test("Builder can copy, duplicate, and delete a page from the header menu", asyn
});
await waitForCanvasText({ page, text: "2026" });
await expectDataResourceCopies(fixture, dataResourceCountsBefore);
await measure(
"pages actions generated app build for copied resources",
async () => {
await expectGeneratedAppBuild({ projectId: fixture.projectId });
}
);
await openPageSettings({ page, pageName });
const renameSave = waitForChangeToBeSaved({ page });
@@ -471,3 +486,207 @@ test("Builder can copy, duplicate, and delete a page template from actions menus
await close();
}
});
const createInstanceTransfer = ({ id, text }: { id: string; text: string }) =>
JSON.stringify({
"@webstudio/instance/v0.1": {
instanceSelector: [id, "source-body"],
children: [{ type: "id", value: id }],
instances: [
{
type: "instance",
id,
component: "ws:element",
tag: "p",
children: [{ type: "text", value: text }],
},
],
assets: [],
dataSources: [],
resources: [],
props: [],
breakpoints: [],
styleSourceSelections: [],
styleSources: [],
styles: [],
},
});
const selectPasteBody = async ({ page }: { page: Page }) => {
const navigatorTab = page.getByRole("tab", { name: "Navigator" });
if ((await navigatorTab.getAttribute("aria-selected")) !== "true") {
await navigatorTab.click();
}
const body = page
.locator("[data-navigator-tree] [data-tree-button]")
.filter({ hasText: "Body" })
.last();
await body.dblclick();
};
const pasteClipboardData = async ({
page,
data,
}: {
page: Page;
data: Record<string, string>;
}) => {
const save = waitForChangeToBeSaved({ page });
await page.evaluate((data) => {
const clipboardData = new DataTransfer();
for (const [mimeType, value] of Object.entries(data)) {
clipboardData.setData(mimeType, value);
}
document.dispatchEvent(
new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData,
})
);
}, data);
await save;
await waitForSyncStatus({ page, status: "idle" });
};
const countPastedElements = async ({
tag,
text,
}: {
tag: string;
text: string;
}) => {
const build = await loadDevBuild({ projectId: pasteFixture.projectId });
const instances = JSON.parse(build.instances) as Array<{
component?: string;
tag?: string;
children?: Array<{ type: string; value?: string }>;
}>;
return instances.filter(
(instance) =>
instance.component === "ws:element" &&
instance.tag === tag &&
instance.children?.some(
(child) => child.type === "text" && child.value?.includes(text)
)
).length;
};
const countBuildInstancesWithText = async (text: string) => {
const build = await loadDevBuild({ projectId: pasteFixture.projectId });
const instances = JSON.parse(build.instances) as Array<{
id: string;
children?: Array<{ type: string; value?: string }>;
}>;
const instancesById = new Map(
instances.map((instance) => [instance.id, instance])
);
const containsText = (instanceId: string): boolean => {
const instance = instancesById.get(instanceId);
return (
instance?.children?.some(
(child) =>
(child.type === "text" && child.value?.includes(text)) ||
(child.type === "id" &&
child.value !== undefined &&
containsText(child.value))
) ?? false
);
};
return instances.filter((instance) => containsText(instance.id)).length;
};
test("Builder runs paste plugins through generic browser paste events", async () => {
const { page, close } = await newIsolatedPage();
const entries = [
[
"application/json",
createInstanceTransfer({
id: "paste-json",
text: "Instance JSON paste heading",
}),
"Instance JSON paste heading",
"p",
],
[
"text/plain",
createInstanceTransfer({
id: "paste-text",
text: "Instance text paste heading",
}),
"Instance text paste heading",
"p",
],
[
"text/plain",
'<ws.element ws:tag="h2">JSX paste heading</ws.element>',
"JSX paste heading",
"h2",
],
[
"text/plain",
"<section><h2>HTML paste heading</h2></section>",
"HTML paste heading",
"h2",
],
["text/plain", "## Markdown paste heading", "Markdown paste heading", "h2"],
[
"application/json",
JSON.stringify({
type: "@webflow/XscpData",
payload: {
nodes: [
{
_id: "webflow-heading",
type: "Heading",
tag: "h1",
children: ["webflow-heading-text"],
classes: [],
},
{
_id: "webflow-heading-text",
v: "Webflow paste heading",
text: true,
},
],
styles: [],
assets: [],
},
}),
"Webflow paste heading",
undefined,
],
] as const;
try {
await openProjectBuilder({
page,
projectId: pasteFixture.projectId,
authToken: pasteFixture.builderToken,
});
await waitForCanvasText({ page, text: "Initial content" });
for (const [mimeType, value, text, tag] of entries) {
await selectPasteBody({ page });
await pasteClipboardData({ page, data: { [mimeType]: value } });
await waitForCanvasText({ page, text });
if (tag === undefined) {
const count = await countBuildInstancesWithText(text);
if (count === 0) {
throw new Error(
`Expected generic Webflow paste to persist ${JSON.stringify(text)}`
);
}
continue;
}
const count = await countPastedElements({ tag, text });
if (count !== 1) {
throw new Error(
`Expected one ${tag} containing ${JSON.stringify(text)}, found ${count}`
);
}
}
} finally {
await close();
}
});
@@ -1,5 +1,9 @@
import type { Locator } from "playwright";
import { openBuilderUrl, waitForCanvasFrame } from "../flows/builder";
import {
expectGeneratedAppNavigation,
expectGeneratedAppToRender,
} from "../flows/generated-app";
import { createContentModeProject } from "../fixtures/content-mode-suite";
import { insertAuthorizationToken, loadDevBuild, updateBuild } from "../db";
import { getProjectBuilderUrl, newIsolatedPage, test } from "../harness";
@@ -404,3 +408,22 @@ test("Preview links expose current page state for components and element anchors
await close();
}
});
test("Generated preview renders and navigates linked routes", async () => {
const { page, close } = await newIsolatedPage();
try {
await expectGeneratedAppToRender({
projectId: fixture.projectId,
path: "/my-page",
expectedText: previewLinkText.pageLink,
});
await expectGeneratedAppNavigation({
projectId: fixture.projectId,
page,
linkText: previewLinkText.pageLink,
targetPath: "/my-page",
});
} finally {
await close();
}
});
@@ -1,5 +1,6 @@
import type { Page as PlaywrightPage } from "playwright";
import type { Page as WebstudioPage } from "@webstudio-is/sdk";
import { parseProjectAuthRoutes } from "@webstudio-is/project-build/runtime/project-settings";
import { loadDevBuild, updateProject } from "../db";
import { openProjectBuilder, waitForCanvasText } from "../flows/builder";
import { loginWithSecret } from "../flows/dashboard";
@@ -12,15 +13,15 @@ import {
waitForChangeToBeSaved,
waitForSyncStatus,
} from "../flows/sync-status";
import { expectGeneratedRedirects } from "../flows/generated-app";
import {
expectGeneratedAppRedirect,
expectGeneratedRedirects,
} from "../flows/generated-app";
import { createContentModeProject } from "../fixtures/content-mode-suite";
import { newIsolatedPage, test } from "../harness";
import { measure } from "../perf";
type PersistedPages = {
meta?: {
siteName?: string;
};
redirects?: Array<{
old: string;
new: string;
@@ -29,11 +30,25 @@ type PersistedPages = {
pages: WebstudioPage[];
};
type PersistedProjectSettings = {
compiler: { atomicStyles?: boolean };
meta: { auth?: string | null; siteName?: string };
};
const getPersistedPages = async ({ projectId }: { projectId: string }) => {
const build = await loadDevBuild({ projectId });
return JSON.parse(build.pages) as PersistedPages;
};
const getPersistedProjectSettings = async ({
projectId,
}: {
projectId: string;
}) => {
const build = await loadDevBuild({ projectId });
return JSON.parse(build.projectSettings) as PersistedProjectSettings;
};
const getPersistedPageByName = async ({
projectId,
name,
@@ -68,6 +83,29 @@ const saveSiteName = async ({
await waitForSyncStatus({ page, status: "idle" });
};
const setAtomicStyles = async ({
page,
checked,
}: {
page: PlaywrightPage;
checked: boolean;
}) => {
await page.getByRole("option", { name: "Publish" }).click();
const checkbox = page.getByRole("checkbox", {
name: "Generate atomic CSS when publishing",
});
const toggle = async () => {
const save = waitForChangeToBeSaved({ page });
await checkbox.click();
await save;
await waitForSyncStatus({ page, status: "idle" });
};
if ((await checkbox.isChecked()) === checked) {
await toggle();
}
await toggle();
};
const addRedirect = async ({
page,
from,
@@ -90,6 +128,70 @@ const addRedirect = async ({
await waitForSyncStatus({ page, status: "idle" });
};
const addBasicAuth = async ({
page,
route,
login,
password,
}: {
page: PlaywrightPage;
route: string;
login: string;
password: string;
}) => {
await page.getByRole("option", { name: "Authentication" }).click();
await page.getByPlaceholder("/private or /docs/*").fill(route);
await page.getByPlaceholder("Login").fill(login);
await page.getByPlaceholder("Password").fill(password);
const save = waitForChangeToBeSaved({ page });
await page.getByRole("button", { name: "Add" }).click();
await save;
await waitForSyncStatus({ page, status: "idle" });
};
const deleteBasicAuth = async ({
page,
route,
}: {
page: PlaywrightPage;
route: string;
}) => {
await page.getByText(route, { exact: true }).hover();
const save = waitForChangeToBeSaved({ page });
await page
.getByRole("button", { name: `Delete authentication for ${route}` })
.click();
await save;
await waitForSyncStatus({ page, status: "idle" });
};
const expectPersistedBasicAuth = async ({
projectId,
route,
login,
exists,
}: {
projectId: string;
route: string;
login: string;
exists: boolean;
}) => {
const settings = await getPersistedProjectSettings({ projectId });
const routes = parseProjectAuthRoutes(settings.meta.auth ?? undefined).routes;
const authRoute = routes.find((authRoute) => authRoute.route === route);
if (exists === false) {
if (authRoute !== undefined) {
throw new Error(`Expected auth route ${route} to be deleted.`);
}
return;
}
if (authRoute?.auth.login !== login) {
throw new Error(
`Expected auth route ${route} for ${login}. Received ${JSON.stringify(routes)}`
);
}
};
const deleteRedirect = async ({
page,
from,
@@ -116,9 +218,10 @@ const expectPersistedProjectSettings = async ({
redirect?: { from: string; to: string; status?: string };
}) => {
const pages = await getPersistedPages({ projectId });
if (pages.meta?.siteName !== siteName) {
const settings = await getPersistedProjectSettings({ projectId });
if (settings.meta.siteName !== siteName) {
throw new Error(
`Expected siteName "${siteName}", received ${JSON.stringify(pages.meta)}.`
`Expected siteName "${siteName}", received ${JSON.stringify(settings.meta)}.`
);
}
@@ -177,6 +280,7 @@ test("Project settings site name and redirects persist after reload", async () =
await measure("project settings runtime update settings", async () => {
await openProjectSettings({ page });
await saveSiteName({ page, siteName });
await setAtomicStyles({ page, checked: false });
await addRedirect({
page,
from: redirect.from,
@@ -189,18 +293,23 @@ test("Project settings site name and redirects persist after reload", async () =
siteName,
redirect,
});
await measure("project settings runtime generated redirects", async () => {
await expectGeneratedRedirects({
if (
(await getPersistedProjectSettings({ projectId: fixture.projectId }))
.compiler.atomicStyles !== false
) {
throw new Error("Expected atomic CSS setting to persist as disabled.");
}
await measure(
"project settings runtime generated redirect response",
async () => {
await expectGeneratedAppRedirect({
projectId: fixture.projectId,
expectedRedirects: [
{
old: redirect.from,
new: redirect.to,
status: "301",
},
],
});
path: redirect.from,
status: 301,
location: redirect.to,
});
}
);
await measure("project settings runtime reload with redirect", async () => {
await openProjectBuilder({
@@ -220,6 +329,19 @@ test("Project settings site name and redirects persist after reload", async () =
}
await page.getByRole("option", { name: "Redirects" }).click();
await page.getByText(redirect.from, { exact: true }).waitFor();
await page.getByRole("option", { name: "Publish" }).click();
if (
await page
.getByRole("checkbox", {
name: "Generate atomic CSS when publishing",
})
.isChecked()
) {
throw new Error(
"Expected atomic CSS setting to remain disabled after reload."
);
}
await page.getByRole("option", { name: "Redirects" }).click();
await measure("project settings runtime delete redirect", async () => {
await deleteRedirect({ page, from: redirect.from });
@@ -372,3 +494,73 @@ test("Marketplace page settings persist after reload", async () => {
await close();
}
});
test("Project authentication routes persist after reload and deletion", async () => {
const email = "project-auth-runtime@webstudio.test";
const fixture = await createContentModeProject({
email,
title: "Project Auth Runtime",
assetNamePrefix: "project-auth-runtime-",
editorToken: "project-auth-runtime-editor-token",
builderToken: "project-auth-runtime-builder-token",
});
const { page, close } = await newIsolatedPage();
const route = "/private";
const login = "editor";
const password = "e2e-secret";
try {
await loginWithSecret({ page, email });
await measure("project auth runtime open builder", async () => {
await openProjectBuilder({
page,
projectId: fixture.projectId,
authToken: fixture.builderToken,
});
await waitForCanvasText({ page, text: "Initial content" });
});
await measure("project auth runtime add route", async () => {
await openProjectSettings({ page });
await page.getByRole("option", { name: "Authentication" }).click();
await page.getByPlaceholder("/private or /docs/*").fill("private");
if (
(await page.getByRole("button", { name: "Add" }).isDisabled()) === false
) {
throw new Error("Expected invalid auth route to disable Add.");
}
await addBasicAuth({ page, route, login, password });
});
await expectPersistedBasicAuth({
projectId: fixture.projectId,
route,
login,
exists: true,
});
await measure("project auth runtime reload builder", async () => {
await openProjectBuilder({
page,
projectId: fixture.projectId,
authToken: fixture.builderToken,
});
await waitForCanvasText({ page, text: "Initial content" });
});
await openProjectSettings({ page });
await page.getByRole("option", { name: "Authentication" }).click();
await page.getByText(route, { exact: true }).waitFor();
await page.getByText(login, { exact: true }).waitFor();
await measure("project auth runtime delete route", async () => {
await deleteBasicAuth({ page, route });
});
await expectPersistedBasicAuth({
projectId: fixture.projectId,
route,
login,
exists: false,
});
} finally {
await close();
}
});
@@ -2,6 +2,11 @@ import type { Page } from "playwright";
import { loadDevBuild } from "../db";
import { openProjectBuilder, waitForCanvasText } from "../flows/builder";
import { selectCanvasTextInstance } from "../flows/canvas-selection";
import {
resetSelectedProperty,
setSelectedBooleanProperty,
waitForSelectedBooleanPropertyValue,
} from "../flows/props-panel";
import {
waitForChangeToBeSaved,
waitForSyncStatus,
@@ -18,13 +23,19 @@ const openComponentsPanel = async ({ page }: { page: Page }) => {
const insertComponentPanelOption = async ({
page,
name,
component,
}: {
page: Page;
name: string;
component?: string;
}) => {
const search = page.getByPlaceholder("Find components");
await search.fill(name);
await page.getByRole("option", { name, exact: true }).click();
const option =
component === undefined
? page.getByRole("option", { name, exact: true })
: page.locator(`[data-drag-component="${component}"]`);
await option.click();
await waitForSyncStatus({ page, status: "idle" });
};
@@ -56,6 +67,26 @@ const selectNavigatorItem = async ({
await item.click();
};
const selectFirstNavigatorChild = async ({
page,
parentLabel,
}: {
page: Page;
parentLabel: string;
}) => {
await openNavigatorPanel({ page });
const parent = page
.locator("[data-navigator-tree] [data-tree-button]")
.filter({ hasText: parentLabel })
.first();
await parent.waitFor({ state: "visible" });
await parent.press("ArrowRight");
const child = parent.locator("xpath=following::button[@data-tree-button][1]");
await child.waitFor({ state: "visible" });
await child.click();
await child.click();
};
const updateResourceActionUrl = async ({
page,
url,
@@ -242,6 +273,79 @@ const expectPersistedExpressionProp = async ({
}
};
const expectPersistedBooleanProp = async ({
projectId,
component,
name,
value,
}: {
projectId: string;
component: string;
name: string;
value: boolean;
}) => {
const build = await loadDevBuild({ projectId });
const instances = JSON.parse(build.instances) as Array<{
id: string;
component: string;
}>;
const props = JSON.parse(build.props) as Array<{
instanceId: string;
name: string;
type: string;
value?: unknown;
}>;
const instanceIds = new Set(
instances
.filter((instance) => instance.component === component)
.map((instance) => instance.id)
);
const prop = props.find(
(prop) =>
instanceIds.has(prop.instanceId) &&
prop.name === name &&
prop.type === "boolean" &&
prop.value === value
);
if (prop === undefined) {
throw new Error(
`Expected ${component} ${name}=${value} to persist. Props: ${JSON.stringify(props)}`
);
}
};
const expectBooleanPropDeleted = async ({
projectId,
component,
name,
}: {
projectId: string;
component: string;
name: string;
}) => {
const build = await loadDevBuild({ projectId });
const instances = JSON.parse(build.instances) as Array<{
id: string;
component: string;
}>;
const props = JSON.parse(build.props) as Array<{
instanceId: string;
name: string;
}>;
const instanceIds = new Set(
instances
.filter((instance) => instance.component === component)
.map((instance) => instance.id)
);
if (
props.some((prop) => instanceIds.has(prop.instanceId) && prop.name === name)
) {
throw new Error(
`Expected ${component} ${name} prop to be deleted. Props: ${JSON.stringify(props)}`
);
}
};
test("Props panel resource action persists after reload", async () => {
const fixture = await createContentModeProject({
email: "props-runtime@webstudio.test",
@@ -365,3 +469,111 @@ test("Props panel expression binding persists after reload", async () => {
await close();
}
});
test("Props panel boolean prop persists after reload", async () => {
const fixture = await createContentModeProject({
email: "props-boolean-runtime@webstudio.test",
title: "Props Boolean Runtime",
assetNamePrefix: "props-boolean-runtime-",
editorToken: "props-boolean-runtime-editor-token",
builderToken: "props-boolean-runtime-builder-token",
});
const { page, close } = await newIsolatedPage();
try {
await measure("props boolean runtime open builder", async () => {
await openProjectBuilder({
page,
projectId: fixture.projectId,
authToken: fixture.builderToken,
});
});
await waitForCanvasText({ page, text: "Initial content" });
await measure("props boolean runtime insert checkbox", async () => {
await openComponentsPanel({ page });
await insertComponentPanelOption({
page,
name: "Checkbox",
component: "@webstudio-is/sdk-components-react-radix:Checkbox",
});
});
await selectFirstNavigatorChild({
page,
parentLabel: "Checkbox Field",
});
await page.getByRole("tab", { name: "Settings" }).click();
await measure("props boolean runtime set checked", async () => {
await setSelectedBooleanProperty({
page,
label: "Checked",
checked: true,
});
});
await expectPersistedBooleanProp({
projectId: fixture.projectId,
component: "@webstudio-is/sdk-components-react-radix:Checkbox",
name: "checked",
value: true,
});
await measure("props boolean runtime reload builder", async () => {
await openProjectBuilder({
page,
projectId: fixture.projectId,
authToken: fixture.builderToken,
});
});
await selectFirstNavigatorChild({
page,
parentLabel: "Checkbox Field",
});
await page.getByRole("tab", { name: "Settings" }).click();
await waitForSelectedBooleanPropertyValue({
page,
label: "Checked",
checked: true,
});
await expectPersistedBooleanProp({
projectId: fixture.projectId,
component: "@webstudio-is/sdk-components-react-radix:Checkbox",
name: "checked",
value: true,
});
await measure("props boolean runtime reset checked", async () => {
await resetSelectedProperty({ page, label: "Checked" });
});
await expectBooleanPropDeleted({
projectId: fixture.projectId,
component: "@webstudio-is/sdk-components-react-radix:Checkbox",
name: "checked",
});
await measure("props boolean runtime reload after reset", async () => {
await openProjectBuilder({
page,
projectId: fixture.projectId,
authToken: fixture.builderToken,
});
});
await selectFirstNavigatorChild({
page,
parentLabel: "Checkbox Field",
});
await page.getByRole("tab", { name: "Settings" }).click();
await waitForSelectedBooleanPropertyValue({
page,
label: "Checked",
checked: false,
});
await expectBooleanPropDeleted({
projectId: fixture.projectId,
component: "@webstudio-is/sdk-components-react-radix:Checkbox",
name: "checked",
});
} finally {
await close();
}
});
@@ -42,6 +42,25 @@ const setCssValue = async ({
await waitForSyncStatus({ page, status: "idle" });
};
const resetCssValue = async ({
page,
property,
}: {
page: Page;
property: string;
}) => {
const input = page.getByLabel(property, { exact: true }).first();
const labelText = `${property.slice(0, 1).toUpperCase()}${property.slice(1)}`;
const control = input.locator(
`xpath=ancestor::*[.//*[normalize-space()=${JSON.stringify(labelText)}]][1]`
);
const label = control.getByText(labelText, { exact: true });
await label.waitFor({ state: "visible", timeout: 10_000 });
const save = waitForChangeToBeSaved({ page });
await label.click({ modifiers: ["Alt"] });
await save;
};
const selectStyleSourceState = async ({
page,
state,
@@ -73,6 +92,19 @@ const expectBuildStylesToContain = async ({
}
};
const expectBuildStylesNotToContain = async ({
fixture,
text,
}: {
fixture: SeededContentModeProject;
text: string;
}) => {
const styles = await readBuildStylesText(fixture);
if (styles.includes(text)) {
throw new Error(`Expected build styles not to contain "${text}".`);
}
};
test("Builder style panel edits representative styles and persists after reload", async () => {
const fixture = await createStylePanelRuntimeProject("styles");
const { page, close } = await newIsolatedPage();
@@ -143,6 +175,15 @@ test("Builder style panel edits representative styles and persists after reload"
text: `"state":":hover"`,
});
await page.getByRole("tab", { name: "Style" }).click();
await measure("style panel runtime reset css value", async () => {
await resetCssValue({ page, property: "width" });
});
await expectBuildStylesNotToContain({
fixture,
text: `"property":"width"`,
});
await measure("style panel runtime reload builder", async () => {
await openProjectBuilder({
page,
@@ -174,18 +215,16 @@ test("Builder style panel edits representative styles and persists after reload"
property: "color",
value: "rgb(34, 85, 255)",
});
await waitForCanvasTextStyle({
page,
text,
property: "width",
value: "420px",
});
await waitForHoveredCanvasTextStyle({
page,
text,
property: "color",
value: "rgb(202, 18, 107)",
});
await expectBuildStylesNotToContain({
fixture,
text: `"property":"width"`,
});
await expectBuildStylesToContain({
fixture,
text: `"state":":hover"`,
+72
View File
@@ -0,0 +1,72 @@
import { expect, test, vi } from "vitest";
import { runWithTimeout } from "./timeout";
test("aborts, cleans up, and waits for timed-out work to settle", async () => {
const events: string[] = [];
let releaseTask: (() => void) | undefined;
const cleanup = vi.fn(async () => {
events.push("cleanup");
releaseTask?.();
});
await expect(
runWithTimeout({
name: "slow test",
timeoutMs: 1,
onTimeout: cleanup,
task: async () => {
await new Promise<void>((resolve) => {
releaseTask = () => {
events.push("settled");
resolve();
};
});
},
})
).rejects.toThrow("Timed out after 1ms: slow test");
expect(cleanup).toHaveBeenCalledOnce();
expect(events).toEqual(["cleanup", "settled"]);
});
test("preserves timeout semantics when cleanup rejects", async () => {
const cleanupError = new Error("cleanup failed");
let releaseTask: (() => void) | undefined;
const result = runWithTimeout({
name: "cleanup failure",
timeoutMs: 1,
settleTimeoutMs: 50,
onTimeout: async () => {
releaseTask?.();
throw cleanupError;
},
task: async () => {
await new Promise<void>((resolve) => {
releaseTask = resolve;
});
},
});
await expect(result).rejects.toMatchObject({
message: "Timed out after 1ms: cleanup failure",
cause: cleanupError,
});
});
test("fails within the settlement grace period when work ignores cancellation", async () => {
const startedAt = Date.now();
await expect(
runWithTimeout({
name: "uncooperative test",
timeoutMs: 1,
settleTimeoutMs: 1,
task: async () => await new Promise<never>(() => undefined),
})
).rejects.toThrow(
"Timed out after 1ms: uncooperative test; task did not settle within 1ms"
);
expect(Date.now() - startedAt).toBeLessThan(100);
});
+60
View File
@@ -0,0 +1,60 @@
const delay = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
export const runWithTimeout = async <Result>({
name,
timeoutMs,
settleTimeoutMs = 5_000,
task,
onTimeout,
}: {
name: string;
timeoutMs: number;
settleTimeoutMs?: number;
task: (signal: AbortSignal) => Promise<Result>;
onTimeout?: () => Promise<void>;
}) => {
const controller = new AbortController();
const taskPromise = task(controller.signal);
let timeout: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<{ type: "timeout" }>((resolve) => {
timeout = setTimeout(() => resolve({ type: "timeout" }), timeoutMs);
});
try {
const outcome = await Promise.race([
taskPromise.then(
(value) => ({ type: "success" as const, value }),
(error: unknown) => ({ type: "failure" as const, error })
),
timeoutPromise,
]);
if (outcome.type === "success") {
return outcome.value;
}
if (outcome.type === "failure") {
throw outcome.error;
}
controller.abort();
let cleanupError: unknown;
try {
await onTimeout?.();
} catch (error) {
cleanupError = error;
}
const settled = await Promise.race([
taskPromise.then(
() => true,
() => true
),
delay(settleTimeoutMs).then(() => false),
]);
const error = new Error(
`Timed out after ${timeoutMs}ms: ${name}${settled ? "" : `; task did not settle within ${settleTimeoutMs}ms`}`,
cleanupError === undefined ? undefined : { cause: cleanupError }
);
throw error;
} finally {
clearTimeout(timeout);
}
};
+1 -1
View File
@@ -157,7 +157,7 @@
"react-test-renderer": "18.3.0-canary-14898b6a9-20240318",
"type-fest": "^4.37.0",
"typescript": "7.0.2",
"vite": "npm:rolldown-vite@^7.3.1",
"vite": "^6.3.4",
"vitest": "^3.1.2"
},
"sideEffects": false,
@@ -1,5 +1,5 @@
{
"bundleVersion": "bundle-a4rjtk",
"bundleVersion": "bundle-u14gh7",
"build": {
"id": "f565d527-32e7-4731-bc71-aca9e9574587",
"projectId": "d845c167-ea07-4875-b08d-83e97c09dcce",
@@ -7,11 +7,6 @@
"createdAt": "2025-01-04T11:01:50.091+00:00",
"updatedAt": "2025-01-04T11:01:50.091+00:00",
"pages": {
"meta": {
"siteName": "",
"faviconAssetId": "d0974db9300c1a3b0fb8b291dd9fabd45ad136478908394280af2f7087e3aecd",
"code": ""
},
"homePageId": "9di_L14CzctvSruIoKVvE",
"rootFolderId": "root",
"pages": [
@@ -411,6 +406,14 @@
]
],
"resources": [],
"projectSettings": {
"meta": {
"siteName": "",
"faviconAssetId": "d0974db9300c1a3b0fb8b291dd9fabd45ad136478908394280af2f7087e3aecd",
"code": ""
},
"compiler": {}
},
"deployment": {
"destination": "saas",
"domains": ["cli-basic-test-d0osr"],
@@ -1,5 +1,5 @@
{
"bundleVersion": "bundle-a4rjtk",
"bundleVersion": "bundle-u14gh7",
"build": {
"id": "f565d527-32e7-4731-bc71-aca9e9574587",
"projectId": "d845c167-ea07-4875-b08d-83e97c09dcce",
@@ -7,11 +7,6 @@
"createdAt": "2025-01-04T11:01:50.091+00:00",
"updatedAt": "2025-01-04T11:01:50.091+00:00",
"pages": {
"meta": {
"siteName": "",
"faviconAssetId": "d0974db9300c1a3b0fb8b291dd9fabd45ad136478908394280af2f7087e3aecd",
"code": ""
},
"homePageId": "9di_L14CzctvSruIoKVvE",
"rootFolderId": "root",
"pages": [
@@ -411,6 +406,14 @@
]
],
"resources": [],
"projectSettings": {
"meta": {
"siteName": "",
"faviconAssetId": "d0974db9300c1a3b0fb8b291dd9fabd45ad136478908394280af2f7087e3aecd",
"code": ""
},
"compiler": {}
},
"deployment": {
"destination": "saas",
"domains": ["cli-basic-test-d0osr"],
@@ -1,5 +1,5 @@
{
"bundleVersion": "bundle-a4rjtk",
"bundleVersion": "bundle-u14gh7",
"build": {
"id": "f565d527-32e7-4731-bc71-aca9e9574587",
"projectId": "d845c167-ea07-4875-b08d-83e97c09dcce",
@@ -7,11 +7,6 @@
"createdAt": "2025-01-04T11:01:50.091+00:00",
"updatedAt": "2025-01-04T11:01:50.091+00:00",
"pages": {
"meta": {
"siteName": "",
"faviconAssetId": "d0974db9300c1a3b0fb8b291dd9fabd45ad136478908394280af2f7087e3aecd",
"code": ""
},
"homePageId": "9di_L14CzctvSruIoKVvE",
"rootFolderId": "root",
"pages": [
@@ -411,6 +406,14 @@
]
],
"resources": [],
"projectSettings": {
"meta": {
"siteName": "",
"faviconAssetId": "d0974db9300c1a3b0fb8b291dd9fabd45ad136478908394280af2f7087e3aecd",
"code": ""
},
"compiler": {}
},
"deployment": {
"destination": "saas",
"domains": ["cli-basic-test-d0osr"],
@@ -1,5 +1,5 @@
{
"bundleVersion": "bundle-a4rjtk",
"bundleVersion": "bundle-u14gh7",
"build": {
"id": "f565d527-32e7-4731-bc71-aca9e9574587",
"projectId": "d845c167-ea07-4875-b08d-83e97c09dcce",
@@ -7,11 +7,6 @@
"createdAt": "2025-01-04T11:01:50.091+00:00",
"updatedAt": "2025-01-04T11:01:50.091+00:00",
"pages": {
"meta": {
"siteName": "",
"faviconAssetId": "d0974db9300c1a3b0fb8b291dd9fabd45ad136478908394280af2f7087e3aecd",
"code": ""
},
"homePageId": "9di_L14CzctvSruIoKVvE",
"rootFolderId": "root",
"pages": [
@@ -411,6 +406,14 @@
]
],
"resources": [],
"projectSettings": {
"meta": {
"siteName": "",
"faviconAssetId": "d0974db9300c1a3b0fb8b291dd9fabd45ad136478908394280af2f7087e3aecd",
"code": ""
},
"compiler": {}
},
"deployment": {
"destination": "saas",
"domains": ["cli-basic-test-d0osr"],
@@ -1,5 +1,5 @@
{
"bundleVersion": "bundle-a4rjtk",
"bundleVersion": "bundle-u14gh7",
"build": {
"id": "a2e8de30-03d5-4514-a3a6-406b3266a3af",
"projectId": "d845c167-ea07-4875-b08d-83e97c09dcce",
@@ -7,11 +7,6 @@
"createdAt": "2024-07-29T12:50:07.515+00:00",
"updatedAt": "2024-07-29T12:50:07.515+00:00",
"pages": {
"meta": {
"siteName": "",
"faviconAssetId": "",
"code": ""
},
"homePageId": "9di_L14CzctvSruIoKVvE",
"rootFolderId": "root",
"pages": [
@@ -411,6 +406,14 @@
]
],
"resources": [],
"projectSettings": {
"meta": {
"siteName": "",
"faviconAssetId": "",
"code": ""
},
"compiler": {}
},
"deployment": {
"domains": [],
"projectDomain": "cli-basic-test-d0osr"
@@ -1,5 +1,5 @@
{
"bundleVersion": "bundle-a4rjtk",
"bundleVersion": "bundle-u14gh7",
"build": {
"id": "8f0fdff2-e76b-4f86-9203-ea7df5f1143c",
"projectId": "8a7358b1-7de3-459d-b7b1-56dddfb6ce1e",
@@ -7,7 +7,6 @@
"createdAt": "2026-01-04T10:46:48.415+00:00",
"updatedAt": "2026-01-04T10:46:48.415+00:00",
"pages": {
"meta": {},
"homePageId": "Y60VJnVZMU_ifI0GnAR1N",
"rootFolderId": "root",
"pages": [
@@ -145,6 +144,10 @@
]
],
"resources": [],
"projectSettings": {
"meta": {},
"compiler": {}
},
"deployment": {
"destination": "saas",
"domains": ["fixture-client-do-not-touch-wsmel"],
@@ -1,5 +1,5 @@
{
"bundleVersion": "bundle-a4rjtk",
"bundleVersion": "bundle-u14gh7",
"build": {
"id": "a2e8de30-03d5-4514-a3a6-406b3266a3af",
"projectId": "d845c167-ea07-4875-b08d-83e97c09dcce",
@@ -7,11 +7,6 @@
"createdAt": "2024-07-29T12:50:07.515+00:00",
"updatedAt": "2024-07-29T12:50:07.515+00:00",
"pages": {
"meta": {
"siteName": "",
"faviconAssetId": "",
"code": ""
},
"homePageId": "9di_L14CzctvSruIoKVvE",
"rootFolderId": "root",
"pages": [
@@ -411,6 +406,14 @@
]
],
"resources": [],
"projectSettings": {
"meta": {
"siteName": "",
"faviconAssetId": "",
"code": ""
},
"compiler": {}
},
"deployment": {
"domains": [],
"projectDomain": "cli-basic-test-d0osr"
@@ -1,5 +1,5 @@
{
"bundleVersion": "bundle-a4rjtk",
"bundleVersion": "bundle-u14gh7",
"build": {
"id": "1b66ee06-8ea5-4420-9a0e-e0d3a67aca32",
"projectId": "cddc1d44-af37-4cb6-a430-d300cf6f932d",
@@ -7,11 +7,6 @@
"createdAt": "2026-01-15T16:19:55.574+00:00",
"updatedAt": "2026-01-15T16:19:55.574+00:00",
"pages": {
"meta": {
"siteName": "KittyGuardedZone",
"faviconAssetId": "7cf5892080fa66b5e6175ffd2d27c304ee6b09ce1f21847a95000225ad1afa59",
"code": "<script>console.log('KittyGuardedZone')</script>\n"
},
"homePageId": "7Db64ZXgYiRqKSQNR-qTQ",
"rootFolderId": "root",
"pages": [
@@ -6885,6 +6880,14 @@
}
]
],
"projectSettings": {
"meta": {
"siteName": "KittyGuardedZone",
"faviconAssetId": "7cf5892080fa66b5e6175ffd2d27c304ee6b09ce1f21847a95000225ad1afa59",
"code": "<script>console.log('KittyGuardedZone')</script>\n"
},
"compiler": {}
},
"deployment": {
"destination": "saas",
"domains": ["webstudio-fixture-project-a-0su3o"],
+1
View File
@@ -42,6 +42,7 @@
"deepmerge": "^4.3.1",
"decode-named-character-reference": "^1.0.2",
"env-paths": "^3.0.0",
"esbuild": "^0.25.3",
"fast-deep-equal": "^3.1.3",
"immer": "^10.1.1",
"nanoid": "^5.1.5",
+11 -3
View File
@@ -4,12 +4,16 @@ import { getAllPages, getPagePath, type Pages } from "@webstudio-is/sdk";
import { createWsAuthResources } from "@webstudio-is/wsauth";
import { migratePages } from "@webstudio-is/project-migrations/pages";
import type { PublishedProjectBundle } from "@webstudio-is/protocol";
import type { ProjectSettings } from "@webstudio-is/project-build";
export const LOCAL_AUTH_FILE = ".webstudio/auth.json";
export const createAuthConfigResources = (pages: Pages) =>
export const createAuthConfigResources = (
pages: Pages,
projectSettings?: ProjectSettings
) =>
createWsAuthResources({
projectContent: pages.meta?.auth,
projectContent: projectSettings?.meta.auth ?? pages.meta?.auth,
pages: getAllPages(pages)
.filter((page) => page.meta.auth !== undefined)
.map((page) => ({
@@ -20,7 +24,11 @@ export const createAuthConfigResources = (pages: Pages) =>
export const createAuthConfigContentFromBundle = (
data: Pick<PublishedProjectBundle, "build">
) => createAuthConfigResources(migratePages(data.build.pages)).content;
) =>
createAuthConfigResources(
migratePages(data.build.pages),
data.build.projectSettings
).content;
export const assertAuthConfigMatchesBundle = ({
authFileContent,
@@ -0,0 +1,10 @@
import { describe, expect, test } from "vitest";
import { isExternal } from "../vite.config";
describe("CLI bundle externals", () => {
test("loads esbuild and Node builtins from the installed environment", () => {
expect(isExternal("esbuild")).toBe(true);
expect(isExternal("esbuild/lib/main.js")).toBe(true);
expect(isExternal("node:fs")).toBe(true);
});
});
+17 -1
View File
@@ -209,11 +209,12 @@ describe("registerCommands", () => {
expect(commandNames(commands)).toEqual(
expect.arrayContaining([
"preview",
"screenshot <url>",
"screenshot [url]",
"import",
"permissions",
"publish",
"domains",
"registry",
"mcp",
"$0",
])
@@ -236,6 +237,13 @@ describe("registerCommands", () => {
"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(
@@ -268,4 +276,12 @@ describe("registerCommands", () => {
expect(domainsHelp).toContain("webstudio domains list");
expect(domainsHelp).toContain("webstudio domains create");
});
test("documents registry inspection as a read-only nested command", async () => {
const output = await getHelpOutput(["registry", "inspect", "--help"]);
expect(output).toContain("--source");
expect(output).toContain("--item");
expect(output).toContain("No files are installed");
});
});
+17 -2
View File
@@ -22,6 +22,7 @@ import {
} from "./commands/api-command-metadata";
import { schema, schemaOptions } from "./commands/schema";
import { initFlow, initOptions } from "./commands/init-flow";
import { registryInspect, registryInspectOptions } from "./commands/registry";
import makeCLI from "yargs";
import packageJson from "../package.json" with { type: "json" };
import type { CommonYargsArgv } from "./commands/yargs-types";
@@ -190,11 +191,25 @@ export const registerCommands = (cmd: CommonYargsArgv) => {
preview
);
cmd.command(
["screenshot <url>"],
"Capture a PNG screenshot of a URL with an installed Chromium-family browser",
["screenshot [url]"],
"Capture a PNG screenshot of a URL or local generated project route",
screenshotOptions,
screenshot
);
cmd.command(
["registry"],
"Inspect external component registries without installing items",
(yargs: CommonYargsArgv) =>
yargs
.command(
["inspect"],
"Inspect one local or remote shadcn registry item",
registryInspectOptions,
registryInspect
)
.demandCommand(1, "Specify a registry command."),
() => undefined
);
const { direct, grouped } = getGroupedCommands(cliCommandMetadata);
for (const metadata of direct) {
registerApiCommand(cmd, metadata.cliCommand, metadata);
@@ -1,5 +1,6 @@
import {
publicApiOperations,
type InputJsonSchema,
type PublicApiOperationMethod,
type PublicApiOperationPermit,
} from "@webstudio-is/protocol";
@@ -17,6 +18,8 @@ type ApiCommandMetadata = {
inputFields: readonly string[];
requiredInputFields: readonly string[];
inputFieldTypes: Partial<Record<string, "array">>;
inputSchema: InputJsonSchema;
outputSchema?: InputJsonSchema;
requiredOptions?: readonly string[];
examples: readonly string[];
};
@@ -77,9 +80,10 @@ export const topLevelCliCommandMetadata = [
{
command: "screenshot",
description:
"Capture a PNG screenshot of a URL with an installed Chromium-family browser",
"Capture a PNG screenshot of an absolute URL or a generated project route with an installed Chromium-family browser",
examples: [
'webstudio screenshot "https://example.com" --output current.png --width 1440 --height 900',
"webstudio screenshot --path /pricing --output pricing.png",
],
},
{
@@ -122,6 +126,7 @@ export const topLevelCliCommandMetadata = [
const apiCommandOptionsByCommand: Partial<
Record<ApiCommandName, ApiCommandOptions>
> = {
audit: apiCommand.auditCommandOptions,
snapshot: apiCommand.snapshotCommandOptions,
"apply-patch": apiCommand.applyPatchCommandOptions,
"list-pages": apiCommand.pagesCommandOptions,
@@ -200,6 +205,7 @@ const apiCommandOptionsByCommand: Partial<
"delete-domain": apiCommand.deleteDomainCommandOptions,
"verify-domain": apiCommand.verifyDomainCommandOptions,
"list-assets": apiCommand.assetsCommandOptions,
"list-fonts": apiCommand.fontsCommandOptions,
"upload-asset": apiCommand.uploadAssetCommandOptions,
"upload-assets": apiCommand.uploadAssetsCommandOptions,
"find-asset-usage": apiCommand.assetCommandOptions,
@@ -215,11 +221,16 @@ export const apiCommandMetadata = publicApiOperations.map((operation) => ({
inputFields: operation.inputFields,
requiredInputFields: operation.requiredInputFields,
inputFieldTypes: operation.inputFieldTypes,
inputSchema: operation.inputSchema,
...(operation.outputSchema === undefined
? {}
: { outputSchema: operation.outputSchema }),
requiredOptions: operation.requiredOptions,
examples: operation.examples,
})) satisfies ApiCommandMetadata[];
export const highLevelCliCommands = [
{ command: "audit", operation: "audit" },
{ command: "permissions", operation: "permissions" },
{
command: "get-marketplace-product",
+117 -5
View File
@@ -1,10 +1,12 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import makeCLI from "yargs";
import {
getPublicApiOperation,
publicApiOperations,
} from "@webstudio-is/protocol";
import { apiCompatibilityHeaders } from "./api";
import { apiCommand } from "./api-command";
import { apiCommand, auditCommandOptions } from "./api-command";
import type { CommonYargsArgv } from "./yargs-types";
import { apiCommandMetadata } from "./api-command-metadata";
const apiCalls = new Proxy({} as Record<string, ReturnType<typeof vi.fn>>, {
@@ -112,8 +114,8 @@ const expectJsonOutput = (command: string) => {
version: 1,
source: expect.any(String),
committed: expect.any(Boolean),
namespaces: { read: [], write: [], invalidated: [], missing: [] },
diagnostics: [],
namespaceCounts: { read: 0, write: 0, invalidated: 0, missing: 0 },
diagnosticCount: 0,
},
},
});
@@ -425,7 +427,7 @@ test("passes refresh to local-capable command session", async () => {
);
const session = createCliProjectSession.mock.results[0]?.value;
expect(session.refresh).toHaveBeenCalledWith(["pages"]);
expect(session.refresh).toHaveBeenCalledWith(["pages", "projectSettings"]);
expect(session.read).toHaveBeenCalledWith(
"pages.list",
{ projectId: "project-1", includeFolders: undefined },
@@ -472,6 +474,102 @@ test("calls build snapshot for configured project", async () => {
});
});
test("runs a focused project audit", async () => {
await expectCommandCall({
options: {
command: "audit",
scopes: ["accessibility", "seo"],
severities: ["error", "warning"],
pagePath: "/pricing",
limit: 25,
cursor: "cursor-1",
verbose: true,
},
call: apiCalls.audit,
connection: {
scopes: ["accessibility", "seo"],
severities: ["error", "warning"],
pageId: undefined,
pagePath: "/pricing",
limit: 25,
cursor: "cursor-1",
verbose: true,
},
});
});
test("parses the documented repeated audit scope options", async () => {
const parsed = await auditCommandOptions(
makeCLI([]).exitProcess(false) as unknown as CommonYargsArgv
).parseAsync([
"--scopes",
"accessibility",
"--scopes",
"seo",
"--page-path",
"/pricing",
]);
expect(parsed).toMatchObject({
scopes: ["accessibility", "seo"],
pagePath: "/pricing",
});
});
test("prints a compact project audit without --json", async () => {
mockConfig();
apiCalls.audit.mockResolvedValueOnce({
summary: {
total: 1,
selectedTotal: 1,
bySeverity: { error: 1, warning: 0, info: 0 },
},
findings: [
{
severity: "error",
scope: "accessibility",
ruleId: "missing-alt",
message: "Image has no alt prop.",
location: { instanceId: "image-1" },
},
],
skippedCheckCount: 0,
manualCheckCount: 3,
nextCursor: null,
});
await apiCommand({ command: "audit" }, dependencies);
expect(console.info).toHaveBeenCalledWith(
"Audit: 1 findings (1 errors, 0 warnings, 0 info)"
);
expect(console.info).toHaveBeenCalledWith(
"[ERROR] accessibility/missing-alt: Image has no alt prop. (image-1)"
);
expect(console.info).toHaveBeenCalledWith("3 manual checks recommended.");
});
test("labels filtered audit totals without implying hidden findings are returned", async () => {
mockConfig();
apiCalls.audit.mockResolvedValueOnce({
summary: {
total: 5,
selectedTotal: 1,
bySeverity: { error: 1, warning: 3, info: 1 },
},
findings: [],
skippedCheckCount: 0,
manualCheckCount: 0,
nextCursor: "next",
});
await apiCommand({ command: "audit" }, dependencies);
expect(console.info).toHaveBeenCalledWith(
"Audit: 1 selected findings (5 across all severities) (1 errors, 3 warnings, 1 info)"
);
});
test("applies build patch transactions for configured project", async () => {
await expectCommandCall({
options: {
@@ -1539,6 +1637,14 @@ test("calls asset list with pagination", async () => {
});
});
test("lists fonts with optional system stacks", async () => {
await expectCommandCall({
options: { command: "list-fonts", includeSystem: false },
call: apiCalls.listFonts,
connection: { includeSystem: false },
});
});
test("uploads asset descriptor with local file reader", async () => {
mockConfig();
readFile.mockResolvedValueOnce(
@@ -1678,11 +1784,17 @@ test("lists design tokens with filters", async () => {
options: {
command: "list-design-tokens",
filter: "brand",
includeStyles: true,
withUsage: true,
sort: "usage",
},
call: apiCalls.listDesignTokens,
connection: { filter: "brand", withUsage: true, sort: "usage" },
connection: {
filter: "brand",
includeStyles: true,
withUsage: true,
sort: "usage",
},
});
});
+165 -11
View File
@@ -149,7 +149,7 @@ export const createPageCommandOptions = (yargs: CommonYargsArgv) =>
})
.option("title", {
type: "string",
describe: "Page title expression; defaults to --name",
describe: "Page title text or expression; defaults to --name",
})
.option("parent-folder", {
type: "string",
@@ -173,15 +173,15 @@ export const updatePageCommandOptions = (yargs: CommonYargsArgv) =>
})
.option("title", {
type: "string",
describe: "Update page title expression",
describe: "Update page title text or expression",
})
.option("description", {
type: "string",
describe: "Update page meta description expression",
describe: "Update page meta description text or expression",
})
.option("language", {
type: "string",
describe: "Update page language expression",
describe: "Update page language text or expression",
})
.option("redirect", {
type: "string",
@@ -404,17 +404,15 @@ const pageTemplateFieldsCommandOptions = (
})
.option("title", {
type: "string",
describe:
'Page template title expression. For fixed text use a JavaScript string literal such as "\\"Landing\\""',
describe: "Page template title text or expression",
})
.option("description", {
type: "string",
describe:
'Page template meta description expression. For fixed text use a JavaScript string literal such as "\\"Plans for teams\\""',
describe: "Page template meta description text or expression",
})
.option("language", {
type: "string",
describe: 'Page template language expression, for example "\\"en\\""',
describe: "Page template language text or expression",
})
.option("social-image-url", {
type: "string",
@@ -789,8 +787,13 @@ export const designTokensCommandOptions = (yargs: CommonYargsArgv) =>
type: "string",
describe: "Only return design tokens whose name contains this text",
})
.option("include-styles", {
type: "boolean",
describe: "Include full inline style declarations for each design token",
})
.option("with-usage", {
type: "boolean",
default: true,
describe: "Include how many instances use each design token",
})
.option("sort", {
@@ -1146,6 +1149,61 @@ export const assetsCommandOptions = (yargs: CommonYargsArgv) =>
describe: "Maximum number of assets to return",
});
export const auditCommandOptions = (yargs: CommonYargsArgv) =>
apiCommandOptions(yargs)
.option("scopes", {
type: "array",
string: true,
choices: [
"accessibility",
"security",
"seo",
"assets",
"styles",
"performance",
] as const,
describe: "Audit only the selected project-quality scopes",
})
.option("severities", {
type: "array",
string: true,
choices: ["error", "warning", "info"] as const,
describe: "Return only findings with the selected severities",
})
.option("page-id", {
type: "string",
describe: "Audit page-owned findings for one page id",
})
.option("page-path", {
type: "string",
describe: "Audit page-owned findings for one page path",
})
.option("limit", {
type: "number",
describe: "Maximum findings to return, from 1 to 200",
})
.option("cursor", {
type: "string",
describe: "Pagination cursor returned by the previous audit call",
})
.option("verbose", {
type: "boolean",
describe:
"Include full finding evidence, remediation, skipped checks, and manual-check workflows",
})
.example("$0 audit --json", "Run every project audit")
.example(
"$0 audit --scopes accessibility --scopes seo --page-path /pricing --json",
"Audit accessibility and SEO for one page"
);
export const fontsCommandOptions = (yargs: CommonYargsArgv) =>
apiCommandOptions(yargs).option("include-system", {
type: "boolean",
default: true,
describe: "Include built-in system font stacks",
});
export const uploadAssetCommandOptions = (yargs: CommonYargsArgv) =>
requiredInputOption(
apiCommandOptions(yargs).option("assets-dir", {
@@ -1291,11 +1349,18 @@ type ApiCommandOptions = {
position?: "before-local" | "after-local" | "before" | "after";
filter?: string;
withUsage?: boolean;
includeStyles?: boolean;
includeSystem?: boolean;
scopeInstance?: string;
type?: "image" | "font" | "file";
sort?: "name" | "usage" | "size" | "createdAt";
cursor?: string;
limit?: number;
scopes?: Array<"accessibility" | "security" | "seo" | "assets" | "styles">;
severities?: Array<"error" | "warning" | "info">;
pageId?: string;
pagePath?: string;
verbose?: boolean;
dryRun?: boolean;
refresh?: boolean;
};
@@ -1726,6 +1791,65 @@ type ApiCommandHandler = (
dependencies: ApiCommandDependencies
) => Promise<unknown>;
const printAuditReport = (value: unknown) => {
if (typeof value !== "object" || value === null) {
console.info("Audit completed.");
return;
}
const result = value as {
summary?: {
total?: number;
selectedTotal?: number;
bySeverity?: Record<string, number>;
};
findings?: Array<{
severity?: string;
scope?: string;
ruleId?: string;
message?: string;
location?: Record<string, unknown>;
}>;
skippedChecks?: unknown[];
manualChecks?: unknown[];
skippedCheckCount?: number;
manualCheckCount?: number;
nextCursor?: string | null;
};
const severity = result.summary?.bySeverity ?? {};
const total = result.summary?.total ?? 0;
const selectedTotal = result.summary?.selectedTotal ?? total;
console.info(
`Audit: ${
selectedTotal === total
? `${total} findings`
: `${selectedTotal} selected findings (${total} across all severities)`
} ` +
`(${severity.error ?? 0} errors, ${severity.warning ?? 0} warnings, ${severity.info ?? 0} info)`
);
for (const finding of result.findings ?? []) {
const location = Object.values(finding.location ?? {})
.flatMap((item) => (Array.isArray(item) ? item : [item]))
.filter((item) => item !== undefined)
.join(" ");
console.info(
`[${finding.severity?.toUpperCase() ?? "INFO"}] ${finding.scope ?? "audit"}/${finding.ruleId ?? "finding"}: ${finding.message ?? ""}${location === "" ? "" : ` (${location})`}`
);
}
const skippedCheckCount =
result.skippedCheckCount ?? result.skippedChecks?.length ?? 0;
const manualCheckCount =
result.manualCheckCount ?? result.manualChecks?.length ?? 0;
if (skippedCheckCount > 0) {
console.info(`${skippedCheckCount} checks skipped.`);
}
if (manualCheckCount > 0) {
console.info(`${manualCheckCount} manual checks recommended.`);
}
if (result.nextCursor != null) {
console.info(`More findings: rerun with --cursor '${result.nextCursor}'.`);
}
};
const apiCommandHandlers: Partial<Record<ApiCommandName, ApiCommandHandler>> = {
whoami: async (_options, connection, dependencies) =>
runProjectSessionCommand("whoami", {}, connection, dependencies),
@@ -1745,6 +1869,21 @@ const apiCommandHandlers: Partial<Record<ApiCommandName, ApiCommandHandler>> = {
dependencies
);
},
audit: async (options, connection, dependencies) =>
runProjectSessionCommand(
"audit",
{
scopes: listOption(options.scopes),
severities: listOption(options.severities),
pageId: options.pageId,
pagePath: options.pagePath,
limit: options.limit,
cursor: options.cursor,
verbose: options.verbose,
},
connection,
dependencies
),
"apply-patch": async (options, connection, dependencies) => {
const patchInput = await readJsonFile(
dependencies,
@@ -2361,6 +2500,7 @@ const apiCommandHandlers: Partial<Record<ApiCommandName, ApiCommandHandler>> = {
const input = {
filter: options.filter,
withUsage: options.withUsage,
includeStyles: options.includeStyles,
sort: designTokenSortOption(options.sort),
};
return runProjectSessionCommand(
@@ -2709,6 +2849,13 @@ const apiCommandHandlers: Partial<Record<ApiCommandName, ApiCommandHandler>> = {
dependencies
);
},
"list-fonts": async (options, connection, dependencies) =>
runProjectSessionCommand(
"list-fonts",
{ includeSystem: options.includeSystem },
connection,
dependencies
),
"upload-asset": async (options, connection, dependencies) => {
const input = createLocalUploadAssetInput({
asset: await readInputObject<UploadAssetInput>(dependencies, options),
@@ -2779,7 +2926,7 @@ export const apiCommand = async (
const start = Date.now();
let projectId: string | undefined;
try {
if (options.json !== true) {
if (options.json !== true && options.command !== "audit") {
throw new Error(`${options.command} currently requires --json.`);
}
@@ -2809,9 +2956,15 @@ export const apiCommand = async (
const session = isProjectSessionEnvelope(response)
? getProjectSessionMeta(response)
: undefined;
const result = isProjectSessionEnvelope(response)
? response.result
: response;
if (options.command === "audit" && options.json !== true) {
printAuditReport(result);
} else {
printJson({
ok: true,
data: isProjectSessionEnvelope(response) ? response.result : response,
data: result,
meta: {
command: options.command,
projectId: connection.projectId,
@@ -2819,6 +2972,7 @@ export const apiCommand = async (
...(session === undefined ? {} : { session }),
},
});
}
} catch (error) {
const message = getCliErrorMessage(error);
if (options.json === true) {
+3 -3
View File
@@ -65,7 +65,7 @@ test("saves global credentials and local project config", async () => {
token: "token-1",
},
});
expect(JSON.parse(await readFile(LOCAL_CONFIG_FILE, "utf8"))).toEqual({
projectId,
});
expect(await readFile(LOCAL_CONFIG_FILE, "utf8")).toBe(
`${JSON.stringify({ projectId }, undefined, 2)}\n`
);
});
+1 -1
View File
@@ -129,7 +129,7 @@ export const saveShareLink = async (shareLink: string) => {
await mkdir(dirname(localConfigPath), { recursive: true });
await writeFile(
localConfigPath,
JSON.stringify(localConfig, null, 2),
`${JSON.stringify(localConfig, null, 2)}\n`,
"utf8"
);
+20 -6
View File
@@ -202,6 +202,7 @@ test("prints api manual as json", () => {
useCase: "Update project settings",
commands: [
'MCP tool: update-project-settings {"meta":{"siteName":"Acme"}}',
'MCP tool: update-project-settings {"meta":{"agentInstructions":"Use existing design tokens and keep product copy concise."}}',
],
}),
])
@@ -326,12 +327,11 @@ test("prints llm manual with discovery rules", () => {
expect(output).toContain(
"Use `bind-props` only when the prop must stay dynamic"
);
expect(output).toContain('"\\"Pricing | Acme\\""');
expect(output).toContain(
'{"pageId":"page-id","values":{"title":"\\"Pricing | Acme\\"","meta":{"description":"\\"Plans for teams\\""}}}'
'{"pageId":"page-id","values":{"title":"Pricing | Acme","meta":{"description":"Plans for teams"}}}'
);
expect(output).toContain(
'{"resourceId":"resource-id","values":{"url":"\\"https://api.example.com/items\\""}}'
'{"resourceId":"resource-id","values":{"url":"https://api.example.com/items"}}'
);
expect(output).toContain(
"Make edits through Webstudio semantic commands/MCP tools"
@@ -427,7 +427,7 @@ test("prints llm manual as json with implementation process", () => {
"Use direct values for static strings and bindings only for dynamic expressions/resources/actions."
);
expect(output.rules).toContain(
"For expression-backed fields that need fixed text, encode the fixed text as a quoted JavaScript string literal expression."
"Use plain fixed text where documented. Only encode a quoted JavaScript string literal when a field is explicitly documented as an expression-only value."
);
expect(output.visionVerificationLoop).toContain(
getVisionVerificationLoop({ includeDiff: true })[1]
@@ -502,8 +502,12 @@ test("prints mcp manual as json", () => {
expect.stringContaining("viewport"),
])
);
expect(output.visionVerificationLoop).toContain(
"When a baseline PNG exists, call screenshot.diff with baselinePath, currentPath, and outputDir for each page/viewport pair."
expect(output.visionVerificationLoop).toEqual(
expect.arrayContaining([
expect.stringContaining(
"Add expectedText when a specific visible phrase must be present"
),
])
);
expect(output.screenshotVerification).toContain("screenshot.diff");
expect(output.screenshotVerification).toContain("OCR textAnalysis");
@@ -517,6 +521,16 @@ test("prints mcp manual as json", () => {
outputDir: "visual-diff",
threshold: 0.1,
ignoreTopNormalizedY: 0,
expectedText: ["Pricing", "Start free"],
expectedVisual: {
maxMismatchPercentage: 2,
maxChangedRegions: 3,
dominantColorChange: {
channel: "luminance",
direction: "increase",
minMagnitude: 10,
},
},
},
]);
expect(output.mcpArgumentExamples["update-text"]).toEqual([
+7 -2
View File
@@ -435,6 +435,9 @@ const apiCommandsByArea = {
setupAndDiscovery: cliCommandMetadata
.filter(({ cliCommand }) => cliCommand === "permissions")
.map(({ cliCommand }) => cliCommand),
projectQuality: cliCommandMetadata
.filter(({ cliCommand }) => cliCommand === "audit")
.map(({ cliCommand }) => cliCommand),
publishAndDomains: cliCommandMetadata
.filter(
({ cliCommand }) =>
@@ -608,7 +611,9 @@ const createLlmManualJson = (
readCommands,
writeCommands,
sessionBehavior: [
"Read meta.session.source and meta.session.namespaces to understand whether data came from local cache, remote refresh, dry-run, or server-only execution.",
"Read meta.session.source and meta.session.namespaceCounts to understand whether data came from local cache, remote refresh, dry-run, or server-only execution.",
"For a dry-run mutation, inspect meta.session.transaction for the computed Builder patch; meta.session.version is its base build version.",
"Use status with { verbose: true } only when debugging full namespace arrays, freshness, compatibility, or diagnostic details.",
"Use --refresh before a local-capable command when the cached snapshot may be stale.",
"A mutation is durable only when meta.session.committed is true.",
],
@@ -700,7 +705,7 @@ const topics = {
refreshFlag:
"Use --refresh to refresh required namespaces before local-capable commands.",
metadata:
"Successful command JSON includes meta.session with operationId, buildId, version, source, committed, compatibility, namespace metadata, and diagnostics.",
"Successful command JSON includes compact meta.session with operationId, buildId, version, source, committed, namespaceCounts, diagnosticCount, non-empty diagnostic summaries, and optional compatibilityVersion.",
},
},
apiDocSections
+16
View File
@@ -689,6 +689,15 @@ const createCaptureScreenshotMock = (events: string[]) =>
fullPage: options.fullPage === true,
elapsedMs: 1,
warnings: [],
layout: {
viewportWidth: options.width,
viewportHeight: options.height,
contentWidth: options.width + 20,
contentHeight: options.height * 2,
horizontalOverflow: true,
images: [],
resources: [],
},
};
});
@@ -737,6 +746,13 @@ test("captures stale path screenshots through the restarted preview server", asy
)
).resolves.toMatchObject({
output: "screenshot.png",
layout: {
viewportWidth: 1440,
contentWidth: 1460,
horizontalOverflow: true,
images: [],
resources: [],
},
});
expect(captureScreenshot).toHaveBeenCalledWith(
+5
View File
@@ -86,6 +86,8 @@ type McpScreenshotInput = {
port?: number;
viewport: { width: number; height: number };
fullPage?: boolean;
includeImageMetrics?: boolean;
includeResourceMetrics?: boolean;
browser?: CaptureScreenshotInput["browser"];
browserPath?: string;
waitUntil?: CaptureScreenshotInput["waitUntil"];
@@ -368,6 +370,8 @@ const createMcpPreviewHandlers = ({
width: input.viewport.width,
height: input.viewport.height,
fullPage: input.fullPage,
includeImageMetrics: input.includeImageMetrics,
includeResourceMetrics: input.includeResourceMetrics,
browser: input.browser ?? "auto",
browserPath: input.browserPath,
waitUntil: input.waitUntil,
@@ -906,6 +910,7 @@ const createCliMcpHost = async () => {
fullPage: result.fullPage,
elapsedMs: result.elapsedMs,
warnings: result.warnings,
layout: result.layout,
};
},
async diffScreenshots(input) {
@@ -49,6 +49,10 @@ test("prepares preview by syncing missing data and generating the app template",
})),
spinner: vi.fn(),
writeFile: vi.fn(async () => undefined),
materializeManagedAgents: vi.fn(async () => ({
path: "/project/AGENTS.md",
status: "unchanged" as const,
})),
};
const accessLocalDataFile = vi.fn(async () => {
throw Object.assign(new Error("missing"), { code: "ENOENT" });
+231
View File
@@ -0,0 +1,231 @@
import { expect, test, vi } from "vitest";
import { inspectRegistry } from "./registry";
test("inspects an item embedded in a local registry index", async () => {
const readFile = vi.fn(async () =>
JSON.stringify({
items: [
{
name: "button",
title: "Button",
type: "registry:ui",
dependencies: ["class-variance-authority"],
registryDependencies: ["utils"],
files: [
{
path: "registry/new-york/button/button.tsx",
target: "components/ui/button.tsx",
type: "registry:ui",
},
],
docs: "https://example.com/button",
},
],
})
);
await expect(
inspectRegistry(
{ source: "/tmp/registry.json", item: "button" },
{ readFile, fetch: async (source) => fetch(source) }
)
).resolves.toEqual({
source: "/tmp/registry.json",
name: "button",
title: "Button",
description: undefined,
type: "registry:ui",
dependencies: ["class-variance-authority"],
devDependencies: [],
registryDependencies: ["utils"],
files: [
{
path: "registry/new-york/button/button.tsx",
target: "components/ui/button.tsx",
type: "registry:ui",
},
],
docs: "https://example.com/button",
compatibility: {
status: "unsupported",
installation: {
status: "unsupported",
reason:
"Webstudio does not install external shadcn registry files into a project.",
},
conversion: {
status: "unsupported",
reason:
"Webstudio does not convert external React registry files into editable Webstudio components yet.",
},
requirements: {
dependencies: ["class-variance-authority"],
devDependencies: [],
registryDependencies: ["utils"],
},
sourceCode: {
status: "not-analyzed",
reason:
"Inspecting registry metadata does not execute or analyze arbitrary external source files.",
},
manualSteps: [
"Use the item metadata as a reference to recreate the UI with Webstudio components and styles.",
],
},
});
expect(readFile).toHaveBeenCalledWith("/tmp/registry.json", "utf8");
});
test("loads a remote item file when the registry index only lists its name", async () => {
const fetch = vi.fn(async (source: string) => ({
ok: true,
status: 200,
text: async () =>
source.endsWith("registry.json")
? JSON.stringify({ items: [{ name: "dialog" }] })
: JSON.stringify({
name: "dialog",
files: [
{
path: "registry/new-york/dialog/dialog.tsx",
type: "registry:ui",
},
],
}),
}));
await expect(
inspectRegistry(
{
source: "https://registry.example.com/r/registry.json",
item: "dialog",
},
{ readFile: vi.fn(), fetch: async (source) => fetch(source) }
)
).resolves.toMatchObject({
source: "https://registry.example.com/r/dialog.json",
name: "dialog",
files: [
{
path: "registry/new-york/dialog/dialog.tsx",
type: "registry:ui",
},
],
compatibility: {
status: "unsupported",
requirements: { registryDependencies: [] },
},
});
expect(fetch).toHaveBeenNthCalledWith(
1,
"https://registry.example.com/r/registry.json"
);
expect(fetch).toHaveBeenNthCalledWith(
2,
"https://registry.example.com/r/dialog.json"
);
});
test("preserves multi-file items and transitive registry dependencies", async () => {
await expect(
inspectRegistry(
{ source: "/tmp/accordion.json" },
{
readFile: vi.fn(async () =>
JSON.stringify({
name: "accordion",
title: "Accordion",
type: "registry:ui",
dependencies: ["@radix-ui/react-accordion"],
devDependencies: ["tailwindcss-animate"],
registryDependencies: ["button", "separator"],
files: [
{
path: "registry/new-york/accordion/accordion.tsx",
target: "components/ui/accordion.tsx",
type: "registry:ui",
},
{
path: "registry/new-york/accordion/accordion-demo.tsx",
target: "components/accordion-demo.tsx",
type: "registry:example",
},
],
docs: "https://example.com/accordion",
})
),
fetch: async (source) => fetch(source),
}
)
).resolves.toEqual({
source: "/tmp/accordion.json",
name: "accordion",
title: "Accordion",
description: undefined,
type: "registry:ui",
dependencies: ["@radix-ui/react-accordion"],
devDependencies: ["tailwindcss-animate"],
registryDependencies: ["button", "separator"],
files: [
{
path: "registry/new-york/accordion/accordion.tsx",
target: "components/ui/accordion.tsx",
type: "registry:ui",
},
{
path: "registry/new-york/accordion/accordion-demo.tsx",
target: "components/accordion-demo.tsx",
type: "registry:example",
},
],
docs: "https://example.com/accordion",
compatibility: {
status: "unsupported",
installation: expect.objectContaining({ status: "unsupported" }),
conversion: expect.objectContaining({ status: "unsupported" }),
requirements: {
dependencies: ["@radix-ui/react-accordion"],
devDependencies: ["tailwindcss-animate"],
registryDependencies: ["button", "separator"],
},
sourceCode: expect.objectContaining({ status: "not-analyzed" }),
manualSteps: expect.any(Array),
},
});
});
test("accepts a matching item name for a direct registry item source", async () => {
await expect(
inspectRegistry(
{ source: "/tmp/button.json", item: "button" },
{
readFile: vi.fn(async () => JSON.stringify({ name: "button" })),
fetch: async (source) => fetch(source),
}
)
).resolves.toMatchObject({ source: "/tmp/button.json", name: "button" });
await expect(
inspectRegistry(
{ source: "/tmp/button.json", item: "dialog" },
{
readFile: vi.fn(async () => JSON.stringify({ name: "button" })),
fetch: async (source) => fetch(source),
}
)
).rejects.toThrow("contains item button, not dialog");
});
test("rejects a registry index without an item name", async () => {
await expect(
inspectRegistry(
{ source: "/tmp/registry.json", item: undefined },
{
readFile: vi.fn(async () =>
JSON.stringify({ items: [{ name: "button" }] })
),
fetch,
}
)
).rejects.toThrow("Pass --item <name>");
});
+255
View File
@@ -0,0 +1,255 @@
import { readFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { z } from "zod";
import { printJson } from "../json-output";
import type {
CommonYargsArgv,
StrictYargsOptionsToInterface,
} from "./yargs-types";
const registryFileSchema = z.object({
path: z.string(),
type: z.string().optional(),
target: z.string().optional(),
});
const registryItemSchema = z.object({
name: z.string(),
title: z.string().optional(),
description: z.string().optional(),
type: z.string().optional(),
dependencies: z.array(z.string()).optional(),
devDependencies: z.array(z.string()).optional(),
registryDependencies: z.array(z.string()).optional(),
files: z.array(registryFileSchema).optional(),
docs: z.unknown().optional(),
});
const registrySchema = z.object({
items: z.array(registryItemSchema).optional(),
});
type RegistryItem = z.infer<typeof registryItemSchema>;
type RegistryDependencies = {
readFile: (source: string, encoding: "utf8") => Promise<string>;
fetch: (source: string) => Promise<{
ok: boolean;
status: number;
text: () => Promise<string>;
}>;
};
const defaultDependencies: RegistryDependencies = {
readFile: (source, encoding) => readFile(source, encoding),
fetch: async (source) => fetch(source),
};
const isRemoteSource = (source: string) => /^https?:\/\//i.test(source);
const parseRegistryJson = (source: string, content: string) => {
try {
return JSON.parse(content) as unknown;
} catch {
throw new Error(`Registry source ${source} is not valid JSON.`);
}
};
const readRegistrySource = async ({
source,
dependencies,
}: {
source: string;
dependencies: RegistryDependencies;
}) => {
if (isRemoteSource(source)) {
const response = await dependencies.fetch(source);
if (response.ok === false) {
throw new Error(
`Could not read registry source ${source}: HTTP ${response.status}.`
);
}
return parseRegistryJson(source, await response.text());
}
return parseRegistryJson(source, await dependencies.readFile(source, "utf8"));
};
const getItemSource = ({ source, item }: { source: string; item: string }) => {
if (isRemoteSource(source)) {
return new URL(`${item}.json`, source).toString();
}
const absoluteSource = isAbsolute(source) ? source : resolve(source);
return resolve(dirname(absoluteSource), `${item}.json`);
};
const parseRegistryItem = (source: string, value: unknown) => {
const parsed = registryItemSchema.safeParse(value);
if (parsed.success === false) {
throw new Error(
`Registry item from ${source} is invalid: ${parsed.error.issues[0]?.message ?? "unknown error"}.`
);
}
return parsed.data;
};
const serializeRegistryItem = ({
source,
item,
}: {
source: string;
item: RegistryItem;
}) => ({
source,
name: item.name,
title: item.title,
description: item.description,
type: item.type,
dependencies: item.dependencies ?? [],
devDependencies: item.devDependencies ?? [],
registryDependencies: item.registryDependencies ?? [],
files: (item.files ?? []).map((file) => ({
path: file.path,
target: file.target,
type: file.type,
})),
docs: item.docs,
});
const getCompatibilityReport = (item: RegistryItem) => ({
status: "unsupported" as const,
installation: {
status: "unsupported" as const,
reason:
"Webstudio does not install external shadcn registry files into a project.",
},
conversion: {
status: "unsupported" as const,
reason:
"Webstudio does not convert external React registry files into editable Webstudio components yet.",
},
requirements: {
dependencies: item.dependencies ?? [],
devDependencies: item.devDependencies ?? [],
registryDependencies: item.registryDependencies ?? [],
},
sourceCode: {
status: "not-analyzed" as const,
reason:
"Inspecting registry metadata does not execute or analyze arbitrary external source files.",
},
manualSteps: [
"Use the item metadata as a reference to recreate the UI with Webstudio components and styles.",
],
});
const serializeRegistryInspection = ({
source,
item,
}: {
source: string;
item: RegistryItem;
}) => ({
...serializeRegistryItem({ source, item }),
compatibility: getCompatibilityReport(item),
});
export const inspectRegistry = async (
options: { source: string; item?: string },
dependencies: RegistryDependencies = defaultDependencies
) => {
const sourceValue = await readRegistrySource({
source: options.source,
dependencies,
});
const directItem = registryItemSchema.safeParse(sourceValue);
const registry = registrySchema.safeParse(sourceValue);
if (
directItem.success &&
(registry.success === false || registry.data.items === undefined)
) {
if (options.item !== undefined && options.item !== directItem.data.name) {
throw new Error(
`Registry source ${options.source} contains item ${directItem.data.name}, not ${options.item}.`
);
}
return serializeRegistryInspection({
source: options.source,
item: directItem.data,
});
}
if (registry.success === false || registry.data.items === undefined) {
throw new Error(
`Registry source ${options.source} is not an item. Pass --item <name> with a registry index.`
);
}
if (options.item === undefined) {
throw new Error(
`Registry source ${options.source} contains multiple items. Pass --item <name>.`
);
}
const indexedItem = registry.data.items.find(
(item) => item.name === options.item
);
if (indexedItem === undefined) {
throw new Error(
`Registry item ${options.item} was not found in ${options.source}.`
);
}
if (indexedItem.files !== undefined) {
return serializeRegistryInspection({
source: options.source,
item: indexedItem,
});
}
const itemSource = getItemSource({
source: options.source,
item: options.item,
});
return serializeRegistryInspection({
source: itemSource,
item: parseRegistryItem(
itemSource,
await readRegistrySource({ source: itemSource, dependencies })
),
});
};
export const registryInspectOptions = (yargs: CommonYargsArgv) =>
yargs
.option("source", {
type: "string",
describe:
"Required local registry JSON path or remote registry/item URL. No files are installed; output includes a read-only compatibility report.",
demandOption: true,
})
.option("item", {
type: "string",
describe:
"Registry item name when --source is a registry index, for example button",
})
.option("json", {
type: "boolean",
describe: "Print machine-readable JSON output",
default: false,
})
.example(
"$0 registry inspect --source https://example.com/r/registry.json --item button --json",
"Inspect a remote shadcn registry item without installing it"
);
type RegistryOptions = StrictYargsOptionsToInterface<
typeof registryInspectOptions
>;
export const registryInspect = async (options: RegistryOptions) => {
const data = await inspectRegistry({
source: options.source,
item: options.item,
});
if (options.json) {
printJson({ ok: true, data, meta: { command: "registry inspect" } });
return;
}
console.info(JSON.stringify(data, undefined, 2));
};
+15
View File
@@ -54,6 +54,16 @@ test("prints api command schema as json", () => {
name: "publish deploy",
operation: "publish",
method: "mutation",
inputSchema: expect.objectContaining({
type: "object",
properties: expect.objectContaining({
target: expect.objectContaining({
enum: expect.arrayContaining(["staging", "production"]),
}),
domains: expect.objectContaining({ type: "array" }),
message: expect.objectContaining({ type: "string" }),
}),
}),
}),
expect.objectContaining({
name: "domains list",
@@ -62,6 +72,11 @@ test("prints api command schema as json", () => {
}),
])
);
for (const command of output.commands) {
expect(command.inputSchema).toEqual(
expect.objectContaining({ type: "object" })
);
}
expect(output.commands).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "apply-patch" }),
+8 -1
View File
@@ -73,6 +73,10 @@ const cliCommands = cliCommandMetadata.map((command) => ({
method: command.method,
permit: command.permit,
requiredOptions: command.requiredOptions ?? ["json"],
inputSchema: command.inputSchema,
...(command.outputSchema === undefined
? {}
: { outputSchema: command.outputSchema }),
examples: (command.examples ?? []).map(formatApiUseCaseCommand),
}));
@@ -100,6 +104,9 @@ const mcpToolSchema = listProjectSessionMcpTools(publicApiOperations, {
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
...(tool.outputSchema === undefined
? {}
: { outputSchema: tool.outputSchema }),
examples: tool.mcpExamples ?? [],
annotations: tool.annotations,
}));
@@ -258,7 +265,7 @@ const apiSchema = {
serverOnly:
"Server-only commands run remotely and invalidate/refetch namespaces declared by the public operation catalog.",
resultMetadata:
"Successful command JSON includes meta.session with operationId, buildId, version, source, committed, compatibility, namespace metadata, and diagnostics.",
"Successful command JSON includes compact meta.session with operationId, buildId, version, source, committed, namespaceCounts, diagnosticCount, non-empty diagnostic summaries, and optional compatibilityVersion.",
},
useCases: topLevelUseCases,
patch: {
@@ -85,6 +85,130 @@ test("prints json result with a browser executable path", async () => {
});
});
test("captures a project route through a temporary production preview", async () => {
const folder = await mkdtemp(join(tmpdir(), "webstudio-screenshot-test-"));
vi.spyOn(console, "info").mockImplementation(() => undefined);
const captureScreenshotWithBrowserInstall = vi.fn(async () => ({
output: join(folder, "pricing.png"),
browser: {
path: "/browser",
source: "option" as const,
browser: "chrome" as const,
},
viewport: { width: 800, height: 600 },
fullPage: false,
elapsedMs: 12,
warnings: [] as string[],
}));
const preview = {
startAndWait: vi.fn(async () => ({
url: "http://127.0.0.1:5180/",
running: true,
})),
stop: vi.fn(async () => ({
url: "http://127.0.0.1:5180/",
running: false,
})),
resolveUrl: vi.fn(() => "http://127.0.0.1:5180/pricing"),
};
const preparePreviewProject = vi.fn(async () => ({ cwd: "/tmp/preview" }));
const createPreviewController = vi.fn(() => preview);
try {
await screenshot(
{
path: "/pricing",
width: 800,
height: 600,
json: true,
},
{
captureScreenshotWithBrowserInstall,
preparePreviewProject,
createPreviewController,
}
);
} finally {
await rm(folder, { recursive: true, force: true });
}
expect(preparePreviewProject).toHaveBeenCalledWith({
assets: true,
template: ["defaults", "react-router"],
generate: true,
});
expect(createPreviewController).toHaveBeenCalledWith({
host: "127.0.0.1",
port: 5173,
});
expect(preview.startAndWait).toHaveBeenCalledWith({
cwd: "/tmp/preview",
host: "127.0.0.1",
port: 5173,
restart: true,
});
expect(preview.resolveUrl).toHaveBeenCalledWith("/pricing");
expect(captureScreenshotWithBrowserInstall).toHaveBeenCalledWith(
expect.objectContaining({ url: "http://127.0.0.1:5180/pricing" })
);
expect(preview.stop).toHaveBeenCalledOnce();
});
test("stops the temporary preview when project route capture fails", async () => {
const preview = {
startAndWait: vi.fn(async () => ({
url: "http://127.0.0.1:5173/",
running: true,
})),
stop: vi.fn(async () => ({
url: "http://127.0.0.1:5173/",
running: false,
})),
resolveUrl: vi.fn(() => "http://127.0.0.1:5173/"),
};
await expect(
screenshot(
{ path: "/", width: 800, height: 600 },
{
captureScreenshotWithBrowserInstall: vi.fn(async () => {
throw new Error("capture failed");
}),
preparePreviewProject: vi.fn(async () => ({ cwd: "/tmp/preview" })),
createPreviewController: vi.fn(() => preview),
}
)
).rejects.toThrow("capture failed");
expect(preview.stop).toHaveBeenCalledOnce();
});
test("stops the temporary preview when startup fails", async () => {
const preview = {
startAndWait: vi.fn(async () => {
throw new Error("preview failed");
}),
stop: vi.fn(async () => ({
url: "http://127.0.0.1:5173/",
running: false,
})),
resolveUrl: vi.fn(() => "http://127.0.0.1:5173/"),
};
await expect(
screenshot(
{ path: "/", width: 800, height: 600 },
{
captureScreenshotWithBrowserInstall: vi.fn(),
preparePreviewProject: vi.fn(async () => ({ cwd: "/tmp/preview" })),
createPreviewController: vi.fn(() => preview),
}
)
).rejects.toThrow("preview failed");
expect(preview.stop).toHaveBeenCalledOnce();
});
test("rejects invalid screenshot readiness options", async () => {
const captureScreenshotWithBrowserInstall = vi.fn();
+81 -5
View File
@@ -19,6 +19,12 @@ import {
chromiumDownloadUrl,
getNoBrowserFoundMessage,
} from "../screenshot";
import {
preparePreviewProject,
previewDefaultTemplate,
validatePreviewServerOptions,
} from "./preview";
import { createPreviewController } from "../preview-server";
import type {
CommonYargsArgv,
StrictYargsOptionsToInterface,
@@ -28,7 +34,22 @@ export const screenshotOptions = (yargs: CommonYargsArgv) =>
yargs
.positional("url", {
type: "string",
describe: "URL to capture",
describe: "Absolute URL to capture",
})
.option("path", {
type: "string",
describe:
"Project route to capture by generating a local production preview, for example /pricing",
})
.option("host", {
type: "string",
default: "127.0.0.1",
describe: "Host used by the temporary project preview server",
})
.option("port", {
type: "number",
default: 5173,
describe: "Port used by the temporary project preview server",
})
.option("output", {
type: "string",
@@ -96,6 +117,7 @@ type ScreenshotOptions = StrictYargsOptionsToInterface<
typeof screenshotOptions
> & {
url?: string;
path?: string;
browser?: ScreenshotBrowser;
waitUntil?: ScreenshotWaitUntil;
waitForSelector?: string;
@@ -137,10 +159,19 @@ const printScreenshotError = ({
type ScreenshotCommandDependencies = {
captureScreenshotWithBrowserInstall: typeof captureScreenshotWithBrowserInstall;
preparePreviewProject?: typeof preparePreviewProject;
createPreviewController?: (
defaults: Parameters<typeof createPreviewController>[0]
) => Pick<
ReturnType<typeof createPreviewController>,
"startAndWait" | "stop" | "resolveUrl"
>;
};
const defaultScreenshotCommandDependencies: ScreenshotCommandDependencies = {
captureScreenshotWithBrowserInstall,
preparePreviewProject,
createPreviewController,
};
export const screenshot = async (
@@ -148,8 +179,20 @@ export const screenshot = async (
dependencies = defaultScreenshotCommandDependencies
) => {
const options = rawOptions as ScreenshotOptions;
if (options.url === undefined || options.url.length === 0) {
throw new Error("Please specify a URL to capture.");
if (
(options.url === undefined || options.url.length === 0) &&
(options.path === undefined || options.path.length === 0)
) {
throw new Error("Please specify a URL or project --path to capture.");
}
if (options.url !== undefined && options.path !== undefined) {
throw new Error("Specify either a URL or --path, not both.");
}
if (
options.path !== undefined &&
(options.path.startsWith("/") === false || options.path.startsWith("//"))
) {
throw new Error("--path must start with one slash, for example /pricing.");
}
if (isPositiveInteger(options.width) === false) {
throw new Error("--width must be a positive integer.");
@@ -183,8 +226,9 @@ export const screenshot = async (
}
try {
const capture = async (url: string) => {
const result = await dependencies.captureScreenshotWithBrowserInstall({
url: options.url,
url,
output: options.output,
width: options.width,
height: options.height,
@@ -193,7 +237,8 @@ export const screenshot = async (
browserPath: options.browserPath,
waitUntil: options.waitUntil ?? defaultScreenshotWaitUntil,
waitForSelector: options.waitForSelector,
waitForTimeout: options.waitForTimeout ?? defaultScreenshotWaitForTimeout,
waitForTimeout:
options.waitForTimeout ?? defaultScreenshotWaitForTimeout,
timeout: options.timeout ?? defaultScreenshotTimeout,
isJson: options.json,
isMcp: false,
@@ -229,6 +274,37 @@ export const screenshot = async (
return;
}
log.success(`Screenshot saved to ${result.output}`);
};
if (options.path === undefined) {
await capture(options.url!);
return;
}
const host = options.host ?? "127.0.0.1";
const port = options.port ?? 5173;
validatePreviewServerOptions({ host, port });
const previewProject = await (
dependencies.preparePreviewProject ?? preparePreviewProject
)({
assets: true,
template: [...previewDefaultTemplate],
generate: true,
});
const preview = (
dependencies.createPreviewController ?? createPreviewController
)({ host, port });
try {
await preview.startAndWait({
cwd: previewProject.cwd,
host,
port,
restart: true,
});
await capture(preview.resolveUrl(options.path));
} finally {
await preview.stop();
}
} catch (error) {
if (error instanceof BrowserNotFoundError) {
printScreenshotError({
+60 -1
View File
@@ -11,6 +11,7 @@ import { createFileIfNotExists, isFileExists } from "../fs-utils";
import { resolveApiConnection } from "../api-connection";
import { sync } from "./sync";
import { apiCompatibilityHeaders } from "./api";
import { materializeManagedAgents } from "../managed-agents";
const originalCwd = process.cwd();
let tempDir: string;
@@ -32,6 +33,7 @@ const dependencies = {
resolveApiConnection,
spinner: () => indicator,
writeFile,
materializeManagedAgents,
};
type ProjectBundleFixtureOptions = Parameters<
@@ -80,7 +82,64 @@ test("writes current data version after synchronizing from the API", async () =>
bundleVersion,
});
expect(indicator.stop).toHaveBeenCalledWith(
"Project bundle synchronized successfully"
"Project bundle synchronized successfully (AGENTS.md: unchanged)"
);
});
test("materializes project agent instructions during sync", async () => {
loadProjectBundleByBuildId.mockResolvedValue(
createProjectBundle({
build: {
projectSettings: {
meta: { agentInstructions: "Use existing design tokens." },
compiler: {},
},
},
})
);
await sync(
{
authToken: "token-1",
buildId: "build-1",
origin: "https://example.com",
},
dependencies
);
expect(await readFile("AGENTS.md", "utf8")).toContain(
"Use existing design tokens."
);
expect(indicator.stop).toHaveBeenCalledWith(
"Project bundle synchronized successfully (AGENTS.md: created)"
);
});
test("reports a user-owned AGENTS.md conflict without overwriting it", async () => {
await writeFile("AGENTS.md", "# User-owned\n", "utf8");
loadProjectBundleByBuildId.mockResolvedValue(
createProjectBundle({
build: {
projectSettings: {
meta: { agentInstructions: "Use existing design tokens." },
compiler: {},
},
},
})
);
await sync(
{
authToken: "token-1",
buildId: "build-1",
origin: "https://example.com",
},
dependencies
);
expect(await readFile("AGENTS.md", "utf8")).toBe("# User-owned\n");
expect(indicator.stop).toHaveBeenCalledWith(
expect.stringContaining("AGENTS.md blocked by user-owned file")
);
});
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff. Show more