penpot/frontend/playwright/ui/specs/tokens/stroke-per-side.spec.js
Luis de Dios 8f1100d0f5
✨ Enable stroke-per-side flag (#11942)
* ✨ Enable stroke-per-side flag

* 🐛 Fix stroke per side tests for the enabled flag

The default flags now include :enable-stroke-per-side, so
frontend tests and Playwright specs that assumed the flag
was off need to turn it off explicitly.

Update the token context menu test expectations: rects and
boards now expose the stroke-width submenu, and force the
flag off in the "per-side is disabled" cases.

Pass disable-stroke-per-side in the two Playwright specs
that check the flag-off behavior, since app.config always
merges the default flags.

AI-assisted-by: deepseek-v4.1-flash
2026-09-28 14:39:18 +02:00

609 lines
20 KiB
JavaScript

import { test, expect } from "@playwright/test";
import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage";
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
import { WorkspacePage } from "../../pages/WorkspacePage";
import { setupStrokePerSideFile, unfoldTokenType } from "./helpers";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
});
// Accessible names of the design tab controls, from the translations.
const TOP = "Top (block start)";
const RIGHT = "Right (inline end)";
const BOTTOM = "Bottom (block end)";
const LEFT = "Left (inline start)";
const GLOBAL = "Stroke width";
const PER_SIDE_TOGGLE_LABEL = "Stroke per side";
const ONLY_FIRST =
"Design tokens can only be applied to the first fill or stroke in the list.";
const MIXED = "Mixed";
function strokeSection(page) {
return page.getByTestId("right-sidebar").getByRole("region", {
name: "Stroke section",
});
}
function strokeRow(page, index = 0) {
return strokeSection(page).getByLabel(`stroke-row-${index}`);
}
function perSideOptions(page) {
return page.getByTestId("stroke.per-side-options");
}
async function enablePerSide(page) {
await page.getByTestId("stroke.per-side-toggle").click();
await expect(perSideOptions(page)).toBeVisible();
}
async function prepareShape(workspace, page) {
await workspace.layers.getByTestId("layer-row").nth(0).click();
await page.getByTestId("add-stroke").click();
await expect(strokeSection(page)).toBeVisible();
}
async function openTokensTab(page) {
await page.getByRole("tab", { name: "Tokens" }).click();
}
async function unfoldStrokeWidth(page, workspace) {
await openTokensTab(page);
await unfoldTokenType(workspace.tokensSidebar, "stroke width");
}
test.describe("Tokens: stroke per side", () => {
test("per-side toggle is hidden when the feature flag is off", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.mockConfigFlags([
"enable-feature-token-input",
"disable-stroke-per-side",
]);
await workspace.setupEmptyFile();
await workspace.mockGetFile("workspace/get-file-layout-stroke-token-json");
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
await prepareShape(workspace, page);
await expect(page.getByTestId("stroke.per-side-toggle")).toHaveCount(0);
await expect(perSideOptions(page)).toHaveCount(0);
});
test("per-side toggle is enabled for a board with the feature on", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
const toggle = page.getByTestId("stroke.per-side-toggle");
await expect(toggle).toBeVisible();
await expect(toggle).toBeEnabled();
await expect(toggle).toHaveAccessibleName(PER_SIDE_TOGGLE_LABEL);
await expect(perSideOptions(page)).toHaveCount(0);
});
test("clicking the toggle expands and collapses the side inputs", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
const toggle = page.getByTestId("stroke.per-side-toggle");
await toggle.click();
await expect(perSideOptions(page)).toBeVisible();
await expect(
perSideOptions(page).getByRole("textbox", { name: TOP }),
).toBeVisible();
await expect(
perSideOptions(page).getByRole("textbox", { name: RIGHT }),
).toBeVisible();
await expect(
perSideOptions(page).getByRole("textbox", { name: BOTTOM }),
).toBeVisible();
await expect(
perSideOptions(page).getByRole("textbox", { name: LEFT }),
).toBeVisible();
await toggle.click();
await expect(perSideOptions(page)).toHaveCount(0);
});
test("the per-side toggle is independent for each stroke", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
// The add button stays in the section header, so a second stroke is
// one more click.
await page.getByTestId("add-stroke").click();
const firstRow = strokeRow(page, 0);
const secondRow = strokeRow(page, 1);
await expect(firstRow).toBeVisible();
await expect(secondRow).toBeVisible();
await firstRow.getByTestId("stroke.per-side-toggle").click();
await expect(firstRow.getByTestId("stroke.per-side-options")).toBeVisible();
await expect(secondRow.getByTestId("stroke.per-side-options")).toHaveCount(
0,
);
});
test("the per-side state is not persisted across a reload", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
await page.getByTestId("stroke.per-side-toggle").click();
await expect(perSideOptions(page)).toBeVisible();
await page.reload();
await workspace.waitForFirstRender();
await prepareShape(workspace, page);
await expect(perSideOptions(page)).toHaveCount(0);
});
test("the per-side state survives switching shapes", async ({ page }) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
await enablePerSide(page);
// Select another shape; it has no stroke, so nothing is expanded.
await workspace.clickLeafLayer("Board");
await expect(perSideOptions(page)).toHaveCount(0);
// Come back to the rectangle: its per-side state is still expanded.
await workspace.clickLeafLayer("Rectangle", {}, 0);
await expect(perSideOptions(page)).toBeVisible();
});
test("applying a stroke width token from the panel covers every side", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
await enablePerSide(page);
await unfoldStrokeWidth(page, workspace);
await workspace.tokensSidebar
.getByRole("button", { name: "width-big" })
.click();
const row = strokeRow(page);
await expect(
row
.getByLabel(GLOBAL, { exact: true })
.getByRole("button", { name: "width-big" }),
).toBeVisible();
await expect(
perSideOptions(page).getByRole("button", { name: "width-big" }),
).toHaveCount(4);
await expect(
perSideOptions(page).getByRole("textbox", { name: TOP }),
).toHaveCount(0);
});
test("applying the global action from the token menu covers every side", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
await enablePerSide(page);
await unfoldStrokeWidth(page, workspace);
await workspace.tokensSidebar
.getByRole("button", { name: "width-big" })
.click({ button: "right" });
await workspace.tokenContextMenuForToken.getByText("All").first().click();
await expect(
perSideOptions(page).getByRole("button", { name: "width-big" }),
).toHaveCount(4);
});
test("a dimensions token targets every side through the menu", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
await enablePerSide(page);
await openTokensTab(page);
await unfoldTokenType(workspace.tokensSidebar, "dimensions");
await workspace.tokensSidebar
.getByRole("button", { name: "dim.md" })
.click({ button: "right" });
const menu = workspace.tokenContextMenuForToken;
const strokeWidthEntry = menu
.getByRole("listitem")
.filter({ hasText: "Stroke Width" });
await strokeWidthEntry.hover();
await strokeWidthEntry
.getByRole("listitem")
.filter({ hasText: "All" })
.click();
await expect(
perSideOptions(page).getByRole("button", { name: "dim.md" }),
).toHaveCount(4);
});
test("applying a token to one side leaves the other sides untouched", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
await enablePerSide(page);
await unfoldStrokeWidth(page, workspace);
await workspace.tokensSidebar
.getByRole("button", { name: "width-big" })
.click({ button: "right" });
const menu = workspace.tokenContextMenuForToken;
await expect(menu.getByText("Top")).toBeVisible();
await menu.getByText("Top").click();
await expect(
perSideOptions(page).getByRole("button", { name: "width-big" }),
).toHaveCount(1);
await expect(
perSideOptions(page).getByRole("textbox", { name: TOP }),
).toHaveCount(0);
await expect(
perSideOptions(page).getByRole("textbox", { name: RIGHT }),
).toHaveCount(1);
await expect(
perSideOptions(page).getByRole("textbox", { name: BOTTOM }),
).toHaveCount(1);
await expect(
perSideOptions(page).getByRole("textbox", { name: LEFT }),
).toHaveCount(1);
});
test("editing one side does not change the other sides", async ({ page }) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
await enablePerSide(page);
const options = perSideOptions(page);
const rightInput = options.getByRole("textbox", { name: RIGHT });
const bottomInput = options.getByRole("textbox", { name: BOTTOM });
const leftInput = options.getByRole("textbox", { name: LEFT });
const rightBefore = await rightInput.inputValue();
const bottomBefore = await bottomInput.inputValue();
const leftBefore = await leftInput.inputValue();
const topInput = options.getByRole("textbox", { name: TOP });
await topInput.fill("8");
await topInput.press("Enter");
await expect(topInput).toHaveValue("8");
await expect(rightInput).toHaveValue(rightBefore);
await expect(bottomInput).toHaveValue(bottomBefore);
await expect(leftInput).toHaveValue(leftBefore);
// The uniform field cannot show a single width anymore.
await expect(
strokeRow(page).getByRole("textbox", { name: GLOBAL }),
).toHaveAttribute("placeholder", MIXED);
});
test("detaching a side token leaves the other sides applied", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
await enablePerSide(page);
await unfoldStrokeWidth(page, workspace);
await workspace.tokensSidebar
.getByRole("button", { name: "width-big" })
.click();
await expect(
perSideOptions(page).getByRole("button", { name: "width-big" }),
).toHaveCount(4);
await perSideOptions(page)
.getByRole("button", { name: "Detach token" })
.first()
.click();
await expect(
perSideOptions(page).getByRole("textbox", { name: TOP }),
).toHaveCount(1);
await expect(
perSideOptions(page).getByRole("button", { name: "width-big" }),
).toHaveCount(3);
});
test("applying a token to one side creates the stroke when missing", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await workspace.layers.getByTestId("layer-row").nth(0).click();
await unfoldStrokeWidth(page, workspace);
await workspace.tokensSidebar
.getByRole("button", { name: "width-big" })
.click({ button: "right" });
await workspace.tokenContextMenuForToken.getByText("Top").click();
await expect(strokeSection(page)).toBeVisible();
await enablePerSide(page);
await expect(
perSideOptions(page).getByRole("button", { name: "width-big" }),
).toHaveCount(1);
await expect(
perSideOptions(page).getByRole("textbox", { name: RIGHT }),
).toHaveValue("0");
});
test("a token on one side overrides a global token on that side only", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
await enablePerSide(page);
await unfoldStrokeWidth(page, workspace);
await workspace.tokensSidebar
.getByRole("button", { name: "width-big" })
.click();
await workspace.tokensSidebar
.getByRole("button", { name: "width-small" })
.click({ button: "right" });
await workspace.tokenContextMenuForToken.getByText("Right").click();
const options = perSideOptions(page);
await expect(
options.getByRole("button", { name: "width-big" }),
).toHaveCount(3);
await expect(
options.getByRole("button", { name: "width-small" }),
).toHaveCount(1);
});
test("selecting the token again on one side completes it on every side", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
await enablePerSide(page);
await unfoldStrokeWidth(page, workspace);
await workspace.tokensSidebar
.getByRole("button", { name: "width-big" })
.click({ button: "right" });
await workspace.tokenContextMenuForToken.getByText("Top").click();
await expect(
perSideOptions(page).getByRole("button", { name: "width-big" }),
).toHaveCount(1);
// The uniform field is mixed; picking the same token there covers
// every side instead of removing the partial application. The uniform
// field is the first token control in the row.
const row = strokeRow(page);
await row.getByRole("button", { name: "Open token list" }).first().click();
await row.getByRole("option", { name: "width-big" }).click();
const options = perSideOptions(page);
await expect(
options.getByRole("button", { name: "width-big" }),
).toHaveCount(4);
await expect(options.getByRole("textbox", { name: TOP })).toHaveCount(0);
});
test("clicking the token again removes it from every side", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
await enablePerSide(page);
await unfoldStrokeWidth(page, workspace);
const chip = workspace.tokensSidebar.getByRole("button", {
name: "width-big",
});
await chip.click();
await expect(
perSideOptions(page).getByRole("button", { name: "width-big" }),
).toHaveCount(4);
await chip.click();
const options = perSideOptions(page);
await expect(
options.getByRole("button", { name: "width-big" }),
).toHaveCount(0);
await expect(options.getByRole("textbox", { name: TOP })).toHaveCount(1);
await expect(options.getByRole("textbox", { name: RIGHT })).toHaveCount(1);
});
test("the token menu only shows per-side entries for eligible shapes", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
await unfoldStrokeWidth(page, workspace);
await workspace.tokensSidebar
.getByRole("button", { name: "width-big" })
.click({ button: "right" });
const menu = workspace.tokenContextMenuForToken;
await expect(menu.getByText("All")).toBeVisible();
await expect(menu.getByText("Top")).toBeVisible();
await expect(menu.getByText("Right")).toBeVisible();
await expect(menu.getByText("Bottom")).toBeVisible();
await expect(menu.getByText("Left")).toBeVisible();
});
test("the token menu hides per-side entries when the flag is off", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.mockConfigFlags([
"enable-feature-token-input",
"disable-stroke-per-side",
]);
await workspace.setupEmptyFile();
await workspace.mockGetFile("workspace/get-file-layout-stroke-token-json");
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
await prepareShape(workspace, page);
await unfoldStrokeWidth(page, workspace);
await workspace.tokensSidebar
.getByRole("button", { name: "width-big" })
.click({ button: "right" });
const menu = workspace.tokenContextMenuForToken;
await expect(menu.getByText("Stroke Width").first()).toBeVisible();
await expect(menu.getByText("Top")).toHaveCount(0);
await expect(menu.getByText("Right")).toHaveCount(0);
});
test("other shape types do not offer per-side controls", async ({ page }) => {
const workspace = await setupStrokePerSideFile(page);
await workspace.selectToolFromFlyout(workspace, {
triggerToolName: "Rectangle (R)",
targetToolName: "Ellipse (E)",
});
await workspace.clickWithDragViewportAt(520, 100, 100, 100);
await page.getByTestId("add-stroke").click();
await expect(strokeSection(page)).toBeVisible();
await expect(page.getByTestId("stroke.per-side-toggle")).toHaveCount(0);
});
test("token controls are disabled on the second stroke", async ({ page }) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
// The add button stays in the section header, so a second stroke is
// one more click.
await page.getByTestId("add-stroke").click();
const firstRow = strokeRow(page, 0);
const secondRow = strokeRow(page, 1);
await expect(firstRow).toBeVisible();
await expect(secondRow).toBeVisible();
await expect(
firstRow.getByRole("button", { name: "Open token list" }),
).toBeEnabled();
const disabledTokenButton = secondRow.getByRole("button", {
name: ONLY_FIRST,
});
await expect(disabledTokenButton).toBeVisible();
await expect(disabledTokenButton).toBeDisabled();
});
test("the colorpicker disables tokens on the second stroke", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
await page.getByTestId("add-stroke").click();
await strokeRow(page, 1)
.getByRole("button", { name: "#000000" })
.first()
.click();
const colorpicker = page.getByTestId("colorpicker");
await expect(colorpicker).toBeVisible();
await expect(
colorpicker.getByRole("button", { name: ONLY_FIRST }),
).toBeDisabled();
});
test("per-side is offered for a multi-selection of rectangles", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await workspace.clickToggableLayer("Board");
await workspace.clickLeafLayer("Rectangle", {}, 0);
await workspace.clickLeafLayer("Rectangle", { modifiers: ["Shift"] }, 1);
await page.getByTestId("add-stroke").click();
await expect(page.getByTestId("stroke.per-side-toggle")).toBeVisible();
});
});
test.describe("Tokens: stroke per side (inspect)", () => {
test("Inspect styles expose the four per-side border widths", async ({
page,
}) => {
const workspace = await setupStrokePerSideFile(page);
await prepareShape(workspace, page);
await enablePerSide(page);
const options = perSideOptions(page);
for (const [name, value] of [
[TOP, "2"],
[RIGHT, "8"],
[BOTTOM, "4"],
[LEFT, "6"],
]) {
const input = options.getByRole("textbox", { name });
await input.fill(value);
await input.press("Enter");
}
await page.getByRole("tab", { name: "Inspect" }).click();
const panel = page
.getByTestId("right-sidebar")
.getByRole("article")
.filter({ hasText: "Stroke" });
const expectRow = async (term, value) => {
const row = panel.getByTestId("property-row").filter({ hasText: term });
await expect(row).toContainText(value);
};
await expectRow("Border block start width", "2px");
await expectRow("Border inline end width", "8px");
await expectRow("Border block end width", "4px");
await expectRow("Border inline start width", "6px");
});
});
test.describe("Tokens: stroke per side (non wasm)", () => {
test("per-side toggle is disabled without the new renderer", async ({
page,
}) => {
const workspace = new WorkspacePage(page);
await workspace.mockConfigFlags([
"enable-stroke-per-side",
"enable-feature-token-input",
]);
await workspace.setupEmptyFile();
await workspace.mockGetFile("workspace/get-file-layout-stroke-token-json");
await workspace.goToWorkspace();
await prepareShape(workspace, page);
const toggle = page.getByTestId("stroke.per-side-toggle");
await expect(toggle).toBeVisible();
await expect(toggle).toBeDisabled();
await expect(toggle).toHaveAccessibleName(
/only available in the new render/,
);
});
});