511 lines
19 KiB
TypeScript
511 lines
19 KiB
TypeScript
import { type Locator, 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,
|
|
EntityReferenceSearchResponse,
|
|
} from "../../../lib/types/entity-reference";
|
|
import { waitForQuickChatComposerReady } from "./quick-chat-helpers";
|
|
|
|
const REFERENCE_QUERY = "E2E 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 linearReference(id: string, key: string, title: string): EntityReference {
|
|
return {
|
|
version: 1,
|
|
ref: `mention:v1:linear:issue:${LINEAR_SCOPE}:${id}`,
|
|
provider: "linear",
|
|
kind: "issue",
|
|
id,
|
|
key,
|
|
title,
|
|
url: `https://linear.app/${LINEAR_SCOPE}/issue/${key}`,
|
|
scope: LINEAR_SCOPE,
|
|
};
|
|
}
|
|
|
|
function externalSearchResponse(
|
|
query: string,
|
|
references: EntityReference[],
|
|
includeTimedOutProvider = false,
|
|
): EntityReferenceSearchResponse {
|
|
return {
|
|
query,
|
|
groups: [
|
|
{
|
|
source: "kandev_tasks",
|
|
provider: "kandev",
|
|
kind: "task",
|
|
display_name: "Kandev tasks",
|
|
kind_label: "Task",
|
|
status: "ok",
|
|
results: [taskReference("workspace-stale", "task-stale", "Hidden Kandev task")],
|
|
},
|
|
{
|
|
source: "linear_issues",
|
|
provider: "linear",
|
|
kind: "issue",
|
|
display_name: "Linear",
|
|
kind_label: "Issue",
|
|
status: "ok",
|
|
results: references,
|
|
},
|
|
...(includeTimedOutProvider
|
|
? [
|
|
{
|
|
source: "github_issues",
|
|
provider: "github",
|
|
kind: "issue",
|
|
display_name: "GitHub issues",
|
|
kind_label: "Issue",
|
|
status: "timeout" as const,
|
|
results: [],
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
};
|
|
}
|
|
|
|
async function createReadyTask(
|
|
apiClient: ApiClient,
|
|
seedData: SeedData,
|
|
title: string,
|
|
): Promise<CreateTaskResponse> {
|
|
return apiClient.createTaskWithAgent(seedData.workspaceId, title, 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;
|
|
}
|
|
|
|
function visibleEditor(scope: Locator | Page): Locator {
|
|
return scope.locator(".tiptap.ProseMirror:visible").first();
|
|
}
|
|
|
|
function referenceOption(page: Page, title: string): Locator {
|
|
return page.getByRole("option").filter({ hasText: title });
|
|
}
|
|
|
|
async function expectMenuAnchoredToEditor(menu: Locator, editor: Locator): Promise<void> {
|
|
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);
|
|
}
|
|
|
|
async function typeReferenceQuery(editor: Locator, query: string): Promise<void> {
|
|
await editor.click();
|
|
await editor.fill("");
|
|
await editor.pressSequentially(`#${query}`);
|
|
}
|
|
|
|
async function expectPersistedReference(
|
|
apiClient: ApiClient,
|
|
sessionId: string,
|
|
referenceId: string,
|
|
): Promise<EntityReference> {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { messages } = await apiClient.listSessionMessages(sessionId);
|
|
return messages.some((message) => {
|
|
const references = message.metadata?.entity_references;
|
|
return (
|
|
message.author_type === "user" &&
|
|
Array.isArray(references) &&
|
|
references.some(
|
|
(reference) =>
|
|
typeof reference === "object" &&
|
|
reference !== null &&
|
|
(reference as Record<string, unknown>).id === referenceId,
|
|
)
|
|
);
|
|
});
|
|
},
|
|
{ timeout: 15_000, message: `Wait for persisted reference ${referenceId}` },
|
|
)
|
|
.toBe(true);
|
|
|
|
const { messages } = await apiClient.listSessionMessages(sessionId);
|
|
for (const message of messages) {
|
|
const references = message.metadata?.entity_references;
|
|
if (!Array.isArray(references)) continue;
|
|
const reference = references.find(
|
|
(candidate) =>
|
|
typeof candidate === "object" &&
|
|
candidate !== null &&
|
|
(candidate as Record<string, unknown>).id === referenceId,
|
|
);
|
|
if (reference) return reference as EntityReference;
|
|
}
|
|
throw new Error(`Persisted entity reference ${referenceId} disappeared`);
|
|
}
|
|
|
|
async function configureLinear(apiClient: ApiClient, workspaceId: string): Promise<void> {
|
|
await apiClient.setLinearConfig({ secret: "lin_api_entity_refs", workspaceId });
|
|
await apiClient.waitForIntegrationAuthHealthy("linear", { workspaceId });
|
|
}
|
|
|
|
async function openQuickChatWithAgent(page: Page): Promise<{
|
|
dialog: Locator;
|
|
sessionId: string;
|
|
}> {
|
|
await page.goto("/");
|
|
await page.waitForLoadState("networkidle");
|
|
await page.getByTestId("sidebar-quick-chat-shortcut").click();
|
|
|
|
const dialog = page.getByRole("dialog", { name: "Quick Chat" });
|
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
|
const setup = dialog.getByTestId("quick-chat-setup");
|
|
if (!(await setup.isVisible({ timeout: 1_000 }).catch(() => false))) {
|
|
await dialog.getByTestId("quick-chat-add-menu-trigger").click();
|
|
await page.getByTestId("quick-chat-new-agent").click();
|
|
}
|
|
await expect(setup).toBeVisible({ timeout: 5_000 });
|
|
|
|
const agentSelector = dialog.getByTestId("agent-profile-selector");
|
|
if (
|
|
await agentSelector
|
|
.getByText("Select agent", { exact: false })
|
|
.isVisible()
|
|
.catch(() => false)
|
|
) {
|
|
await agentSelector.click();
|
|
await page.getByRole("option").first().click();
|
|
}
|
|
|
|
const started = page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === "POST" &&
|
|
new URL(response.url()).pathname.endsWith("/quick-chat"),
|
|
);
|
|
await dialog.getByTestId("quick-chat-start").click();
|
|
const payload = (await started).json() as Promise<{ session_id: string }>;
|
|
const { session_id: sessionId } = await payload;
|
|
|
|
await waitForQuickChatComposerReady(dialog);
|
|
return { dialog, sessionId };
|
|
}
|
|
|
|
async function createPassthroughProfile(apiClient: ApiClient): Promise<string> {
|
|
const { agents } = await apiClient.listAgents();
|
|
const agent = agents[0];
|
|
if (!agent) throw new Error("No E2E agent registered");
|
|
const profile = await apiClient.createAgentProfile(agent.id, "Entity Reference Passthrough", {
|
|
model: "mock-fast",
|
|
cli_passthrough: true,
|
|
});
|
|
return profile.id;
|
|
}
|
|
|
|
test.describe("Entity reference composer", () => {
|
|
test("keeps Kandev task suggestions under @", async ({ testPage, apiClient, seedData }) => {
|
|
const targetTitle = "At-Mention-Task-Target";
|
|
await apiClient.seedTask(seedData.workspaceId, targetTitle, {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
});
|
|
const active = await createReadyTask(apiClient, seedData, "At Mention Active Task");
|
|
const session = await openTaskChat(testPage, active.id);
|
|
const editor = visibleEditor(session.activeChat());
|
|
|
|
await editor.fill("");
|
|
await editor.pressSequentially(`@${targetTitle}`);
|
|
|
|
await expect(testPage.getByRole("option").filter({ hasText: targetTitle })).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
await editor.press("Escape");
|
|
await expect(testPage.getByRole("option").filter({ hasText: targetTitle })).toHaveCount(0);
|
|
await expect(editor).toBeFocused();
|
|
await editor.pressSequentially(" continued");
|
|
await expect(editor).toHaveText(new RegExp(`@${targetTitle} continued`));
|
|
});
|
|
|
|
test("task chat restores a keyboard-selected draft and explicitly sends durable metadata", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const alpha = linearReference("linear-alpha", "ENG-101", `${REFERENCE_QUERY} Alpha`);
|
|
const beta = linearReference("linear-beta", "ENG-102", `${REFERENCE_QUERY} Beta`);
|
|
await configureLinear(apiClient, seedData.workspaceId);
|
|
const active = await createReadyTask(apiClient, seedData, "Entity Reference Active Task");
|
|
if (!active.session_id) throw new Error("createTaskWithAgent did not return a session_id");
|
|
|
|
const pageErrors: string[] = [];
|
|
testPage.on("pageerror", (error) => pageErrors.push(error.message));
|
|
const session = await openTaskChat(testPage, active.id);
|
|
const editor = visibleEditor(session.activeChat());
|
|
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(externalSearchResponse(query, [alpha, beta])),
|
|
});
|
|
});
|
|
await typeReferenceQuery(editor, REFERENCE_QUERY);
|
|
|
|
const menu = testPage.getByTestId("entity-reference-menu");
|
|
await expect(menu).toBeVisible({ timeout: 10_000 });
|
|
await expect(menu.getByRole("listbox", { name: "Reference work items" })).toBeVisible();
|
|
await expect(referenceOption(testPage, alpha.title)).toHaveAttribute("aria-selected", "true");
|
|
await expect(referenceOption(testPage, "Hidden Kandev task")).toHaveCount(0);
|
|
await expectMenuAnchoredToEditor(menu, editor);
|
|
|
|
await editor.press("ArrowDown");
|
|
const betaOption = referenceOption(testPage, beta.title);
|
|
await expect(betaOption).toHaveAttribute("aria-selected", "true");
|
|
await editor.press("Tab");
|
|
|
|
await expect(menu).not.toBeVisible();
|
|
await expect(editor).toBeFocused();
|
|
await expect(editor.getByTestId("entity-reference-chip")).toContainText(beta.key ?? beta.title);
|
|
await editor.pressSequentially("needs follow-up");
|
|
await expect(editor).toHaveText(/ENG-102\s+needs follow-up/);
|
|
await expect(
|
|
session
|
|
.activeChat()
|
|
.locator(".chat-message-list:visible")
|
|
.getByText("needs follow-up", { exact: false }),
|
|
).not.toBeVisible();
|
|
|
|
await testPage.reload();
|
|
await session.waitForLoad();
|
|
const restoredEditor = visibleEditor(session.activeChat());
|
|
// A restored non-empty draft intentionally has no idle placeholder, so
|
|
// wait on editability instead of SessionPage.waitForChatIdle().
|
|
await expect(restoredEditor).toHaveAttribute("contenteditable", "true", {
|
|
timeout: 30_000,
|
|
});
|
|
await expect(restoredEditor.getByTestId("entity-reference-chip")).toContainText(
|
|
beta.key ?? beta.title,
|
|
);
|
|
await expect(restoredEditor).toHaveText(/ENG-102\s+needs follow-up/);
|
|
|
|
await session.activeChat().getByTestId("submit-message-button").click();
|
|
const persisted = await expectPersistedReference(apiClient, active.session_id, beta.id);
|
|
expect(persisted).toMatchObject({
|
|
version: 1,
|
|
provider: "linear",
|
|
kind: "issue",
|
|
id: beta.id,
|
|
key: beta.key,
|
|
title: beta.title,
|
|
url: beta.url,
|
|
scope: LINEAR_SCOPE,
|
|
});
|
|
|
|
const sentChip = session
|
|
.activeChat()
|
|
.locator(".chat-message-list:visible")
|
|
.getByTestId("entity-reference-chip")
|
|
.filter({ hasText: beta.key });
|
|
await expect(sentChip).toBeVisible({ timeout: 10_000 });
|
|
await expect(sentChip).toHaveAttribute("href", beta.url);
|
|
expect(pageErrors).toEqual([]);
|
|
});
|
|
|
|
test("keeps successful results visible when another provider times out", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const target = linearReference("linear-partial", "ENG-201", "Partial Result Target");
|
|
const active = await createReadyTask(apiClient, seedData, "Partial Result Active Task");
|
|
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(externalSearchResponse(query, [target], true)),
|
|
});
|
|
});
|
|
|
|
await typeReferenceQuery(visibleEditor(testPage), "Partial Result");
|
|
const menu = testPage.getByTestId("entity-reference-menu");
|
|
await expect(referenceOption(testPage, target.title)).toBeVisible({ timeout: 10_000 });
|
|
await expect(referenceOption(testPage, "Hidden Kandev task")).toHaveCount(0);
|
|
await expect(menu.getByText("GitHub issues", { exact: true })).toBeVisible();
|
|
await expect(menu.getByText("Search timed out", { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test("Quick Chat searches, inserts without auto-send, and explicitly sends an external reference", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const target = linearReference("linear-quick-chat", "ENG-301", "Quick Chat Reference Target");
|
|
await configureLinear(apiClient, seedData.workspaceId);
|
|
const { dialog, sessionId } = await openQuickChatWithAgent(testPage);
|
|
const editor = visibleEditor(dialog);
|
|
|
|
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(externalSearchResponse(query, [target])),
|
|
});
|
|
});
|
|
|
|
await typeReferenceQuery(editor, "Quick Chat Reference");
|
|
await expect(referenceOption(testPage, target.title)).toBeVisible({ timeout: 10_000 });
|
|
await expect(referenceOption(testPage, "Hidden Kandev task")).toHaveCount(0);
|
|
await editor.press("Enter");
|
|
|
|
await expect(editor.getByTestId("entity-reference-chip")).toContainText(
|
|
target.key ?? target.title,
|
|
);
|
|
await expect(editor).toBeFocused();
|
|
await expect(
|
|
dialog
|
|
.getByTestId("quick-chat-messages")
|
|
.getByTestId("entity-reference-chip")
|
|
.filter({ hasText: target.key ?? target.title }),
|
|
).not.toBeVisible();
|
|
|
|
await dialog.getByTestId("submit-message-button").click();
|
|
await expectPersistedReference(apiClient, sessionId, target.id);
|
|
await expect(
|
|
dialog
|
|
.getByTestId("quick-chat-messages")
|
|
.getByTestId("entity-reference-chip")
|
|
.filter({ hasText: target.key ?? target.title }),
|
|
).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("passthrough keeps hash text literal and never starts entity search", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
const profileId = await createPassthroughProfile(apiClient);
|
|
const active = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Entity Reference Passthrough Task",
|
|
profileId,
|
|
{
|
|
description: "initial passthrough prompt",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
await testPage.goto(`/t/${active.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForPassthroughLoad(20_000);
|
|
await session.waitForPassthroughLoaded(20_000);
|
|
await session.expectPassthroughHasText("Processed:", 20_000);
|
|
|
|
await testPage.getByTestId("passthrough-toggle-composer").click();
|
|
const editor = testPage.getByTestId("passthrough-composer").getByTestId("chat-input-editor");
|
|
await expect(editor).toBeVisible({ timeout: 5_000 });
|
|
const searchObserved = testPage
|
|
.waitForRequest((request) => new URL(request.url()).pathname.endsWith("/mentions/search"), {
|
|
timeout: 750,
|
|
})
|
|
.then(
|
|
() => true,
|
|
() => false,
|
|
);
|
|
await editor.fill("#Literal passthrough reference");
|
|
|
|
expect(await searchObserved).toBe(false);
|
|
await expect(testPage.getByTestId("entity-reference-menu")).toHaveCount(0);
|
|
await expect(editor).toHaveText("#Literal passthrough reference");
|
|
});
|
|
|
|
test("pasted hash text stays literal and dismissed entity reference stays closed", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
const target = linearReference("linear-lifecycle", "ENG-401", "Lifecycle Reference Target");
|
|
const pastedText = "#2730 explicit profile overridden seems an issue";
|
|
const active = await createReadyTask(apiClient, seedData, "Entity Reference Lifecycle Task");
|
|
const session = await openTaskChat(testPage, active.id);
|
|
const editor = visibleEditor(session.activeChat());
|
|
const menu = testPage.getByTestId("entity-reference-menu");
|
|
|
|
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(externalSearchResponse(query, [target])),
|
|
});
|
|
});
|
|
await testPage.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
|
await testPage.evaluate(async (text) => navigator.clipboard.writeText(text), pastedText);
|
|
|
|
const searchObserved = testPage
|
|
.waitForRequest((request) => new URL(request.url()).pathname.endsWith("/mentions/search"), {
|
|
timeout: 750,
|
|
})
|
|
.then(
|
|
() => true,
|
|
() => false,
|
|
);
|
|
await editor.click();
|
|
await editor.press("Control+V");
|
|
|
|
expect(await searchObserved).toBe(false);
|
|
await expect(menu).toHaveCount(0);
|
|
await expect(editor).toHaveText(pastedText);
|
|
|
|
await editor.pressSequentially(" #E2E");
|
|
await expect(referenceOption(testPage, target.title)).toBeVisible({ timeout: 10_000 });
|
|
await editor.press("Backspace");
|
|
await expect(referenceOption(testPage, target.title)).toBeVisible({ timeout: 10_000 });
|
|
await editor.pressSequentially("E");
|
|
await editor.press("ArrowLeft");
|
|
await editor.press("Delete");
|
|
await expect(referenceOption(testPage, target.title)).toBeVisible({ timeout: 10_000 });
|
|
await editor.pressSequentially("E");
|
|
await editor.press("Escape");
|
|
await expect(menu).toHaveCount(0);
|
|
await expect(editor).toBeFocused();
|
|
await editor.pressSequentially(" continued");
|
|
await expect(menu).toHaveCount(0);
|
|
await expect(editor).toHaveText(
|
|
/#2730 explicit profile overridden seems an issue #E2E continued/,
|
|
);
|
|
|
|
await editor.pressSequentially(" #Late");
|
|
await expect(referenceOption(testPage, target.title)).toBeVisible({ timeout: 10_000 });
|
|
await testPage.locator("body").click({ position: { x: 4, y: 4 } });
|
|
await expect(menu).toHaveCount(0);
|
|
});
|
|
});
|