Files
webstudio/apps/builder/e2e/tests/pages-actions-bridge.[shard-2].[shard-5].[shard-6].e2e.ts
T
Oleg Isonen dabaeede02 fix: Hide deprecated components in Builder (#6576)
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
2026-10-21 21:06:41 +02:00

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}`
);
}
});