mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
* 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>
418 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|