mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
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:
1 parent
68b7458720
commit
071a4d9a85
236 files changed
+25458
-1768
No files matched your search
Vendored
+6
@@ -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,30 +104,51 @@ 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 error = validateHtmlEmbedCode(value);
|
||||
setError(error);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, value);
|
||||
} else {
|
||||
onChange({ type: "string", value });
|
||||
}
|
||||
});
|
||||
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);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, storedValue);
|
||||
} else {
|
||||
onChange({ type: "string", value: storedValue });
|
||||
}
|
||||
},
|
||||
{ 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,
|
||||
meta: { siteName: "Acme" },
|
||||
compiler: { atomicStyles: true },
|
||||
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,22 +81,35 @@ const createBuildImportUpdate = ({
|
||||
lastTransactionId: string;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
}) => ({
|
||||
version,
|
||||
lastTransactionId,
|
||||
updatedAt,
|
||||
pages: JSON.stringify(data.build.pages),
|
||||
breakpoints: serializeData<Breakpoint>(toMap(data.build.breakpoints)),
|
||||
styles: serializeStyles(toMap(data.build.styles)),
|
||||
styleSources: serializeData<StyleSource>(toMap(data.build.styleSources)),
|
||||
styleSourceSelections: serializeStyleSourceSelections(
|
||||
toMap(data.build.styleSourceSelections)
|
||||
),
|
||||
props: serializeData<Prop>(toMap(data.build.props)),
|
||||
dataSources: serializeData<DataSource>(toMap(data.build.dataSources)),
|
||||
resources: serializeData<Resource>(toMap(data.build.resources)),
|
||||
instances: serializeData<Instance>(toMap(data.build.instances)),
|
||||
});
|
||||
}) => {
|
||||
const pages = migratePages(data.build.pages);
|
||||
const projectSettings =
|
||||
data.build.projectSettings ?? createProjectSettingsFromPages(pages);
|
||||
return {
|
||||
version,
|
||||
lastTransactionId,
|
||||
updatedAt,
|
||||
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)),
|
||||
styleSourceSelections: serializeStyleSourceSelections(
|
||||
toMap(data.build.styleSourceSelections)
|
||||
),
|
||||
props: serializeData<Prop>(toMap(data.build.props)),
|
||||
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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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())),
|
||||
};
|
||||
};
|
||||
@@ -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,20 +157,177 @@ export const expectGeneratedAppBuild = async ({
|
||||
}
|
||||
);
|
||||
|
||||
const generatedHome = await readFile(
|
||||
join(tempDir, "app", "__generated__", "_index.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
if (generatedHome.includes(expectedText) === false) {
|
||||
throw new Error(
|
||||
`Expected generated home route to include "${expectedText}".`
|
||||
if (expectedText !== undefined) {
|
||||
const generatedHome = await readFile(
|
||||
join(tempDir, "app", "__generated__", "_index.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
if (generatedHome.includes(expectedText) === false) {
|
||||
throw new Error(
|
||||
`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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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" \
|
||||
|
||||
+75
-62
@@ -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 suite.beforeEach?.();
|
||||
await test.run();
|
||||
}
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
@@ -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({
|
||||
projectId: fixture.projectId,
|
||||
expectedRedirects: [
|
||||
{
|
||||
old: redirect.from,
|
||||
new: redirect.to,
|
||||
status: "301",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
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,
|
||||
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"`,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,16 +2956,23 @@ export const apiCommand = async (
|
||||
const session = isProjectSessionEnvelope(response)
|
||||
? getProjectSessionMeta(response)
|
||||
: undefined;
|
||||
printJson({
|
||||
ok: true,
|
||||
data: isProjectSessionEnvelope(response) ? response.result : response,
|
||||
meta: {
|
||||
command: options.command,
|
||||
projectId: connection.projectId,
|
||||
durationMs: Date.now() - start,
|
||||
...(session === undefined ? {} : { session }),
|
||||
},
|
||||
});
|
||||
const result = isProjectSessionEnvelope(response)
|
||||
? response.result
|
||||
: response;
|
||||
if (options.command === "audit" && options.json !== true) {
|
||||
printAuditReport(result);
|
||||
} else {
|
||||
printJson({
|
||||
ok: true,
|
||||
data: result,
|
||||
meta: {
|
||||
command: options.command,
|
||||
projectId: connection.projectId,
|
||||
durationMs: Date.now() - start,
|
||||
...(session === undefined ? {} : { session }),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getCliErrorMessage(error);
|
||||
if (options.json === true) {
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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>");
|
||||
});
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,52 +226,85 @@ export const screenshot = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dependencies.captureScreenshotWithBrowserInstall({
|
||||
url: options.url,
|
||||
output: options.output,
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
fullPage: options.fullPage,
|
||||
browser: options.browser ?? "auto",
|
||||
browserPath: options.browserPath,
|
||||
waitUntil: options.waitUntil ?? defaultScreenshotWaitUntil,
|
||||
waitForSelector: options.waitForSelector,
|
||||
waitForTimeout: options.waitForTimeout ?? defaultScreenshotWaitForTimeout,
|
||||
timeout: options.timeout ?? defaultScreenshotTimeout,
|
||||
isJson: options.json,
|
||||
isMcp: false,
|
||||
isInteractive:
|
||||
process.stdin.isTTY === true && process.stdout.isTTY === true,
|
||||
async confirmInstall(installCommand) {
|
||||
const answer = await confirm({
|
||||
message: `No supported browser was found. Install Chromium now with "${installCommand.label}"?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (isCancel(answer)) {
|
||||
cancel("Screenshot capture is cancelled.");
|
||||
throw new HandledCliError();
|
||||
}
|
||||
return answer;
|
||||
},
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
printJson({
|
||||
ok: true,
|
||||
data: {
|
||||
output: result.output,
|
||||
browserPath: result.browser.path,
|
||||
browser: result.browser.browser,
|
||||
viewport: result.viewport,
|
||||
fullPage: result.fullPage,
|
||||
elapsedMs: result.elapsedMs,
|
||||
warnings: result.warnings,
|
||||
const capture = async (url: string) => {
|
||||
const result = await dependencies.captureScreenshotWithBrowserInstall({
|
||||
url,
|
||||
output: options.output,
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
fullPage: options.fullPage,
|
||||
browser: options.browser ?? "auto",
|
||||
browserPath: options.browserPath,
|
||||
waitUntil: options.waitUntil ?? defaultScreenshotWaitUntil,
|
||||
waitForSelector: options.waitForSelector,
|
||||
waitForTimeout:
|
||||
options.waitForTimeout ?? defaultScreenshotWaitForTimeout,
|
||||
timeout: options.timeout ?? defaultScreenshotTimeout,
|
||||
isJson: options.json,
|
||||
isMcp: false,
|
||||
isInteractive:
|
||||
process.stdin.isTTY === true && process.stdout.isTTY === true,
|
||||
async confirmInstall(installCommand) {
|
||||
const answer = await confirm({
|
||||
message: `No supported browser was found. Install Chromium now with "${installCommand.label}"?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (isCancel(answer)) {
|
||||
cancel("Screenshot capture is cancelled.");
|
||||
throw new HandledCliError();
|
||||
}
|
||||
return answer;
|
||||
},
|
||||
meta: { command: "screenshot" },
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
printJson({
|
||||
ok: true,
|
||||
data: {
|
||||
output: result.output,
|
||||
browserPath: result.browser.path,
|
||||
browser: result.browser.browser,
|
||||
viewport: result.viewport,
|
||||
fullPage: result.fullPage,
|
||||
elapsedMs: result.elapsedMs,
|
||||
warnings: result.warnings,
|
||||
},
|
||||
meta: { command: "screenshot" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
log.success(`Screenshot saved to ${result.output}`);
|
||||
};
|
||||
|
||||
if (options.path === undefined) {
|
||||
await capture(options.url!);
|
||||
return;
|
||||
}
|
||||
log.success(`Screenshot saved to ${result.output}`);
|
||||
|
||||
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({
|
||||
|
||||
@@ -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
Reference in new issue
Block a user