test(web): add responsive text hierarchy E2E (#3237)
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import {
|
||||
assertLocatorWithinViewportX,
|
||||
assertNoDescendantOverflowsRight,
|
||||
assertNoDocumentHorizontalOverflow,
|
||||
} from "../../helpers/layout-assertions";
|
||||
import { waitForFiniteAnimations } from "../../helpers/animations";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
const LONG_TASK_TITLE = `Desktop cleanup confirmation ${"X".repeat(31)}`;
|
||||
|
||||
async function assertDesktopDeleteDialog(dialog: Locator): Promise<void> {
|
||||
await waitForFiniteAnimations(dialog);
|
||||
|
||||
const title = dialog.locator('[data-slot="alert-dialog-title"]');
|
||||
const description = dialog.locator('[data-slot="alert-dialog-description"]');
|
||||
await expect(title).toBeVisible();
|
||||
await expect(description).toBeVisible();
|
||||
await expect(title).toHaveCSS("text-wrap", "balance");
|
||||
await expect(description).toHaveCSS("text-wrap", "pretty");
|
||||
await expect(description).toHaveCSS("text-align", "left");
|
||||
|
||||
const labelledBy = await dialog.getAttribute("aria-labelledby");
|
||||
const describedBy = await dialog.getAttribute("aria-describedby");
|
||||
expect(labelledBy).toBe(await title.getAttribute("id"));
|
||||
expect(describedBy).toBe(await description.getAttribute("id"));
|
||||
expect(await description.locator("p").count()).toBeGreaterThan(0);
|
||||
expect(await description.locator("ul, ol").count()).toBeGreaterThan(0);
|
||||
|
||||
await assertLocatorWithinViewportX(dialog, "desktop delete dialog");
|
||||
await assertNoDescendantOverflowsRight(dialog, "desktop delete dialog");
|
||||
await assertNoDocumentHorizontalOverflow(dialog.page(), "desktop delete dialog");
|
||||
|
||||
const footer = dialog.locator('[data-slot="alert-dialog-footer"]');
|
||||
const cancel = dialog.locator('[data-slot="alert-dialog-cancel"]');
|
||||
const deleteAction = dialog.locator('[data-slot="alert-dialog-action"]');
|
||||
const [footerBox, cancelBox, deleteBox] = await Promise.all([
|
||||
footer.boundingBox(),
|
||||
cancel.boundingBox(),
|
||||
deleteAction.boundingBox(),
|
||||
]);
|
||||
if (!footerBox || !cancelBox || !deleteBox) {
|
||||
throw new Error("desktop delete dialog footer has no rendered action boxes");
|
||||
}
|
||||
expect(Math.round(cancelBox.height)).toBeLessThan(44);
|
||||
expect(Math.round(deleteBox.height)).toBeLessThan(44);
|
||||
expect(cancelBox.y).toBeCloseTo(deleteBox.y, 1);
|
||||
expect(deleteBox.x + deleteBox.width).toBeCloseTo(footerBox.x + footerBox.width, 1);
|
||||
await expect(deleteAction).toHaveAttribute("data-variant", "destructive");
|
||||
await expect(deleteAction).not.toHaveClass(/bg-primary/);
|
||||
}
|
||||
|
||||
test.describe("Desktop confirmation text hierarchy", () => {
|
||||
// @covers AC-UI-SURFACE-TEXT-HIERARCHY-001.1, AC-UI-SURFACE-TEXT-HIERARCHY-001.2
|
||||
// @covers AC-UI-SURFACE-TEXT-HIERARCHY-001.3, AC-UI-SURFACE-TEXT-HIERARCHY-001.5
|
||||
// @covers AC-UI-TASK-CLEANUP-CONFIRMATION-001.5, AC-UI-TASK-CLEANUP-CONFIRMATION-001.6
|
||||
// @covers AC-UI-TASK-CLEANUP-CONFIRMATION-001.7
|
||||
test("keeps the fine-pointer archive popover wide and the sidebar row stable", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await testPage.setViewportSize({ width: 900, height: 700 });
|
||||
const title = "Desktop archive popover geometry target";
|
||||
const task = await apiClient.seedTask(seedData.workspaceId, title, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${task.task_id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
const taskRow = session.sidebarTaskItem(title);
|
||||
await expect(taskRow).toBeVisible();
|
||||
const heightBefore = await taskRow.evaluate(
|
||||
(element) => element.getBoundingClientRect().height,
|
||||
);
|
||||
|
||||
await session.openSidebarMenuAndClick(title, "Archive");
|
||||
const popover = testPage.getByTestId("task-archive-confirm-popover");
|
||||
await expect(popover).toBeVisible();
|
||||
await waitForFiniteAnimations(popover);
|
||||
|
||||
const popoverDescription = popover.locator('[data-slot="popover-description"]');
|
||||
await expect(popoverDescription).toBeVisible();
|
||||
await expect(popoverDescription).toHaveCSS("text-wrap", "pretty");
|
||||
const [heightOpen, popoverBox, viewport] = await Promise.all([
|
||||
taskRow.evaluate((element) => element.getBoundingClientRect().height),
|
||||
popover.boundingBox(),
|
||||
testPage.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })),
|
||||
]);
|
||||
if (!popoverBox) throw new Error("desktop archive popover has no rendered layout box");
|
||||
|
||||
expect(heightOpen).toBeCloseTo(heightBefore, 3);
|
||||
expect(popoverBox.width).toBeGreaterThan(256);
|
||||
expect(popoverBox.x).toBeGreaterThanOrEqual(0);
|
||||
expect(popoverBox.x + popoverBox.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(popoverBox.y).toBeGreaterThanOrEqual(0);
|
||||
expect(popoverBox.y + popoverBox.height).toBeLessThanOrEqual(viewport.height);
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "desktop archive popover");
|
||||
|
||||
await popover.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(popover).toBeHidden();
|
||||
const heightAfter = await taskRow.evaluate((element) => element.getBoundingClientRect().height);
|
||||
expect(heightAfter).toBeCloseTo(heightBefore, 3);
|
||||
});
|
||||
|
||||
// @covers AC-UI-SURFACE-TEXT-HIERARCHY-001.1, AC-UI-SURFACE-TEXT-HIERARCHY-001.2
|
||||
// @covers AC-UI-SURFACE-TEXT-HIERARCHY-001.3, AC-UI-SURFACE-TEXT-HIERARCHY-001.5
|
||||
// @covers AC-UI-TASK-CLEANUP-CONFIRMATION-001.5, AC-UI-TASK-CLEANUP-CONFIRMATION-001.6
|
||||
// @covers AC-UI-TASK-CLEANUP-CONFIRMATION-001.7
|
||||
test("preserves Dialog and Delete behavior with compact desktop actions", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.seedTask(seedData.workspaceId, LONG_TASK_TITLE, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const survivor = await apiClient.seedTask(seedData.workspaceId, "Desktop delete survivor", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${task.task_id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(session.taskInSidebar(LONG_TASK_TITLE)).toBeVisible();
|
||||
|
||||
await session.openSidebarMenuAndClick(LONG_TASK_TITLE, "Rename");
|
||||
const renameDialog = testPage.locator('[data-slot="dialog-content"]:visible').last();
|
||||
await expect(renameDialog).toBeVisible();
|
||||
const renameTitle = renameDialog.locator('[data-slot="dialog-title"]');
|
||||
await expect(renameTitle).toBeVisible();
|
||||
await expect(renameTitle).toHaveCSS("text-wrap", "balance");
|
||||
const renameLabelledBy = await renameDialog.getAttribute("aria-labelledby");
|
||||
expect(renameLabelledBy).toBe(await renameTitle.getAttribute("id"));
|
||||
await assertLocatorWithinViewportX(renameDialog, "desktop rename Dialog");
|
||||
await renameDialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(renameDialog).toBeHidden();
|
||||
|
||||
await session.openSidebarMenuAndClick(LONG_TASK_TITLE, "Delete");
|
||||
const deleteDialog = testPage.getByRole("alertdialog");
|
||||
await expect(deleteDialog).toBeVisible();
|
||||
await assertDesktopDeleteDialog(deleteDialog);
|
||||
|
||||
await deleteDialog.locator('[data-slot="alert-dialog-cancel"]').click();
|
||||
await expect(deleteDialog).toBeHidden();
|
||||
expect((await apiClient.getTask(task.task_id)).id).toBe(task.task_id);
|
||||
|
||||
await session.openSidebarMenuAndClick(LONG_TASK_TITLE, "Delete");
|
||||
await testPage.getByRole("alertdialog").locator('[data-slot="alert-dialog-action"]').click();
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await apiClient.rawRequest("GET", `/api/v1/tasks/${task.task_id}`)).status,
|
||||
{ timeout: 15_000, message: "the desktop Delete action should remove the task" },
|
||||
)
|
||||
.toBe(404);
|
||||
await expect(session.taskInSidebar(LONG_TASK_TITLE)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(session.taskInSidebar("Desktop delete survivor")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect((await apiClient.getTask(survivor.task_id)).id).toBe(survivor.task_id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import {
|
||||
assertLocatorWithinViewportX,
|
||||
assertNoDescendantOverflowsRight,
|
||||
assertNoDocumentHorizontalOverflow,
|
||||
} from "../../helpers/layout-assertions";
|
||||
import { waitForFiniteAnimations } from "../../helpers/animations";
|
||||
import {
|
||||
setAgentRuntimeAvailability,
|
||||
stubAgentRuntimeRestart,
|
||||
} from "../../helpers/agent-runtime-availability";
|
||||
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
const LONG_TASK_TITLE = `Mobile cleanup confirmation ${"X".repeat(32)}`;
|
||||
|
||||
async function enablePseudoLocale(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
document.cookie = "kandev_locale=pseudo; path=/; max-age=31536000; SameSite=Lax";
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "pseudo", { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function openDeleteDialog(page: Page, title: string) {
|
||||
const session = new SessionPage(page);
|
||||
await session.mobileSessionMenu.tap();
|
||||
|
||||
const drawer = page
|
||||
.getByTestId("mobile-task-switcher-list")
|
||||
.locator("xpath=ancestor::*[@data-slot='drawer-content'][1]");
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
const taskRow = drawer.getByTestId("sidebar-task-item").filter({ hasText: title });
|
||||
await expect(taskRow).toBeVisible();
|
||||
await taskRow.locator("button.mobile-task-actions-button").tap();
|
||||
|
||||
const menu = page.locator('[data-slot="context-menu-content"]:visible').last();
|
||||
await expect(menu).toBeVisible();
|
||||
const deleteItem = menu.locator('[role="menuitem"]:has(svg.tabler-icon-trash)');
|
||||
await expect(deleteItem).toHaveCount(1);
|
||||
await deleteItem.tap();
|
||||
|
||||
const dialog = page.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
return { dialog, drawer, taskRow };
|
||||
}
|
||||
|
||||
async function dialogMetrics(dialog: Locator) {
|
||||
return dialog.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const scrollOwners = Array.from(element.querySelectorAll<HTMLElement>("*"))
|
||||
.filter((candidate) => {
|
||||
const style = getComputedStyle(candidate);
|
||||
return (
|
||||
(style.overflowY === "auto" || style.overflowY === "scroll") &&
|
||||
candidate.scrollHeight > candidate.clientHeight + 1
|
||||
);
|
||||
})
|
||||
.map((candidate) => candidate.dataset.slot ?? candidate.tagName.toLowerCase());
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
rootOverflowY: style.overflowY,
|
||||
scrollOwners,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function assertPhoneDeleteDialog(dialog: Locator, width: number): Promise<void> {
|
||||
await waitForFiniteAnimations(dialog);
|
||||
|
||||
const title = dialog.locator('[data-slot="alert-dialog-title"]');
|
||||
const description = dialog.locator('[data-slot="alert-dialog-description"]');
|
||||
await expect(title).toBeVisible();
|
||||
await expect(description).toBeVisible();
|
||||
|
||||
await expect(title).toHaveCSS("text-wrap", "balance");
|
||||
await expect(description).toHaveCSS("text-wrap", "pretty");
|
||||
await expect(description).toHaveCSS("text-align", "left");
|
||||
await expect(description).toHaveCSS("overflow-wrap", /anywhere|break-word/);
|
||||
|
||||
const labelledBy = await dialog.getAttribute("aria-labelledby");
|
||||
const describedBy = await dialog.getAttribute("aria-describedby");
|
||||
expect(labelledBy).toBe(await title.getAttribute("id"));
|
||||
expect(describedBy).toBe(await description.getAttribute("id"));
|
||||
expect(await description.locator("p").count()).toBeGreaterThan(0);
|
||||
expect(await description.locator("ul, ol").count()).toBeGreaterThan(0);
|
||||
|
||||
await assertLocatorWithinViewportX(dialog, `phone delete dialog at ${width}px`);
|
||||
await assertNoDescendantOverflowsRight(dialog, `phone delete dialog at ${width}px`);
|
||||
await assertNoDocumentHorizontalOverflow(pageFor(dialog), `phone delete dialog at ${width}px`);
|
||||
|
||||
const metrics = await dialogMetrics(dialog);
|
||||
expect(metrics.x).toBeGreaterThanOrEqual(15);
|
||||
expect(metrics.right).toBeLessThanOrEqual(metrics.viewportWidth - 15);
|
||||
expect(metrics.y).toBeGreaterThanOrEqual(0);
|
||||
expect(metrics.bottom).toBeLessThanOrEqual(metrics.viewportHeight);
|
||||
expect(metrics.rootOverflowY).not.toMatch(/auto|scroll/);
|
||||
if (width === 320) {
|
||||
expect(metrics.scrollOwners.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const footer = dialog.locator('[data-slot="alert-dialog-footer"]');
|
||||
const cancel = dialog.locator('[data-slot="alert-dialog-cancel"]');
|
||||
const deleteAction = dialog.locator('[data-slot="alert-dialog-action"]');
|
||||
await expect(cancel).toBeVisible();
|
||||
await expect(deleteAction).toBeVisible();
|
||||
|
||||
const [footerBox, cancelBox, deleteBox] = await Promise.all([
|
||||
footer.boundingBox(),
|
||||
cancel.boundingBox(),
|
||||
deleteAction.boundingBox(),
|
||||
]);
|
||||
if (!footerBox || !cancelBox || !deleteBox) {
|
||||
throw new Error("phone delete dialog footer has no rendered action boxes");
|
||||
}
|
||||
expect(Math.round(cancelBox.height)).toBeGreaterThanOrEqual(44);
|
||||
expect(Math.round(deleteBox.height)).toBeGreaterThanOrEqual(44);
|
||||
expect(cancelBox.width).toBeGreaterThanOrEqual(footerBox.width - 2);
|
||||
expect(deleteBox.width).toBeGreaterThanOrEqual(footerBox.width - 2);
|
||||
await expect(cancel).toBeInViewport();
|
||||
await expect(deleteAction).toBeInViewport();
|
||||
await expect(deleteAction).toHaveAttribute("data-variant", "destructive");
|
||||
await expect(deleteAction).not.toHaveClass(/bg-primary/);
|
||||
}
|
||||
|
||||
function pageFor(locator: Locator): Page {
|
||||
return locator.page();
|
||||
}
|
||||
|
||||
test.describe("Mobile confirmation text hierarchy", () => {
|
||||
// @covers AC-UI-SURFACE-TEXT-HIERARCHY-001.1, AC-UI-SURFACE-TEXT-HIERARCHY-001.2
|
||||
// @covers AC-UI-SURFACE-TEXT-HIERARCHY-001.3, AC-UI-SURFACE-TEXT-HIERARCHY-001.5
|
||||
// @covers AC-UI-TASK-CLEANUP-CONFIRMATION-001.5, AC-UI-TASK-CLEANUP-CONFIRMATION-001.6
|
||||
// @covers AC-UI-TASK-CLEANUP-CONFIRMATION-001.7
|
||||
test("keeps a pseudo-localized delete confirmation contained at 320px and 393px", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await testPage.setViewportSize({ width: 320, height: 400 });
|
||||
const task = await apiClient.seedTask(seedData.workspaceId, LONG_TASK_TITLE, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
await apiClient.seedTask(seedData.workspaceId, "Mobile cleanup confirmation child", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
parent_id: task.task_id,
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${task.task_id}`);
|
||||
await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible();
|
||||
await enablePseudoLocale(testPage);
|
||||
|
||||
for (const width of [320, 393]) {
|
||||
await testPage.setViewportSize({ width, height: 400 });
|
||||
const { dialog, drawer } = await openDeleteDialog(testPage, LONG_TASK_TITLE);
|
||||
const drawerTitle = drawer.locator('[data-slot="drawer-title"]');
|
||||
await expect(drawerTitle).toBeVisible();
|
||||
await expect(drawerTitle).toHaveCSS("text-wrap", "balance");
|
||||
await assertPhoneDeleteDialog(dialog, width);
|
||||
|
||||
await dialog.locator('[data-slot="alert-dialog-cancel"]').tap();
|
||||
await expect(dialog).toBeHidden();
|
||||
expect((await apiClient.getTask(task.task_id)).id).toBe(task.task_id);
|
||||
}
|
||||
|
||||
const { dialog } = await openDeleteDialog(testPage, LONG_TASK_TITLE);
|
||||
await dialog.locator('[data-slot="alert-dialog-action"]').tap();
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await apiClient.rawRequest("GET", `/api/v1/tasks/${task.task_id}`)).status,
|
||||
{ timeout: 15_000, message: "the mobile Delete action should remove the task" },
|
||||
)
|
||||
.toBe(404);
|
||||
});
|
||||
|
||||
// @covers AC-UI-SURFACE-TEXT-HIERARCHY-001.1, AC-UI-SURFACE-TEXT-HIERARCHY-001.3
|
||||
// @covers AC-UI-TASK-CLEANUP-CONFIRMATION-001.4, AC-UI-TASK-CLEANUP-CONFIRMATION-001.5
|
||||
test("keeps the phone Drawer and inline archive confirmation touch-safe", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await testPage.setViewportSize({ width: 393, height: 640 });
|
||||
const title = "Mobile inline archive confirmation target";
|
||||
const task = await apiClient.seedTask(seedData.workspaceId, title, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${task.task_id}`);
|
||||
await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible();
|
||||
const session = new SessionPage(testPage);
|
||||
await session.mobileSessionMenu.tap();
|
||||
|
||||
const drawer = testPage
|
||||
.getByTestId("mobile-task-switcher-list")
|
||||
.locator("xpath=ancestor::*[@data-slot='drawer-content'][1]");
|
||||
await expect(drawer).toBeVisible();
|
||||
const drawerTitle = drawer.locator('[data-slot="drawer-title"]');
|
||||
await expect(drawerTitle).toHaveCSS("text-wrap", "balance");
|
||||
|
||||
const taskRow = drawer.getByTestId("sidebar-task-item").filter({ hasText: title });
|
||||
await expect(taskRow).toBeVisible();
|
||||
await taskRow.locator("button.mobile-task-actions-button").tap();
|
||||
const menu = testPage.locator('[data-slot="context-menu-content"]:visible').last();
|
||||
await menu.getByRole("menuitem", { name: "Archive", exact: true }).tap();
|
||||
|
||||
const confirmation = taskRow.getByTestId("task-archive-inline-confirmation");
|
||||
await expect(confirmation).toBeVisible();
|
||||
await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
|
||||
await expect(confirmation).toContainText(title);
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "phone inline archive confirmation");
|
||||
|
||||
for (const button of [
|
||||
confirmation.getByRole("button", { name: "Cancel", exact: true }),
|
||||
confirmation.getByTestId("archive-task-confirm"),
|
||||
]) {
|
||||
const box = await button.boundingBox();
|
||||
if (!box) throw new Error("phone inline archive action has no rendered hitbox");
|
||||
expect(Math.round(box.width)).toBeGreaterThanOrEqual(44);
|
||||
expect(Math.round(box.height)).toBeGreaterThanOrEqual(44);
|
||||
await expect(button).toBeInViewport();
|
||||
}
|
||||
|
||||
await confirmation.getByRole("button", { name: "Cancel", exact: true }).tap();
|
||||
await expect(confirmation).toBeHidden();
|
||||
expect((await apiClient.getTask(task.task_id)).id).toBe(task.task_id);
|
||||
});
|
||||
|
||||
// @covers AC-UI-SURFACE-TEXT-HIERARCHY-001.1, AC-UI-SURFACE-TEXT-HIERARCHY-001.3
|
||||
// @covers AC-UI-SURFACE-TEXT-HIERARCHY-001.5
|
||||
test("uses balanced Alert titles and pretty inline Alert prose on a phone", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await testPage.setViewportSize({ width: 320, height: 480 });
|
||||
const task = await apiClient.seedTask(seedData.workspaceId, "Mobile runtime alert target", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
await stubAgentRuntimeRestart(testPage);
|
||||
await testPage.goto(`/t/${task.task_id}`);
|
||||
await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible();
|
||||
|
||||
await setAgentRuntimeAvailability(testPage, {
|
||||
status: "unavailable",
|
||||
reason: "agentctl_exited",
|
||||
occurred_at: "2026-08-08T14:22:52Z",
|
||||
});
|
||||
const alert = testPage.getByTestId("agent-runtime-alert");
|
||||
await expect(alert).toBeVisible();
|
||||
const title = alert.locator('[data-slot="alert-title"]');
|
||||
const description = alert.locator('[data-slot="alert-description"]');
|
||||
await expect(title).toHaveCSS("text-wrap", "balance");
|
||||
await expect(description).toHaveCSS("text-wrap", "pretty");
|
||||
await assertNoDescendantOverflowsRight(alert, "phone runtime Alert");
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "phone runtime Alert");
|
||||
|
||||
const action = alert.getByRole("button").first();
|
||||
await expect(action).toBeVisible({ timeout: 15_000 });
|
||||
const actionBox = await action.boundingBox();
|
||||
if (!actionBox) throw new Error("phone runtime Alert action has no rendered hitbox");
|
||||
expect(Math.round(actionBox.height)).toBeGreaterThanOrEqual(44);
|
||||
await expect(action).toBeInViewport();
|
||||
|
||||
await setAgentRuntimeAvailability(testPage, { status: "available" });
|
||||
await expect(alert).toHaveCount(0);
|
||||
});
|
||||
|
||||
// @covers AC-UI-SURFACE-TEXT-HIERARCHY-001.1, AC-UI-SURFACE-TEXT-HIERARCHY-001.3
|
||||
// @covers AC-UI-SURFACE-TEXT-HIERARCHY-001.5
|
||||
test("keeps the mobile board Drawer title and description inside the viewport", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
await testPage.setViewportSize({ width: 393, height: 640 });
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
await mobile.boardNavigator.tap();
|
||||
|
||||
const drawer = testPage.getByTestId("mobile-board-navigator-drawer");
|
||||
await expect(drawer).toBeVisible();
|
||||
const title = drawer.locator('[data-slot="drawer-title"]');
|
||||
const description = drawer.locator('[data-slot="drawer-description"]');
|
||||
await expect(title).toHaveCSS("text-wrap", "balance");
|
||||
await expect(description).toHaveCSS("text-wrap", "pretty");
|
||||
await assertLocatorWithinViewportX(drawer, "mobile board navigator Drawer");
|
||||
await assertNoDescendantOverflowsRight(drawer, "mobile board navigator Drawer");
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile board navigator Drawer");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
created: 2026-08-31
|
||||
status: in-progress
|
||||
status: completed
|
||||
requirements:
|
||||
- REQ-UI-SURFACE-TEXT-HIERARCHY-001
|
||||
- REQ-UI-TASK-CLEANUP-CONFIRMATION-001
|
||||
@@ -107,7 +107,7 @@ the final composition.
|
||||
- [x] [Task 02: Normalize structured app confirmations](task-02-normalize-structured-app-confirmations.md) (completed)
|
||||
- [x] [Task 03: Refine task cleanup confirmations](task-03-refine-task-cleanup-confirmations.md) (completed)
|
||||
- [x] [Task 04: Normalize structured task confirmations](task-04-normalize-structured-task-confirmations.md) (completed)
|
||||
- [ ] [Task 05: Add responsive text hierarchy E2E](task-05-add-responsive-text-hierarchy-e2e.md)
|
||||
- [x] [Task 05: Add responsive text hierarchy E2E](task-05-add-responsive-text-hierarchy-e2e.md) (completed)
|
||||
|
||||
## Risks
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: "05-add-responsive-text-hierarchy-e2e"
|
||||
title: "Add responsive text hierarchy E2E"
|
||||
status: pending
|
||||
status: completed
|
||||
wave: 3
|
||||
depends_on:
|
||||
- "02-normalize-structured-app-confirmations"
|
||||
@@ -111,4 +111,28 @@ pnpm exec eslint e2e/tests/task/mobile-confirmation-text-hierarchy.spec.ts e2e/t
|
||||
|
||||
## Results
|
||||
|
||||
Pending implementation.
|
||||
- RED baseline on the pre-fix base: the mobile suite had 3 of 4 tests fail on
|
||||
missing balanced Drawer and Alert titles, and the desktop suite had 2 of 2
|
||||
tests fail on missing pretty archive prose and balanced Dialog titles.
|
||||
- Integrated Task 04 RED: the mobile suite passed 4 tests, while the desktop
|
||||
suite had 1 failure because the archive PopoverDescription computed
|
||||
`text-wrap: wrap` instead of `pretty`. Task 04 added the narrow `wide`
|
||||
popover contract fix before final verification.
|
||||
- Added six rendered tests across phone and desktop flows. They cover real
|
||||
task deletion, archive, Alert, board Drawer, Dialog, and Delete surfaces
|
||||
with long content, pseudo-localized copy, computed wrapping, structured
|
||||
alignment, viewport and document containment, scroll ownership, action
|
||||
geometry, semantic Delete styling, Cancel, and task survival.
|
||||
- GREEN: `pnpm e2e:run --project mobile-chrome
|
||||
tests/task/mobile-confirmation-text-hierarchy.spec.ts` (4 tests).
|
||||
- GREEN: `pnpm e2e:run --project chromium
|
||||
tests/task/confirmation-text-hierarchy.spec.ts` (2 tests).
|
||||
- GREEN: `pnpm e2e:run --project chromium
|
||||
tests/task/dialog-long-text-overflow.spec.ts
|
||||
tests/task/sidebar-delete-confirm.spec.ts` (2 tests).
|
||||
- GREEN: `pnpm run e2e:sleep-ratchet` and focused ESLint for both new specs.
|
||||
- Review remediation synchronized the completed parent plan and removed
|
||||
redundant direct computed-style reads in favor of Playwright's retrying CSS
|
||||
assertions. Final reruns remained green: mobile 4/4, desktop 2/2, and existing
|
||||
long-dialog/delete regressions 2/2; focused lint, Prettier, the sleep ratchet,
|
||||
diff checks, and specification lint also passed.
|
||||
|
||||
Reference in New Issue
Block a user