mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
The Components panel now hides deprecated components by default. The default-off `deprecatedComponents` feature flag shows all nine registered deprecated components in a separate **Deprecated** section, including Webhook Form and RemixForm. Webhook Form keeps its saved component ID, icon, behavior, navigator label, and `w-webhook-form` CSS class. The command picker continues to hide deprecated components. ## Checklist - [ ] Document each feature flag beside its declaration using its current behavior. - [x] Keep Webhook Form metadata and picker guidance pointing to Form. - [x] Preserve the generated `w-webhook-form` class without custom `presetClassName` metadata. - [x] Hide deprecated components from the Components panel by default; show all nine eligible components in a dedicated Deprecated section behind the default-off `deprecatedComponents` flag. - [x] Keep deprecated components out of the command picker. - [x] Add focused tests for default and flagged visibility, section membership, exclusions, and template insertion data. - [x] Review documentation impact. Existing Webhook Form docs describe its default unavailability; the flag is for development, so no docs change is needed. - [x] Verify focused tests, typechecks, lint, and fixture generation. The fixture pipeline passed; its unrelated live fixture path drift was restored. - [x] Update the Components panel E2E filter assertion for the flagged deprecated section and verify the rerun. - [x] Verify required CI on the latest head. Focused tests: component catalog 16/16, Builder 3/3. Builder, project-build, and feature-flags typechecks passed. Lint and the full fixture pipeline passed. The new section tests failed as expected with section creation temporarily disabled and passed after restoration. Latest-head CI: Main workflow, Visual regression, Lint PR, and Check submodules all passed, including all six Builder E2E shards. Ref #4093
540 lines
16 KiB
TypeScript
540 lines
16 KiB
TypeScript
import type { Page } from "@playwright/test";
|
|
import {
|
|
openProjectBuilder,
|
|
waitForCanvasText,
|
|
waitForCanvasTextHidden,
|
|
} from "../flows/builder";
|
|
import { selectCanvasTextInstance } from "../flows/canvas-selection";
|
|
import { openNavigatorPanel } from "../flows/navigator";
|
|
import {
|
|
waitForChangeToBeSaved,
|
|
waitForSyncStatus,
|
|
} from "../flows/sync-status";
|
|
import { insertTemplateAfterCanvasText } from "../flows/template-insertion";
|
|
import { createContentModeProject } from "../fixtures/content-mode-suite";
|
|
import type { SeededContentModeProject } from "../fixtures/content-mode-project";
|
|
import { test, withBrowserContext } from "../test";
|
|
import { measure } from "../perf";
|
|
import { loadDevBuild } from "../db";
|
|
|
|
let fixture: SeededContentModeProject;
|
|
|
|
const getInstanceComponentCount = async ({
|
|
fixture,
|
|
component,
|
|
}: {
|
|
fixture: SeededContentModeProject;
|
|
component: string;
|
|
}) => {
|
|
const build = await loadDevBuild({ projectId: fixture.projectId });
|
|
const instances = JSON.parse(build.instances) as Array<{
|
|
component: string;
|
|
}>;
|
|
return instances.filter((instance) => instance.component === component)
|
|
.length;
|
|
};
|
|
|
|
const getInstanceComponentCountSummary = async (
|
|
fixture: SeededContentModeProject
|
|
) => {
|
|
const build = await loadDevBuild({ projectId: fixture.projectId });
|
|
const instances = JSON.parse(build.instances) as Array<{
|
|
component: string;
|
|
}>;
|
|
const counts = new Map<string, number>();
|
|
for (const { component } of instances) {
|
|
counts.set(component, (counts.get(component) ?? 0) + 1);
|
|
}
|
|
return Array.from(counts, ([component, count]) => `${component}:${count}`)
|
|
.sort()
|
|
.join(", ");
|
|
};
|
|
|
|
const selectAppMenuItem = async ({
|
|
page,
|
|
name,
|
|
}: {
|
|
page: Page;
|
|
name: string;
|
|
}) => {
|
|
await page.bringToFront();
|
|
await page.getByRole("button", { name: "Menu", exact: true }).click();
|
|
const menuItem = page.getByRole("menuitem", { name });
|
|
await menuItem.waitFor();
|
|
await menuItem.click();
|
|
};
|
|
|
|
const undoShortcut = async ({
|
|
page,
|
|
waitForSave = true,
|
|
}: {
|
|
page: Page;
|
|
waitForSave?: boolean;
|
|
}) => {
|
|
await waitForSyncStatus({ page, status: "idle" });
|
|
const save =
|
|
waitForSave === true
|
|
? waitForChangeToBeSaved({ page, timeout: 30_000 })
|
|
: undefined;
|
|
await selectAppMenuItem({ page, name: "Undo" });
|
|
await save;
|
|
await waitForSyncStatus({ page, status: "idle" });
|
|
};
|
|
|
|
const redoShortcut = async ({ page }: { page: Page }) => {
|
|
await waitForSyncStatus({ page, status: "idle" });
|
|
const save = waitForChangeToBeSaved({ page, timeout: 30_000 });
|
|
await selectAppMenuItem({ page, name: "Redo" });
|
|
await save;
|
|
await waitForSyncStatus({ page, status: "idle" });
|
|
};
|
|
|
|
const openComponentsPanel = async ({ page }: { page: Page }) => {
|
|
await page.getByRole("tab", { name: "Components" }).click();
|
|
await page.getByPlaceholder("Find components").waitFor();
|
|
};
|
|
|
|
const searchComponentsPanel = async ({
|
|
page,
|
|
text,
|
|
}: {
|
|
page: Page;
|
|
text: string;
|
|
}) => {
|
|
const search = page.getByPlaceholder("Find components");
|
|
await search.fill(text);
|
|
};
|
|
|
|
const expectComponentPanelOptionHidden = async ({
|
|
page,
|
|
name,
|
|
}: {
|
|
page: Page;
|
|
name: string;
|
|
}) => {
|
|
await searchComponentsPanel({ page, text: name });
|
|
await page
|
|
.getByRole("option", { name, exact: true })
|
|
.waitFor({ state: "hidden" });
|
|
};
|
|
|
|
const insertComponentPanelOption = async ({
|
|
page,
|
|
name,
|
|
}: {
|
|
page: Page;
|
|
name: string;
|
|
}) => {
|
|
await searchComponentsPanel({ page, text: name });
|
|
await page.getByRole("option", { name, exact: true }).click();
|
|
await waitForSyncStatus({ page, status: "idle" });
|
|
};
|
|
|
|
const getNavigatorButtonSummary = async ({
|
|
page,
|
|
text,
|
|
}: {
|
|
page: Page;
|
|
text: string;
|
|
}) => {
|
|
return await page
|
|
.locator("[data-navigator-tree] [data-tree-button]")
|
|
.evaluateAll((buttons, text) => {
|
|
return buttons
|
|
.map((button, index) => ({
|
|
index,
|
|
selected: button.getAttribute("aria-selected"),
|
|
text: button.textContent?.replace(/\s+/g, " ").trim() ?? "",
|
|
}))
|
|
.filter((button) =>
|
|
button.text.toLowerCase().includes((text as string).toLowerCase())
|
|
);
|
|
}, text);
|
|
};
|
|
|
|
const selectNavigatorContextAction = async ({
|
|
page,
|
|
itemName,
|
|
action,
|
|
}: {
|
|
page: Page;
|
|
itemName: string;
|
|
action: "Duplicate" | "Delete";
|
|
}) => {
|
|
const selectedMatchingItem = page
|
|
.locator('[data-navigator-tree] [data-tree-button][aria-selected="true"]')
|
|
.filter({ hasText: itemName })
|
|
.first();
|
|
const fallbackItem = page
|
|
.locator("[data-navigator-tree] [data-tree-button]")
|
|
.filter({ hasText: itemName })
|
|
.first();
|
|
const item =
|
|
(await selectedMatchingItem.count()) > 0
|
|
? selectedMatchingItem
|
|
: fallbackItem;
|
|
await item.waitFor({ state: "visible" });
|
|
// The Navigator can intentionally skip the first click after a prior
|
|
// pointer interaction. A second ordinary click guarantees the context menu
|
|
// action runs against this row's fresh single selection.
|
|
await item.click();
|
|
await item.click();
|
|
await item.click({ button: "right" });
|
|
const menuItem = page.getByRole("menuitem", { name: action });
|
|
await menuItem.waitFor({ state: "visible" });
|
|
const ariaDisabled = await menuItem.getAttribute("aria-disabled");
|
|
const dataDisabled = await menuItem.getAttribute("data-disabled");
|
|
if (ariaDisabled === "true" || dataDisabled !== null) {
|
|
throw new Error(
|
|
`Expected Navigator ${action} action to be enabled. aria-disabled=${ariaDisabled}, data-disabled=${dataDisabled}`
|
|
);
|
|
}
|
|
await menuItem.click();
|
|
await waitForChangeToBeSaved({ page });
|
|
};
|
|
|
|
test.beforeAll(async ({ browser }) => {
|
|
fixture = await withBrowserContext(browser, (context) =>
|
|
createContentModeProject({
|
|
context,
|
|
email: "pages-actions-e2e@webstudio.test",
|
|
title: "Pages Actions E2E",
|
|
assetNamePrefix: "pages-actions-",
|
|
editorToken: "pages-actions-e2e-editor-token",
|
|
builderToken: "pages-actions-e2e-builder-token",
|
|
})
|
|
);
|
|
});
|
|
|
|
test("Builder can insert through the engine bridge, undo, redo, and reload", async ({
|
|
page,
|
|
}) => {
|
|
await measure("pages actions open builder for engine bridge", async () => {
|
|
await openProjectBuilder({
|
|
page,
|
|
projectId: fixture.projectId,
|
|
authToken: fixture.editorToken,
|
|
mode: "content",
|
|
});
|
|
});
|
|
|
|
await waitForCanvasText({ page, text: "Initial content" });
|
|
|
|
await measure("pages actions insert template through bridge", async () => {
|
|
await insertTemplateAfterCanvasText({
|
|
page,
|
|
anchorText: "Initial content",
|
|
templateName: fixture.styledHeadingTemplateName,
|
|
});
|
|
});
|
|
await waitForCanvasText({
|
|
page,
|
|
text: fixture.styledHeadingTemplateText,
|
|
});
|
|
|
|
await measure("pages actions undo bridge insert", async () => {
|
|
await undoShortcut({ page, waitForSave: false });
|
|
});
|
|
await waitForCanvasTextHidden({
|
|
page,
|
|
text: fixture.styledHeadingTemplateText,
|
|
});
|
|
|
|
await measure("pages actions redo bridge insert", async () => {
|
|
await redoShortcut({ page });
|
|
});
|
|
await waitForCanvasText({
|
|
page,
|
|
text: fixture.styledHeadingTemplateText,
|
|
});
|
|
|
|
await measure("pages actions reload bridge insert", async () => {
|
|
await openProjectBuilder({
|
|
page,
|
|
projectId: fixture.projectId,
|
|
authToken: fixture.editorToken,
|
|
mode: "content",
|
|
});
|
|
});
|
|
await waitForCanvasText({
|
|
page,
|
|
text: fixture.styledHeadingTemplateText,
|
|
});
|
|
});
|
|
|
|
test("Builder Components panel filters catalog-only entries and inserts a persisted component", async ({
|
|
page,
|
|
}) => {
|
|
const insertedButtonText = "Button";
|
|
|
|
await measure("pages actions open builder for components panel", async () => {
|
|
await openProjectBuilder({
|
|
page,
|
|
projectId: fixture.projectId,
|
|
authToken: fixture.builderToken,
|
|
});
|
|
});
|
|
await waitForCanvasText({ page, text: "Initial content" });
|
|
|
|
await measure(
|
|
"pages actions verify components panel catalog filters",
|
|
async () => {
|
|
await openComponentsPanel({ page });
|
|
await expectComponentPanelOptionHidden({ page, name: "Body" });
|
|
}
|
|
);
|
|
|
|
await selectCanvasTextInstance({ page, text: "Initial content" });
|
|
await measure(
|
|
"pages actions insert component from components panel",
|
|
async () => {
|
|
await openComponentsPanel({ page });
|
|
await insertComponentPanelOption({ page, name: "Button" });
|
|
}
|
|
);
|
|
await waitForCanvasText({ page, text: insertedButtonText });
|
|
|
|
await measure("pages actions reload components panel insert", async () => {
|
|
await openProjectBuilder({
|
|
page,
|
|
projectId: fixture.projectId,
|
|
authToken: fixture.builderToken,
|
|
});
|
|
});
|
|
await waitForCanvasText({ page, text: insertedButtonText });
|
|
});
|
|
|
|
test("Builder can duplicate a component through the engine bridge, undo, redo, and reload", async ({
|
|
page,
|
|
}) => {
|
|
const componentName = "Image";
|
|
|
|
await measure("pages actions open builder for command panel", async () => {
|
|
await openProjectBuilder({
|
|
page,
|
|
projectId: fixture.projectId,
|
|
authToken: fixture.builderToken,
|
|
});
|
|
});
|
|
await waitForCanvasText({ page, text: "Initial content" });
|
|
await selectCanvasTextInstance({ page, text: "Initial content" });
|
|
|
|
const initialCount = await getInstanceComponentCount({
|
|
fixture,
|
|
component: componentName,
|
|
});
|
|
|
|
await measure(
|
|
"pages actions insert component before command duplicate",
|
|
async () => {
|
|
await openComponentsPanel({ page });
|
|
await insertComponentPanelOption({ page, name: componentName });
|
|
}
|
|
);
|
|
const insertedCount = await getInstanceComponentCount({
|
|
fixture,
|
|
component: componentName,
|
|
});
|
|
if (insertedCount !== initialCount + 1) {
|
|
throw new Error(
|
|
`Expected components panel to insert one ${componentName}. Before ${initialCount}, after ${insertedCount}`
|
|
);
|
|
}
|
|
|
|
await measure("pages actions duplicate inserted component", async () => {
|
|
await page.keyboard.press("ControlOrMeta+D");
|
|
await waitForSyncStatus({ page, status: "idle" });
|
|
});
|
|
const duplicatedCount = await getInstanceComponentCount({
|
|
fixture,
|
|
component: componentName,
|
|
});
|
|
if (duplicatedCount !== initialCount + 2) {
|
|
throw new Error(
|
|
`Expected duplicate command to add one ${componentName}. Before ${initialCount}, after ${duplicatedCount}`
|
|
);
|
|
}
|
|
|
|
await measure("pages actions undo duplicate command", async () => {
|
|
await undoShortcut({ page });
|
|
});
|
|
const undoCount = await getInstanceComponentCount({
|
|
fixture,
|
|
component: componentName,
|
|
});
|
|
if (undoCount !== initialCount + 1) {
|
|
throw new Error(
|
|
`Expected undo to remove duplicated ${componentName}. Before ${initialCount}, after ${undoCount}`
|
|
);
|
|
}
|
|
|
|
await measure("pages actions redo duplicate command", async () => {
|
|
await redoShortcut({ page });
|
|
});
|
|
const redoCount = await getInstanceComponentCount({
|
|
fixture,
|
|
component: componentName,
|
|
});
|
|
if (redoCount !== initialCount + 2) {
|
|
throw new Error(
|
|
`Expected redo to restore duplicated ${componentName}. Before ${initialCount}, after ${redoCount}`
|
|
);
|
|
}
|
|
|
|
await measure("pages actions reload duplicate command", async () => {
|
|
await openProjectBuilder({
|
|
page,
|
|
projectId: fixture.projectId,
|
|
authToken: fixture.builderToken,
|
|
});
|
|
});
|
|
const reloadedCount = await getInstanceComponentCount({
|
|
fixture,
|
|
component: componentName,
|
|
});
|
|
if (reloadedCount !== initialCount + 2) {
|
|
throw new Error(
|
|
`Expected reloaded project to keep duplicated ${componentName}. Before ${initialCount}, after ${reloadedCount}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test("Builder can duplicate and delete a component from Navigator context menu", async ({
|
|
page,
|
|
}) => {
|
|
const componentName = "Image";
|
|
|
|
await measure(
|
|
"pages actions open builder for navigator context menu",
|
|
async () => {
|
|
await openProjectBuilder({
|
|
page,
|
|
projectId: fixture.projectId,
|
|
authToken: fixture.builderToken,
|
|
});
|
|
}
|
|
);
|
|
await waitForCanvasText({ page, text: "Initial content" });
|
|
await selectCanvasTextInstance({ page, text: "Initial content" });
|
|
|
|
const initialCount = await getInstanceComponentCount({
|
|
fixture,
|
|
component: componentName,
|
|
});
|
|
|
|
await measure(
|
|
"pages actions insert component before navigator actions",
|
|
async () => {
|
|
await openComponentsPanel({ page });
|
|
await insertComponentPanelOption({ page, name: componentName });
|
|
}
|
|
);
|
|
const insertedCount = await getInstanceComponentCount({
|
|
fixture,
|
|
component: componentName,
|
|
});
|
|
if (insertedCount !== initialCount + 1) {
|
|
throw new Error(
|
|
`Expected components panel to insert one ${componentName}. Before ${initialCount}, after ${insertedCount}. Components: ${await getInstanceComponentCountSummary(fixture)}`
|
|
);
|
|
}
|
|
|
|
await measure(
|
|
"pages actions duplicate component from navigator",
|
|
async () => {
|
|
await openNavigatorPanel({ page });
|
|
await selectNavigatorContextAction({
|
|
page,
|
|
itemName: componentName,
|
|
action: "Duplicate",
|
|
});
|
|
}
|
|
);
|
|
const duplicatedCount = await getInstanceComponentCount({
|
|
fixture,
|
|
component: componentName,
|
|
});
|
|
if (duplicatedCount !== initialCount + 2) {
|
|
throw new Error(
|
|
`Expected Navigator duplicate to add one ${componentName}. Before ${initialCount}, after ${duplicatedCount}. Components: ${await getInstanceComponentCountSummary(fixture)}. Navigator: ${JSON.stringify(await getNavigatorButtonSummary({ page, text: componentName }))}`
|
|
);
|
|
}
|
|
|
|
await measure("pages actions undo navigator duplicate", async () => {
|
|
await undoShortcut({ page });
|
|
});
|
|
const undoDuplicateCount = await getInstanceComponentCount({
|
|
fixture,
|
|
component: componentName,
|
|
});
|
|
if (undoDuplicateCount !== initialCount + 1) {
|
|
throw new Error(
|
|
`Expected undo to remove Navigator duplicate ${componentName}. Before ${initialCount}, after ${undoDuplicateCount}`
|
|
);
|
|
}
|
|
|
|
await measure("pages actions redo navigator duplicate", async () => {
|
|
await redoShortcut({ page });
|
|
});
|
|
const redoDuplicateCount = await getInstanceComponentCount({
|
|
fixture,
|
|
component: componentName,
|
|
});
|
|
if (redoDuplicateCount !== initialCount + 2) {
|
|
throw new Error(
|
|
`Expected redo to restore Navigator duplicate ${componentName}. Before ${initialCount}, after ${redoDuplicateCount}`
|
|
);
|
|
}
|
|
|
|
await measure("pages actions delete component from navigator", async () => {
|
|
await openNavigatorPanel({ page });
|
|
await selectNavigatorContextAction({
|
|
page,
|
|
itemName: componentName,
|
|
action: "Delete",
|
|
});
|
|
});
|
|
const deletedCount = await getInstanceComponentCount({
|
|
fixture,
|
|
component: componentName,
|
|
});
|
|
if (deletedCount !== initialCount + 1) {
|
|
throw new Error(
|
|
`Expected Navigator delete to remove one ${componentName}. Before ${initialCount}, after ${deletedCount}`
|
|
);
|
|
}
|
|
|
|
await measure("pages actions undo navigator delete", async () => {
|
|
await undoShortcut({ page });
|
|
});
|
|
const undoDeleteCount = await getInstanceComponentCount({
|
|
fixture,
|
|
component: componentName,
|
|
});
|
|
if (undoDeleteCount !== initialCount + 2) {
|
|
throw new Error(
|
|
`Expected undo to restore Navigator-deleted ${componentName}. Before ${initialCount}, after ${undoDeleteCount}`
|
|
);
|
|
}
|
|
|
|
await measure(
|
|
"pages actions reload navigator context menu mutations",
|
|
async () => {
|
|
await openProjectBuilder({
|
|
page,
|
|
projectId: fixture.projectId,
|
|
authToken: fixture.builderToken,
|
|
});
|
|
}
|
|
);
|
|
const reloadedCount = await getInstanceComponentCount({
|
|
fixture,
|
|
component: componentName,
|
|
});
|
|
if (reloadedCount !== initialCount + 2) {
|
|
throw new Error(
|
|
`Expected reload to preserve Navigator mutations for ${componentName}. Before ${initialCount}, after ${reloadedCount}`
|
|
);
|
|
}
|
|
});
|