Files
kandev/apps/web/e2e/tests/chat/mobile-entity-reference-composer.spec.ts
T

222 lines
8.0 KiB
TypeScript

import { type Page } from "@playwright/test";
import { test, expect, type SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
import type { CreateTaskResponse } from "../../../lib/types/http";
import type { EntityReference } from "../../../lib/types/entity-reference";
const LINEAR_SCOPE = "mock-org";
function taskReference(workspaceId: string, taskId: string, title: string): EntityReference {
return {
version: 1,
ref: `mention:v1:kandev:task:${workspaceId}:${taskId}`,
provider: "kandev",
kind: "task",
id: taskId,
title,
url: `/t/${taskId}`,
scope: workspaceId,
};
}
function linearIssueReference(index: number): EntityReference {
const id = `linear-issue-${index}`;
const key = `MOB-${100 + index}`;
return {
version: 1,
ref: `mention:v1:linear:issue:${LINEAR_SCOPE}:${id}`,
provider: "linear",
kind: "issue",
id,
key,
title: `Mobile Reference Linear ${String(index).padStart(2, "0")}`,
url: `https://linear.app/${LINEAR_SCOPE}/issue/${key}`,
scope: LINEAR_SCOPE,
};
}
async function createReadyTask(
apiClient: ApiClient,
seedData: SeedData,
): Promise<CreateTaskResponse> {
return apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile Entity Reference Active Task",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
}
async function openTaskChat(page: Page, taskId: string): Promise<SessionPage> {
await page.goto(`/t/${taskId}`);
const session = new SessionPage(page);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
return session;
}
async function expectPersistedReference(
apiClient: ApiClient,
sessionId: string,
referenceId: string,
): Promise<void> {
await expect
.poll(
async () => {
const { messages } = await apiClient.listSessionMessages(sessionId);
return messages.some((message) => {
const references = message.metadata?.entity_references;
return (
Array.isArray(references) &&
references.some(
(reference) =>
typeof reference === "object" &&
reference !== null &&
(reference as Record<string, unknown>).id === referenceId,
)
);
});
},
{ timeout: 15_000, message: `Wait for mobile reference ${referenceId}` },
)
.toBe(true);
}
test.describe("Mobile entity reference composer", () => {
test("touch selection stays viewport-contained, excludes Kandev tasks, and persists", async ({
testPage,
apiClient,
seedData,
}) => {
const references: EntityReference[] = [];
for (let index = 1; index <= 5; index += 1) {
const title = `Mobile Reference ${String(index).padStart(2, "0")}`;
const task = await apiClient.seedTask(seedData.workspaceId, title, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
references.push(taskReference(seedData.workspaceId, task.task_id, title));
}
const linearReferences = Array.from({ length: 8 }, (_, index) =>
linearIssueReference(index + 1),
);
await apiClient.setLinearConfig({
secret: "lin_api_mobile_entity_refs",
workspaceId: seedData.workspaceId,
});
await apiClient.waitForIntegrationAuthHealthy("linear", {
workspaceId: seedData.workspaceId,
});
const active = await createReadyTask(apiClient, seedData);
if (!active.session_id) throw new Error("createTaskWithAgent did not return a session_id");
const session = await openTaskChat(testPage, active.id);
await testPage.route("**/api/v1/workspaces/*/mentions/search?*", async (route) => {
const query = new URL(route.request().url()).searchParams.get("q") ?? "";
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
query,
groups: [
{
source: "kandev_tasks",
provider: "kandev",
kind: "task",
display_name: "Kandev tasks",
kind_label: "Task",
status: "ok",
results: references,
},
{
source: "linear_issues",
provider: "linear",
kind: "issue",
display_name: "Linear",
kind_label: "Issue",
status: "ok",
results: linearReferences,
},
],
}),
});
});
const editor = await session.composerReady();
await editor.tap();
await editor.fill("");
await editor.pressSequentially("#Mobile Reference");
const menu = testPage.getByTestId("entity-reference-menu");
await expect(menu).toBeVisible({ timeout: 10_000 });
await expect(menu.getByRole("option")).toHaveCount(linearReferences.length, {
timeout: 10_000,
});
await expect(menu.getByText("Kandev tasks", { exact: true })).toHaveCount(0);
const [menuBox, editorBox] = await Promise.all([menu.boundingBox(), editor.boundingBox()]);
expect(menuBox).not.toBeNull();
expect(editorBox).not.toBeNull();
expect(Math.abs(menuBox!.y + menuBox!.height - editorBox!.y)).toBeLessThanOrEqual(4);
const geometry = await menu.evaluate((element) => {
const rect = element.getBoundingClientRect();
const viewport = window.visualViewport;
const viewportBounds = viewport
? {
left: viewport.offsetLeft,
top: viewport.offsetTop,
right: viewport.offsetLeft + viewport.width,
bottom: viewport.offsetTop + viewport.height,
}
: { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
const listbox = element.querySelector<HTMLElement>("[role='listbox']");
const rowHeights = Array.from(element.querySelectorAll<HTMLElement>("[role='option']")).map(
(row) => row.getBoundingClientRect().height,
);
return {
menu: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
viewport: viewportBounds,
minRowHeight: Math.min(...rowHeights),
listClientHeight: listbox?.clientHeight ?? 0,
listScrollHeight: listbox?.scrollHeight ?? 0,
listOverflowY: listbox ? getComputedStyle(listbox).overflowY : "",
documentClientWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
};
});
expect(geometry.menu.left).toBeGreaterThanOrEqual(geometry.viewport.left - 1);
expect(geometry.menu.top).toBeGreaterThanOrEqual(geometry.viewport.top - 1);
expect(geometry.menu.right).toBeLessThanOrEqual(geometry.viewport.right + 1);
expect(geometry.menu.bottom).toBeLessThanOrEqual(geometry.viewport.bottom + 1);
expect(geometry.minRowHeight).toBeGreaterThanOrEqual(44);
expect(geometry.listScrollHeight).toBeGreaterThan(geometry.listClientHeight);
expect(geometry.listOverflowY).toMatch(/auto|scroll/);
expect(geometry.documentScrollWidth).toBeLessThanOrEqual(geometry.documentClientWidth);
const selected = linearReferences[0];
const option = testPage.getByRole("option").filter({ hasText: selected.title });
await option.tap();
await expect(menu).not.toBeVisible();
await expect(editor.getByTestId("entity-reference-chip")).toContainText(
selected.key ?? selected.title,
);
await expect(editor).toBeFocused();
await session.activeChat().getByTestId("submit-message-button").tap();
await expectPersistedReference(apiClient, active.session_id, selected.id);
await expect(
session
.activeChat()
.locator(".chat-message-list:visible")
.getByTestId("entity-reference-chip")
.filter({ hasText: selected.key ?? selected.title }),
).toBeVisible({ timeout: 10_000 });
});
});