Ctrl K
ComunidadGuías…

tests/e2e/status-toasts.spec.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { expect, test, type CDPSession, type Page } from "@playwright/test";
import { loadStatusToastFixture } from "./helpers/status-toast-fixture";

async function dragTouch(
  client: CDPSession,
  page: Page,
  start: { x: number; y: number },
  end: { x: number; y: number }
) {
  await client.send("Input.dispatchTouchEvent", {
    type: "touchStart",
    touchPoints: [start],
  });
  for (const progress of [0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1]) {
    await client.send("Input.dispatchTouchEvent", {
      type: "touchMove",
      touchPoints: [
        {
          x: Math.round(start.x + (end.x - start.x) * progress),
          y: Math.round(start.y + (end.y - start.y) * progress),
        },
      ],
    });
    await page.evaluate(
      () =>
        new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
    );
  }
  await client.send("Input.dispatchTouchEvent", {
    type: "touchEnd",
    touchPoints: [],
  });
}

for (const theme of ["light", "dark"] as const) {
  test(`status cards in ${theme} have real tints, readable icons, and visible controls`, async ({
    page,
  }, testInfo) => {
    await page.setViewportSize({ width: 1280, height: 900 });
    await loadStatusToastFixture(page, theme);
    await page
      .getByRole("button", { name: "Show statuses", exact: true })
      .click();
    await expect(page.locator("[data-variant]")).toHaveCount(4);
    const icons: string[] = [];
    for (const variant of ["success", "info", "warning", "destructive"]) {
      const toast = page.locator(`[data-variant="${variant}"]`);
      await expect(toast).toHaveCSS("opacity", "1");
      const measured = await toast.evaluate((element) => {
        const canvas = document.createElement("canvas");
        canvas.width = 1;
        canvas.height = 1;
        const context = canvas.getContext("2d")!;
        const rgb = (color: string) => {
          context.clearRect(0, 0, 1, 1);
          context.fillStyle = "#000000";
          context.fillStyle = color;
          context.fillRect(0, 0, 1, 1);
          return Array.from(context.getImageData(0, 0, 1, 1).data);
        };
        const luminance = (channels: number[]) =>
          channels
            .slice(0, 3)
            .map((value) => {
              const c = value / 255;
              return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
            })
            .reduce(
              (total, value, index) =>
                total + value * [0.2126, 0.7152, 0.0722][index],
              0
            );
        const background = rgb(getComputedStyle(element).backgroundColor);
        const contrast = (node: Element) => {
          const color = rgb(getComputedStyle(node).color);
          const alpha = color[3] / 255;
          const painted = color.map(
            (channel, index) =>
              channel * alpha + background[index] * (1 - alpha)
          );
          const values = [luminance(painted), luminance(background)].sort(
            (a, b) => a - b
          );
          return (values[1] + 0.05) / (values[0] + 0.05);
        };
        const icon = element.querySelector("svg")!;
        const title = element.querySelector('[data-slot="toast-title"]')!;
        const close = element.querySelector(
          'button[aria-label="Dismiss notification"]'
        )!;
        const description = element.querySelector(
          '[data-slot="toast-description"]'
        )!;
        const neutral = document.createElement("span");
        neutral.hidden = true;
        neutral.style.backgroundColor = "var(--popover)";
        document.body.append(neutral);
        const popover = rgb(getComputedStyle(neutral).backgroundColor);
        neutral.remove();
        return {
          background,
          popover,
          icon: contrast(icon),
          title: contrast(title),
          close: contrast(close),
          description: contrast(description),
          closeOpacity: getComputedStyle(close).opacity,
          iconMarkup: Array.from(icon.querySelectorAll("path"), (path) =>
            path.getAttribute("d")
          ).join("|"),
        };
      });
      expect(measured.background).not.toEqual(measured.popover);
      expect(measured.icon).toBeGreaterThanOrEqual(3);
      expect(measured.title).toBeGreaterThanOrEqual(4.5);
      expect(measured.close).toBeGreaterThanOrEqual(3);
      expect(measured.description).toBeGreaterThanOrEqual(4.5);
      expect(measured.closeOpacity).toBe("1");
      icons.push(measured.iconMarkup);
    }
    expect(new Set(icons).size).toBe(4);
    const screenshot = testInfo.outputPath(`toasts-${theme}.png`);
    await page
      .locator('[data-slot="toast-viewport"]')
      .screenshot({ path: screenshot });
    await testInfo.attach(`toasts-${theme}`, {
      path: screenshot,
      contentType: "image/png",
    });
  });
}

test("routine warnings announce politely while failures interrupt", async ({
  page,
}) => {
  await loadStatusToastFixture(page, "light");
  await page
    .getByRole("button", { name: "Show statuses", exact: true })
    .click();
  await expect(
    page
      .locator('[role="status"][aria-live="polite"]')
      .filter({ hasText: "Checks need attention" })
  ).toHaveCount(1);
  await expect(
    page
      .locator('[role="status"][aria-live="assertive"]')
      .filter({ hasText: "Build failed" })
  ).toHaveCount(1);
});

test("notifications keep steering and stop controls available and can be dismissed individually", async ({
  page,
}) => {
  await loadStatusToastFixture(page, "dark");
  let release!: () => void;
  const ready = new Promise<void>((resolve) => {
    release = resolve;
  });
  await page.route("**/__e2e/notify", async (route) => {
    await ready;
    await route.fulfill({ json: { ok: true } });
  });
  await page
    .getByRole("button", { name: "Simulate background completion" })
    .click();
  const composer = page.getByRole("textbox", { name: "Steer agent" });
  await composer.fill("Keep the changes focused.");
  release();
  await expect(page.locator("[data-variant=success]")).toContainText(
    "Background task finished"
  );
  await expect(composer).toBeFocused();
  await expect(composer).toHaveValue("Keep the changes focused.");
  await page.getByRole("button", { name: "Stop agent", exact: true }).click();
  await expect(page.getByTestId("stop-count")).toHaveText("1");
  await page
    .getByRole("button", { name: "Show statuses", exact: true })
    .click();
  await expect(page.locator("[data-variant]")).toHaveCount(5);
  await page
    .locator("[data-variant=destructive]")
    .getByRole("button", { name: "Dismiss notification" })
    .click();
  await expect(page.locator("[data-variant=destructive]")).toHaveCount(0);
  await expect(page.locator("[data-variant=warning]")).toBeVisible();
  await page
    .getByRole("button", { name: "Show statuses", exact: true })
    .click();
  await page
    .getByRole("button", { name: "Show statuses", exact: true })
    .click();
  await expect(page.locator("[data-variant]")).toHaveCount(12);
  expect(
    await page
      .locator('[data-slot="toast-viewport"]')
      .evaluate((viewport) => viewport.scrollHeight > viewport.clientHeight)
  ).toBe(true);
  await page.getByRole("button", { name: "Stop agent", exact: true }).click();
  await expect(page.getByTestId("stop-count")).toHaveText("2");
});

test("short screens keep the toast stack clear of toolbars and the composer", async ({
  page,
}) => {
  await page.setViewportSize({ width: 390, height: 600 });
  await loadStatusToastFixture(page, "dark");
  await page.getByRole("button", { name: "Show burst", exact: true }).click();
  await expect(page.locator("[data-variant]")).toHaveCount(12);
  const viewport = page.locator('[data-slot="toast-viewport"]');
  const stack = await viewport.boundingBox();
  const composer = page.getByRole("textbox", { name: "Steer agent" });
  const input = await composer.boundingBox();
  expect(stack!.y).toBeGreaterThanOrEqual(176);
  expect(stack!.y + stack!.height).toBeLessThan(input!.y);
  expect(
    await viewport.evaluate(
      (element) => element.scrollHeight > element.clientHeight
    )
  ).toBe(true);
  await composer.fill("Keep this easy to steer.");
  await page.getByRole("button", { name: "Stop agent", exact: true }).click();
  await expect(page.getByTestId("stop-count")).toHaveText("1");
});

for (const height of [390, 320]) {
  test(`landscape notifications stay bounded and leave control available at ${height}px`, async ({
    page,
  }) => {
    await page.setViewportSize({ width: 844, height });
    await loadStatusToastFixture(page, "dark");
    await page.getByRole("button", { name: "Show burst", exact: true }).click();
    const viewport = page.locator('[data-slot="toast-viewport"]');
    const stack = await viewport.boundingBox();
    const composer = page.getByRole("textbox", { name: "Steer agent" });
    const input = await composer.boundingBox();
    expect(stack!.y + stack!.height).toBeLessThan(input!.y);
    expect(
      await viewport.evaluate((element) => getComputedStyle(element).maxHeight)
    ).not.toBe("none");
    if (height === 390) {
      const newest = page.locator("[data-variant]").first();
      const card = await newest.boundingBox();
      expect(stack!.height).toBeGreaterThanOrEqual(card!.height);
      const close = newest.getByRole("button", {
        name: "Dismiss notification",
      });
      const closeBox = await close.boundingBox();
      expect(closeBox!.height).toBeGreaterThanOrEqual(44);
      await close.click();
      await expect(page.locator("[data-variant]")).toHaveCount(11);
    }
    await composer.fill("Keep control in landscape.");
    await page.getByRole("button", { name: "Stop agent", exact: true }).click();
    await expect(page.getByTestId("stop-count")).toHaveText("1");
  });
}

test("toast actions and keyboard dismissal work on mobile with reduced motion", async ({
  page,
}, testInfo) => {
  await page.setViewportSize({ width: 390, height: 844 });
  await page.emulateMedia({ reducedMotion: "reduce" });
  await loadStatusToastFixture(page, "light");
  await page.getByRole("button", { name: "Show action", exact: true }).click();
  const toast = page.locator("[data-variant=info]");
  await expect(toast).toHaveCSS("animation-name", "none");
  const close = toast.getByRole("button", { name: "Dismiss notification" });
  const box = await close.boundingBox();
  expect(box?.width).toBeGreaterThanOrEqual(44);
  const actionBox = await toast
    .getByRole("button", { name: "Undo", exact: true })
    .boundingBox();
  expect(actionBox?.height).toBeGreaterThanOrEqual(44);
  expect(actionBox?.width).toBeGreaterThanOrEqual(44);
  await toast.getByRole("button", { name: "Undo", exact: true }).click();
  await expect(page.getByTestId("undo-count")).toHaveText("1");
  await expect(page.getByTestId("closed-count")).toHaveText("1");
  await expect(toast).toHaveCount(0);
  await page.getByRole("button", { name: "Show action", exact: true }).click();
  await toast.getByRole("button", { name: "Dismiss notification" }).focus();
  await page.keyboard.press("Enter");
  await expect(toast).toHaveCount(0);
  await expect(page.getByTestId("closed-count")).toHaveText("2");
  expect(
    await page.evaluate(() => document.documentElement.scrollWidth > innerWidth)
  ).toBe(false);
  await page
    .getByRole("button", { name: "Show statuses", exact: true })
    .click();
  const screenshot = testInfo.outputPath("toasts-mobile.png");
  await page.screenshot({ path: screenshot });
  await testInfo.attach("toasts-mobile", {
    path: screenshot,
    contentType: "image/png",
  });
});

test("hover pauses expiry until the user leaves the notification", async ({
  page,
}) => {
  await loadStatusToastFixture(page, "dark");
  await page.clock.install();
  await page
    .getByRole("button", { name: "Show brief toast", exact: true })
    .click();
  const toast = page.locator("[data-variant=success]");
  await toast.hover();
  await page.clock.fastForward(3000);
  await expect(toast).toBeVisible();
  await page.mouse.move(1, 1);
  await page.clock.fastForward(1001);
  await expect(toast).toHaveCount(0);
});

test("keyboard focus pauses expiry while a notification is being read", async ({
  page,
}) => {
  await loadStatusToastFixture(page, "dark");
  await page.clock.install();
  await page
    .getByRole("button", { name: "Show brief toast", exact: true })
    .click();
  const toast = page.locator("[data-variant=success]");
  await toast.getByRole("button", { name: "Dismiss notification" }).focus();
  await page.clock.fastForward(3000);
  await expect(toast).toBeVisible();
  await page.getByRole("textbox", { name: "Steer agent" }).focus();
  await page.clock.fastForward(1001);
  await expect(toast).toHaveCount(0);
});

test.describe("touch notifications", () => {
  test.use({ hasTouch: true, isMobile: true });
  test("mobile stacks support vertical touch scrolling and horizontal swipe dismissal", async ({
    page,
  }) => {
    await page.setViewportSize({ width: 390, height: 844 });
    await loadStatusToastFixture(page, "light");
    await page.getByRole("button", { name: "Show burst", exact: true }).click();
    await expect(page.locator("[data-variant]")).toHaveCount(12);
    const first = page.locator("[data-variant]").first();
    await expect(first).toHaveCSS("touch-action", "pan-y");
    await expect(first).toHaveCSS("opacity", "1");
    const viewport = page.locator('[data-slot="toast-viewport"]');
    const scrollTarget = page.locator("[data-variant]").nth(2);
    const scrollCard = await scrollTarget.boundingBox();
    expect(scrollCard).not.toBeNull();
    const client = await page.context().newCDPSession(page);
    const scrollStart = {
      x: Math.round(scrollCard!.x + scrollCard!.width / 2),
      y: Math.round(scrollCard!.y + scrollCard!.height / 2),
    };
    expect(
      await scrollTarget.evaluate(
        (element, point) =>
          element.contains(document.elementFromPoint(point.x, point.y)),
        scrollStart
      )
    ).toBe(true);
    await viewport.evaluate((element) => {
      for (const name of ["touchstart", "touchmove", "pointercancel"])
        element.addEventListener(
          name,
          () => {
            const attribute = `data-test-${name}`;
            element.setAttribute(
              attribute,
              String(Number(element.getAttribute(attribute) ?? 0) + 1)
            );
          },
          { passive: true }
        );
    });
    await dragTouch(client, page, scrollStart, {
      x: scrollStart.x,
      y: scrollStart.y - 120,
    });
    try {
      await expect
        .poll(() => viewport.evaluate((element) => element.scrollTop))
        .toBeGreaterThan(0);
    } finally {
      const evidence = await viewport.evaluate((element) => ({
        scrollTop: element.scrollTop,
        scrollHeight: element.scrollHeight,
        clientHeight: element.clientHeight,
        pointerEvents: getComputedStyle(element).pointerEvents,
        overflowY: getComputedStyle(element).overflowY,
        touchStart: element.getAttribute("data-test-touchstart"),
        touchMove: element.getAttribute("data-test-touchmove"),
        pointerCancel: element.getAttribute("data-test-pointercancel"),
        scale: visualViewport?.scale,
        pageScrollY: scrollY,
      }));
      await test.info().attach("touch-scroll-evidence", {
        body: JSON.stringify(evidence, null, 2),
        contentType: "application/json",
      });
    }
    await page.keyboard.press("F8");
    await page.keyboard.press("Home");
    await expect
      .poll(() => viewport.evaluate((element) => element.scrollTop))
      .toBe(0);
    const toast = await first.boundingBox();
    expect(toast).not.toBeNull();
    const start = {
      x: Math.round(toast!.x + 48),
      y: Math.round(toast!.y + 28),
    };
    await dragTouch(client, page, start, { x: start.x + 210, y: start.y });
    await expect(page.locator("[data-variant]")).toHaveCount(11);
    await page.getByRole("button", { name: "Stop agent", exact: true }).click();
    await expect(page.getByTestId("stop-count")).toHaveText("1");
    await client.detach();
  });
});