Ctrl K
ComunidadGuías…

e2e/issue-table.spec.ts

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

Archivo original ↗Preguntar a KiBot
import {
  expect,
  test,
  type Page,
  type Request,
  type Response,
} from "@playwright/test";
import { createTestApi, loginAsDefault, reloadAppPage } from "./helpers";
import type { TestApiClient, TestTableIssueSeed } from "./fixtures";

type TableRequestBody = {
  group?: { kind?: string };
  group_key?: string | null;
  parent_id?: string | null;
  page?: { limit?: number; cursor?: string | null };
};

type TableGroupsResponse = {
  total: number;
  groups: Array<{
    key: string;
    count: number;
    value: { kind: string; status?: string };
  }>;
};

type TableRowsResponse = {
  total: number;
  next_cursor: string | null;
  rows: Array<{
    issue: { id: string; title: string; status: string };
    direct_child_count: number;
  }>;
};

function apiPath(request: Request) {
  return new URL(request.url()).pathname;
}

function tableBody(request: Request): TableRequestBody {
  return (request.postDataJSON() ?? {}) as TableRequestBody;
}

async function switchToTable(page: Page) {
  const currentView = page.getByRole("button", { name: "Board", exact: true });
  await expect(currentView).toBeVisible();
  await currentView.click();
  const tableOption = page.getByRole("menuitemradio", {
    name: "Table",
    exact: true,
  });
  await tableOption.click();
  await expect(
    page.getByRole("button", { name: "Table", exact: true }),
  ).toBeVisible();
  await expect(tableOption).toBeHidden();
}

async function groupByStatus(page: Page) {
  await page.getByRole("button", { name: "Group", exact: true }).click();
  const statusOption = page.getByRole("menuitemradio", {
    name: "Status",
    exact: true,
  });
  await statusOption.click();
  await expect(
    page.getByRole("button", { name: "Group: Status", exact: true }),
  ).toBeVisible();
  await expect(statusOption).toBeHidden();
}

test.describe("Issue Table server grouping", () => {
  test.describe.configure({ timeout: 120000 });

  let api: TestApiClient;

  test.beforeEach(async ({ page }) => {
    api = await createTestApi();
    await loginAsDefault(page);
  });

  test.afterEach(async () => {
    await api?.cleanup();
  });

  test("groups 1,001 issues exactly without materializing the full result", async ({
    page,
  }) => {
    const run = Date.now();
    const seeds: TestTableIssueSeed[] = Array.from({ length: 1001 }, (_, index) => ({
      title: `E2E Table Large ${run} ${index.toString().padStart(4, "0")}`,
      status: index < 501 ? "backlog" : index < 801 ? "todo" : "done",
      position: index + 1,
    }));
    await api.seedTableIssues(seeds);
    await reloadAppPage(page);

    const tableRowRequests: TableRequestBody[] = [];
    const legacyMembershipRequests: string[] = [];
    let lastObservedRequestAt = Date.now();
    const collectRequest = (request: Request) => {
      const path = apiPath(request);
      if (path === "/api/issues/table/rows") {
        lastObservedRequestAt = Date.now();
        tableRowRequests.push(tableBody(request));
      }
      if (path === "/api/issues" || path === "/api/issues/query") {
        lastObservedRequestAt = Date.now();
        legacyMembershipRequests.push(request.url());
      }
    };

    page.on("request", collectRequest);
    // The page initially mounts Board, whose seven status buckets may still
    // be finishing when the header becomes interactive. Observe that startup
    // traffic, wait for it to settle, then clear it while keeping the listener
    // installed so any request caused by the Table switch is captured.
    await expect
      .poll(() => Date.now() - lastObservedRequestAt, {
        timeout: 5000,
        intervals: [100, 200, 300],
      })
      .toBeGreaterThan(750);
    tableRowRequests.length = 0;
    legacyMembershipRequests.length = 0;
    lastObservedRequestAt = Date.now();
    await switchToTable(page);
    const groupsResponsePromise = page.waitForResponse(
      (response) =>
        apiPath(response.request()) === "/api/issues/table/groups" &&
        response.status() === 200,
    );
    await groupByStatus(page);

    const groupsResponse = await groupsResponsePromise;
    const groups = (await groupsResponse.json()) as TableGroupsResponse;
    expect(groups.total).toBe(1001);
    expect(
      Object.fromEntries(groups.groups.map((group) => [group.key, group.count])),
    ).toEqual({
      "status:backlog": 501,
      "status:todo": 300,
      "status:done": 200,
    });

    const backlogGroup = page
      .getByRole("row")
      .filter({ hasText: "Backlog" })
      .first();
    await expect(backlogGroup).toContainText("501");
    await expect(page.getByText(/Loaded \d+ of 1001/)).toBeVisible();
    await expect(
      page.getByText(/Grouping and hierarchy are paused/),
    ).toHaveCount(0);

    await expect
      .poll(() => Date.now() - lastObservedRequestAt, {
        timeout: 5000,
        intervals: [100, 200, 300],
      })
      .toBeGreaterThan(750);
    page.off("request", collectRequest);
    expect(legacyMembershipRequests).toEqual([]);
    expect(tableRowRequests.length).toBeGreaterThan(0);
    expect(tableRowRequests.length).toBeLessThan(8);
    expect(
      tableRowRequests.every(
        (body) =>
          (body.page?.limit ?? 0) <= 50 &&
          (body.page?.cursor === null || body.page?.cursor === undefined),
      ),
    ).toBe(true);
  });

  test("keeps same-group children nested and cross-group children at the group root", async ({
    page,
  }) => {
    const run = Date.now();
    const parentTitle = `E2E Hierarchy Parent ${run}`;
    const sameGroupTitle = `E2E Hierarchy Same Group ${run}`;
    const crossGroupTitle = `E2E Hierarchy Cross Group ${run}`;
    const [parent] = await api.seedTableIssues([
      { title: parentTitle, status: "todo", position: 1 },
    ]);
    if (!parent) throw new Error("Hierarchy parent fixture was not created");
    await api.seedTableIssues([
      {
        title: sameGroupTitle,
        status: "todo",
        parentIssueId: parent.id,
        position: 1,
      },
      {
        title: crossGroupTitle,
        status: "done",
        parentIssueId: parent.id,
        position: 2,
      },
    ]);
    await reloadAppPage(page);
    await switchToTable(page);

    const todoRootPromise = page.waitForResponse((response) => {
      if (apiPath(response.request()) !== "/api/issues/table/rows") return false;
      const body = tableBody(response.request());
      return (
        body.group_key === "status:todo" &&
        body.parent_id === null &&
        response.status() === 200
      );
    });
    const todoChildrenPromise = page.waitForResponse((response) => {
      if (apiPath(response.request()) !== "/api/issues/table/rows") return false;
      const body = tableBody(response.request());
      return body.parent_id === parent.id && response.status() === 200;
    });
    const doneRootPromise = page.waitForResponse((response) => {
      if (apiPath(response.request()) !== "/api/issues/table/rows") return false;
      const body = tableBody(response.request());
      return (
        body.group_key === "status:done" &&
        body.parent_id === null &&
        response.status() === 200
      );
    });

    await groupByStatus(page);
    const [todoRootResponse, todoChildrenResponse, doneRootResponse] =
      await Promise.all([
        todoRootPromise,
        todoChildrenPromise,
        doneRootPromise,
      ]);
    const todoRoot = (await todoRootResponse.json()) as TableRowsResponse;
    const todoChildren =
      (await todoChildrenResponse.json()) as TableRowsResponse;
    const doneRoot = (await doneRootResponse.json()) as TableRowsResponse;

    expect(todoRoot.total).toBe(3);
    expect(todoRoot.rows).toEqual([
      expect.objectContaining({
        issue: expect.objectContaining({ id: parent.id, title: parentTitle }),
        direct_child_count: 1,
      }),
    ]);
    expect(todoChildren.rows.map((row) => row.issue.title)).toEqual([
      sameGroupTitle,
    ]);
    expect(doneRoot.rows.map((row) => row.issue.title)).toEqual([
      crossGroupTitle,
    ]);

    const sameGroupRow = page
      .getByRole("row")
      .filter({ hasText: sameGroupTitle });
    const crossGroupRow = page
      .getByRole("row")
      .filter({ hasText: crossGroupTitle });
    await expect(sameGroupRow).toBeVisible();
    await expect(crossGroupRow).toBeVisible();
    await expect(
      sameGroupRow.getByRole("button", { name: sameGroupTitle }).locator(".."),
    ).toHaveCSS("padding-left", "18px");
    await expect(
      crossGroupRow.getByRole("button", { name: crossGroupTitle }).locator(".."),
    ).toHaveCSS("padding-left", "0px");
  });

  test("drops stale branch cursors after a realtime sort-boundary update", async ({
    page,
  }) => {
    const run = Date.now();
    const seeds = await api.seedTableIssues(
      Array.from({ length: 60 }, (_, index) => ({
        title: `E2E Table Cursor ${run} ${index.toString().padStart(2, "0")}`,
        status: "todo" as const,
        position: index + 1,
      })),
    );
    const moved = seeds.at(-1);
    if (!moved) throw new Error("Cursor fixture was not created");
    await reloadAppPage(page);

    const firstHeadPromise = page.waitForResponse((response) => {
      if (apiPath(response.request()) !== "/api/issues/table/rows") return false;
      const body = tableBody(response.request());
      return (
        body.group?.kind === "none" &&
        (body.page?.cursor === null || body.page?.cursor === undefined) &&
        response.status() === 200
      );
    });
    await switchToTable(page);
    const firstHead = (await (await firstHeadPromise).json()) as TableRowsResponse;
    const staleCursor = firstHead.next_cursor;
    expect(staleCursor).toBeTruthy();

    const firstTailPromise = page.waitForResponse((response) => {
      if (apiPath(response.request()) !== "/api/issues/table/rows") return false;
      const body = tableBody(response.request());
      return body.page?.cursor === staleCursor && response.status() === 200;
    });
    const tableScroller = page.locator("table").locator("..");
    await tableScroller.evaluate((element) => {
      element.scrollTop = element.scrollHeight;
    });
    await firstTailPromise;
    await expect(page.getByText("Loaded 60 of 60", { exact: true })).toBeVisible();

    const postUpdateResponses: Array<{
      body: TableRequestBody;
      payload: TableRowsResponse;
    }> = [];
    const collectResponse = async (response: Response) => {
      if (
        apiPath(response.request()) !== "/api/issues/table/rows" ||
        response.status() !== 200
      ) {
        return;
      }
      postUpdateResponses.push({
        body: tableBody(response.request()),
        payload: (await response.json()) as TableRowsResponse,
      });
    };
    page.on("response", collectResponse);
    await api.updateIssue(moved.id, { position: 0 });

    await expect
      .poll(
        () =>
          postUpdateResponses.find(
            ({ body }) =>
              body.page?.cursor === null || body.page?.cursor === undefined,
          )?.payload.next_cursor,
        { timeout: 10000 },
      )
      .not.toBeUndefined();
    const freshHead = postUpdateResponses.find(
      ({ body }) =>
        body.page?.cursor === null || body.page?.cursor === undefined,
    )?.payload;
    const freshCursor = freshHead?.next_cursor;
    expect(freshCursor).toBeTruthy();
    expect(freshCursor).not.toBe(staleCursor);

    await tableScroller.evaluate((element) => {
      element.scrollTop = element.scrollHeight;
    });
    await expect
      .poll(
        () =>
          postUpdateResponses.find(
            ({ body }) => body.page?.cursor === freshCursor,
          )?.payload,
        { timeout: 10000 },
      )
      .not.toBeUndefined();
    const freshTail = postUpdateResponses.find(
      ({ body }) => body.page?.cursor === freshCursor,
    )?.payload;
    const refreshedIds = [
      ...(freshHead?.rows ?? []),
      ...(freshTail?.rows ?? []),
    ].map((row) => row.issue.id);
    expect(new Set(refreshedIds).size).toBe(60);
    expect(refreshedIds).toContain(moved.id);
    await expect(page.getByText("Loaded 60 of 60", { exact: true })).toBeVisible();
    page.off("response", collectResponse);
  });

  test("shows a branch-level retry and never falls back to client grouping", async ({
    page,
  }) => {
    const title = `E2E Table Branch Retry ${Date.now()}`;
    await api.seedTableIssues([{ title, status: "todo", position: 1 }]);
    await reloadAppPage(page);

    let allowRows = false;
    let forcedFailures = 0;
    await page.route("**/api/issues/table/rows", async (route) => {
      const body = tableBody(route.request());
      if (
        body.group?.kind === "status" &&
        body.group_key === "status:todo" &&
        !allowRows
      ) {
        forcedFailures += 1;
        await route.fulfill({
          status: 500,
          contentType: "application/json",
          body: JSON.stringify({
            error: "forced_e2e_failure",
            message: "Forced Table branch failure",
          }),
        });
        return;
      }
      await route.continue();
    });

    await switchToTable(page);
    await groupByStatus(page);
    const retry = page.getByRole("button", {
      name: "Loading more failed — Retry",
      exact: true,
    });
    await expect(retry).toBeVisible({ timeout: 20000 });
    await expect(page.getByText(title)).toHaveCount(0);
    expect(forcedFailures).toBeGreaterThan(0);

    allowRows = true;
    await retry.click();
    await expect(page.getByText(title)).toBeVisible({ timeout: 10000 });
    await expect(retry).toHaveCount(0);
  });
});