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/, ); }); });