Files
multica/e2e/issue-table.spec.ts
Multica Eve 8065cead85 MUL-5198: Restore server-backed issue table grouping (MUL-5100) (#5817)
* revert(issues): restore server-backed table grouping

Co-authored-by: multica-agent <github@multica.ai>

* test(skills): stabilize import completion coverage

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 12:03:27 +08:00

418 lines
13 KiB
TypeScript

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