penpot/frontend/playwright/ui/specs/tokens/stroke-per-side.spec.js
Eva Marco dacfac17bb
🐛 Show the top stroke width in the old render (#12093)
* 🐛 Show the top stroke width in the old render

With the old render, the stroke width input showed "Mixed" when the four
sides had different widths or tokens. The old render only draws the top
side, and the input must show it as the global stroke width.

Move the input value and token logic into two pure functions. When per
side strokes are disabled, both return the top side: its width and its
token, or no token when the top has none.

Closes #12063

AI-assisted-by: claude-opus-5-5

* 🐛 Add e2e tests for the stroke width input in both renders

Cover the global stroke width input when the four sides differ. With the
new render it shows Mixed. With the old render it shows the top side:
its width, or its token when the top has one.

The old render cannot create per side data from the UI, and switching
the render reloads the page and drops the test state. The tests patch
the existing stroke token fixture instead, appending the per side fields
to a rectangle and a board.

Relates to #12063

AI-assisted-by: claude-opus-5-5
2026-10-06 13:33:36 +02:00

731 lines
24 KiB
JavaScript

import { readFile } from "node:fs/promises";
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");
}
const STROKE_TOKEN_FILE = "workspace/get-file-layout-stroke-token-json";
const PER_SIDE_RECT = "Per side rect";
const PER_SIDE_TOKEN_BOARD = "Per side token board";
// Transit fields appended at the end of a shape, so the earlier "^N" cache
// references stay valid; when a key repeats, the last value wins.
function perSideStrokeFields(name, top, appliedTokens = []) {
const stroke = [
"^ ",
...["~:stroke-style", "~:solid", "~:stroke-alignment", "~:inner"],
...["~:stroke-color", "#000000", "~:stroke-opacity", 1],
...["~:stroke-width", top, "~:stroke-width-top", top],
...["~:stroke-width-right", 4, "~:stroke-width-bottom", 8],
...["~:stroke-width-left", 16],
];
return [
"~:name",
name,
"~:strokes",
[stroke],
"~:applied-tokens",
["^ ", ...appliedTokens],
];
}
// Serves the stroke token file with a rectangle and a board whose sides
// have different widths (top 2 or 20, right 4, bottom 8, left 16). Only the
// board has a token, `width-big`, on its top side. Both are top-level
// layers, so the layers panel shows them without unfolding anything.
async function mockPerSideStrokeFile(workspace) {
await workspace.mockGetFile(STROKE_TOKEN_FILE);
const payload = JSON.parse(
await readFile(`playwright/data/${STROKE_TOKEN_FILE}`, "utf-8"),
);
const [pageData] = Object.values(payload["~:data"]["~:pages-index"]);
const objects = pageData["~:objects"]["~#penpot/objects-map/v2"];
const patch = (id, fields) => {
const shape = objects[id];
const extra = JSON.stringify(fields).slice(1, -1);
objects[id] = `${shape.slice(0, -2)},${extra}]]`;
};
patch(
"~u8506e3f3-e05b-807c-8007-6ceac380abc1",
perSideStrokeFields(PER_SIDE_RECT, 2),
);
patch(
"~u6c65a5dc-fb40-8072-8007-6ce644905054",
perSideStrokeFields(PER_SIDE_TOKEN_BOARD, 20, [
"~:stroke-width-top",
"width-big",
]),
);
// The last matching route wins over the one set by mockGetFile.
await workspace.page.route(/get\-file\?/, (route) =>
route.fulfill({
status: 200,
contentType: "application/transit+json",
body: JSON.stringify(payload),
}),
);
}
function globalWidthInput(page) {
return strokeRow(page).getByRole("textbox", { name: GLOBAL });
}
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, global width input", () => {
test("shows Mixed for different sides with the new renderer", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.mockConfigFlags([
"enable-stroke-per-side",
"enable-feature-token-input",
]);
await workspace.setupEmptyFile();
await mockPerSideStrokeFile(workspace);
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
await workspace.clickLeafLayer(PER_SIDE_RECT);
await expect(globalWidthInput(page)).toHaveAttribute("placeholder", MIXED);
await workspace.clickLeafLayer(PER_SIDE_TOKEN_BOARD);
await expect(globalWidthInput(page)).toHaveAttribute("placeholder", MIXED);
});
});
test.describe("Tokens: stroke per side (non wasm)", () => {
test("the global width input shows the top side 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 mockPerSideStrokeFile(workspace);
await workspace.goToWorkspace();
// Top 2, other sides 4, 8 and 16: the old render draws the top side,
// so the input shows it instead of Mixed.
await workspace.clickLeafLayer(PER_SIDE_RECT);
await expect(globalWidthInput(page)).toHaveValue("2");
await expect(globalWidthInput(page)).not.toHaveAttribute(
"placeholder",
MIXED,
);
// Only the top side has a token: the input shows that token.
await workspace.clickLeafLayer(PER_SIDE_TOKEN_BOARD);
await expect(
strokeRow(page).getByRole("button", { name: "width-big" }),
).toHaveCount(1);
await expect(globalWidthInput(page)).toHaveCount(0);
});
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/,
);
});
});