3c8ff8ab27
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
213 lines
8.5 KiB
TypeScript
213 lines
8.5 KiB
TypeScript
import { expect, type Locator, type Page } from "@playwright/test";
|
|
import { test } from "../../fixtures/test-base";
|
|
import type { SeedData } from "../../fixtures/test-base";
|
|
import type { ApiClient } from "../../helpers/api-client";
|
|
import { typeWhileBusy, waitForComposerQueueMode } from "../../helpers/type-while-busy";
|
|
import { SessionPage } from "../../pages/session-page";
|
|
import { registerSeparateQueueRows } from "../../helpers/message-queue-settings";
|
|
|
|
/** Fragment of dnd-kit's default `onDragOver` accessibility announcement. */
|
|
const MOVED_OVER = "was moved over droppable area";
|
|
|
|
registerSeparateQueueRows(test);
|
|
|
|
/** Create a task whose agent finishes its intro turn and becomes idle. */
|
|
async function seedIdleTask(
|
|
testPage: Page,
|
|
apiClient: ApiClient,
|
|
seedData: SeedData,
|
|
title: string,
|
|
): Promise<SessionPage> {
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
title,
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await session.waitForChatIdle({ timeout: 60_000 });
|
|
return session;
|
|
}
|
|
|
|
/** Keep the agent busy with a slow command, then queue one message per entry. */
|
|
async function queueMessagesWhileBusy(
|
|
page: Page,
|
|
editor: Locator,
|
|
messages: string[],
|
|
): Promise<void> {
|
|
const submit = page.getByTestId("submit-message-button");
|
|
for (const message of messages) {
|
|
await typeWhileBusy(page, editor, message);
|
|
await expect(submit).toBeVisible({ timeout: 5_000 });
|
|
await submit.click();
|
|
}
|
|
}
|
|
|
|
async function openQueuePanel(session: SessionPage): Promise<Locator> {
|
|
const chip = session.activeChat().getByTestId("queue-chip");
|
|
await expect(chip).toBeVisible({ timeout: 10_000 });
|
|
await chip.click();
|
|
const panel = session.activeChat().getByTestId("queued-ghost-list");
|
|
await expect(panel).toBeVisible({ timeout: 5_000 });
|
|
return panel;
|
|
}
|
|
|
|
/** Grab handle of the row whose visible text contains `text`. */
|
|
function rowHandle(panel: Locator, text: string): Locator {
|
|
const row = panel.getByTestId("queue-entry").filter({ hasText: text });
|
|
return row.locator("xpath=..").getByTestId("queue-grab-handle");
|
|
}
|
|
|
|
/**
|
|
* Assert that `contents` occupy exactly the consecutive positions starting at
|
|
* the head after removing the `/slow` keep-busy command, and that the `/slow`
|
|
* row — when present — sits at the head. This pins the global order instead of
|
|
* normalizing it away: a foreign row interleaved between the expected contents
|
|
* or a `/slow` row anywhere but the head fails.
|
|
*/
|
|
async function expectRelativeOrder(panel: Locator, ...contents: string[]): Promise<void> {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const texts = (await panel.getByTestId("queue-entry-text").allTextContents()).map((t) =>
|
|
t.replace(/\s+/g, " ").trim(),
|
|
);
|
|
const slowIndex = texts.findIndex((t) => t.startsWith("/slow"));
|
|
const withoutSlow = texts.filter((_, index) => index !== slowIndex);
|
|
return {
|
|
positions: contents.map((content) => withoutSlow.findIndex((t) => t.includes(content))),
|
|
slowHead: slowIndex === -1 || slowIndex === 0,
|
|
};
|
|
},
|
|
{ timeout: 10_000 },
|
|
)
|
|
.toEqual({ positions: contents.map((_, index) => index), slowHead: true });
|
|
}
|
|
|
|
test.describe("Queue reorder", () => {
|
|
test.describe.configure({ retries: 1 });
|
|
|
|
test("drag-and-drop reorders queued messages and persists the order", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
const session = await seedIdleTask(testPage, apiClient, seedData, "Queue reorder drag");
|
|
await session.sendMessage("/slow 30s");
|
|
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
|
await waitForComposerQueueMode(testPage);
|
|
|
|
const editor = session.activeChat().locator(".tiptap.ProseMirror:visible").first();
|
|
await queueMessagesWhileBusy(testPage, editor, [
|
|
"reorder first",
|
|
"reorder second",
|
|
"reorder third",
|
|
]);
|
|
|
|
const panel = await openQueuePanel(session);
|
|
await expectRelativeOrder(panel, "reorder first", "reorder second", "reorder third");
|
|
|
|
// Multiple queued rows expose their dotted grab handles immediately on a
|
|
// fine pointer, so the drag affordance is discoverable without hover.
|
|
const firstHandle = rowHandle(panel, "reorder first");
|
|
await expect(firstHandle).toHaveCSS("opacity", "1", { timeout: 5_000 });
|
|
const firstContent = panel.getByTestId("queue-entry").filter({ hasText: "reorder first" });
|
|
const [handleBox, contentBox] = await Promise.all([
|
|
firstHandle.boundingBox(),
|
|
firstContent.boundingBox(),
|
|
]);
|
|
expect(handleBox).not.toBeNull();
|
|
expect(contentBox).not.toBeNull();
|
|
expect(handleBox!.width).toBeLessThanOrEqual(12);
|
|
expect(contentBox!.x).toBeGreaterThanOrEqual(handleBox!.x + handleBox!.width + 4);
|
|
|
|
// Drag the third row's handle onto the first row. Playwright's dragTo
|
|
// fires a single mousemove, which only activates dnd-kit's PointerSensor
|
|
// (distance constraint) without dispatching DragMove — the drop would
|
|
// resolve no target. Stepped moves keep the sensor's move events flowing.
|
|
const sourceBox = (await rowHandle(panel, "reorder third").boundingBox())!;
|
|
const targetBox = (await firstHandle.boundingBox())!;
|
|
await testPage.mouse.move(
|
|
sourceBox.x + sourceBox.width / 2,
|
|
sourceBox.y + sourceBox.height / 2,
|
|
);
|
|
await testPage.mouse.down();
|
|
await testPage.mouse.move(
|
|
targetBox.x + targetBox.width / 2,
|
|
targetBox.y + targetBox.height / 2,
|
|
{ steps: 12 },
|
|
);
|
|
await testPage.mouse.up();
|
|
await expectRelativeOrder(panel, "reorder third", "reorder first", "reorder second");
|
|
|
|
// The order is persisted server-side and survives a reload.
|
|
await testPage.reload();
|
|
await session.waitForLoad();
|
|
const panelAfter = await openQueuePanel(session);
|
|
await expectRelativeOrder(panelAfter, "reorder third", "reorder first", "reorder second");
|
|
});
|
|
|
|
test("keyboard reordering moves a queued message one position", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
const session = await seedIdleTask(testPage, apiClient, seedData, "Queue reorder keyboard");
|
|
await session.sendMessage("/slow 30s");
|
|
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
|
await waitForComposerQueueMode(testPage);
|
|
|
|
const editor = session.activeChat().locator(".tiptap.ProseMirror:visible").first();
|
|
await queueMessagesWhileBusy(testPage, editor, [
|
|
"reorder first",
|
|
"reorder second",
|
|
"reorder third",
|
|
]);
|
|
|
|
const panel = await openQueuePanel(session);
|
|
await expectRelativeOrder(panel, "reorder first", "reorder second");
|
|
|
|
// dnd-kit's keyboard sensor: Space starts the drag, arrows move, Space
|
|
// drops. Wait for the drag to actually start (the row dims) before moving.
|
|
const handle = rowHandle(panel, "reorder first");
|
|
const row = handle.locator("xpath=..");
|
|
await handle.focus();
|
|
await testPage.keyboard.press("Space");
|
|
await expect(row).toHaveClass(/opacity-40/, { timeout: 5_000 });
|
|
// dnd-kit announces every resolved drag target in its accessibility live
|
|
// region. That makes the two internal steps below observable rather than
|
|
// guessed at: droppable measuring after the drag starts, and target
|
|
// resolution after the arrow key.
|
|
const announcedTarget = async () =>
|
|
(await testPage.locator('[id^="DndLiveRegion"]').allTextContents()).find((text) =>
|
|
text.includes(MOVED_OVER),
|
|
) ?? "";
|
|
|
|
// Droppable measuring happens asynchronously after the drag starts; an
|
|
// arrow press before a droppable resolves computes no target. Measuring is
|
|
// done once dnd-kit announces a target for the held row.
|
|
await expect.poll(announcedTarget, { timeout: 5_000 }).toContain(MOVED_OVER);
|
|
const targetBeforeArrow = await announcedTarget();
|
|
|
|
await testPage.keyboard.press("ArrowDown");
|
|
// The arrow has resolved the next droppable once the announced target changes.
|
|
await expect.poll(announcedTarget, { timeout: 5_000 }).not.toBe(targetBeforeArrow);
|
|
|
|
await testPage.keyboard.press("Space");
|
|
await expect(row).not.toHaveClass(/opacity-40/, { timeout: 5_000 });
|
|
|
|
await expectRelativeOrder(panel, "reorder second", "reorder first");
|
|
});
|
|
});
|