tests/e2e/control-sidebar-archive-races.spec.ts
Rama main · réplica de lectura del repositorio original.
import { expect, test, type Page } from "@playwright/test";
import { setupControlSidebar } from "./helpers/control-sidebar-fixture";
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
async function archiveSelected(page: Page) {
await page.getByRole("button", { name: "More options" }).click();
await page.getByRole("menuitem", { name: "Archive", exact: true }).click();
}
for (const height of [390, 320]) {
test(`landscape notifications leave the real composer and Stop usable at ${height}px`, async ({
page,
}, testInfo) => {
const held = deferred();
const { sidebar } = await setupControlSidebar(page);
await page.route("**/api/control/chat", async (route) => {
await held.promise;
await route
.fulfill({
status: 200,
headers: {
"content-type": "text/event-stream",
"x-vercel-ai-ui-message-stream": "v1",
},
body: 'data: {"type":"start"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n',
})
.catch(() => undefined);
});
try {
await sidebar
.getByRole("button", { name: "Actions for acme/other", exact: true })
.click();
await page
.getByRole("menuitem", { name: "Archive chats", exact: true })
.click();
const notification = page
.locator('[data-variant="info"]')
.filter({ hasText: "1 chat archived" });
await expect(notification).toBeVisible();
await page.setViewportSize({ width: 844, height });
const composer = page.locator("#control-composer");
await composer.fill("Keep the real controls available.");
await composer.press("Enter");
const stop = page.getByRole("button", { name: "Stop", exact: true });
await expect(stop).toBeVisible();
await expect(
page.locator('[data-slot="toast-viewport"]')
).toHaveAttribute("data-compact", "");
await page.screenshot({
path: testInfo.outputPath(`real-controls-${height}.png`),
});
const viewport = await page
.locator('[data-slot="toast-viewport"]')
.boundingBox();
const close = await notification
.getByRole("button", { name: "Dismiss notification" })
.boundingBox();
const input = await composer.boundingBox();
const stopBox = await stop.boundingBox();
const context = await page
.getByLabel("Context usage", { exact: true })
.boundingBox();
expect(context!.x + context!.width).toBeLessThanOrEqual(stopBox!.x);
expect(context!.y + context!.height).toBeLessThanOrEqual(height);
expect(close!.height).toBeGreaterThanOrEqual(44);
expect(close!.y).toBeGreaterThanOrEqual(viewport!.y);
expect(close!.y + close!.height).toBeLessThanOrEqual(
viewport!.y + viewport!.height
);
expect(viewport!.y + viewport!.height).toBeLessThan(input!.y);
expect(
await stop.evaluate((button) => {
const box = button.getBoundingClientRect();
return button.contains(
document.elementFromPoint(
box.x + box.width / 2,
box.y + box.height / 2
)
);
})
).toBe(true);
await stop.click();
} finally {
held.resolve();
}
});
}
test("a failed archive releases the composer and preserves its draft", async ({
page,
}) => {
const requested = deferred();
const response = deferred();
const { failArchives } = await setupControlSidebar(page, {
beforeUpdate: async () => {
requested.resolve();
await response.promise;
},
});
failArchives();
try {
const composer = page.getByPlaceholder(
"Ask for follow-up changes or attach images"
);
await composer.fill("Keep this draft after failure");
await archiveSelected(page);
await requested.promise;
await expect(composer).toBeDisabled();
response.resolve();
await expect(
page.getByText("Could not archive 1 chat. Try again.", { exact: true })
).toBeVisible();
await expect(composer).toBeEnabled();
await expect(composer).toHaveValue("Keep this draft after failure");
await page.route("**/api/control/chat", (route) =>
route.fulfill({
status: 200,
headers: {
"content-type": "text/event-stream",
"x-vercel-ai-ui-message-stream": "v1",
},
body: 'data: {"type":"start"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n',
})
);
const sent = page.waitForRequest((request) =>
request.url().endsWith("/api/control/chat")
);
await composer.press("Enter");
expect((await sent).postData()).toContain("Keep this draft after failure");
} finally {
response.resolve();
}
});
test("an archive reserves its chat until the delayed server response arrives", async ({
page,
}) => {
const archived = deferred();
const response = deferred();
const { sidebar } = await setupControlSidebar(page, {
updated: async (session) => {
if (session.id === "chat-0" && session.archived) {
archived.resolve();
await response.promise;
}
},
});
const submitted: string[] = [];
await page.route("**/api/control/chat", async (route) => {
submitted.push(route.request().postData() ?? "");
await route.fulfill({ status: 500, body: "Unexpected submission" });
});
try {
const composer = page.getByPlaceholder(
"Ask for follow-up changes or attach images"
);
await composer.fill("A reply must not start during archive");
await archiveSelected(page);
await archived.promise;
await expect(
page.getByText("Archive in progress", { exact: true })
).toBeVisible();
await expect(composer).toBeDisabled();
await expect(
page.getByRole("button", { name: "Send", exact: true })
).toBeDisabled();
await composer.dispatchEvent("keydown", {
key: "Enter",
code: "Enter",
bubbles: true,
});
expect(submitted).toEqual([]);
response.resolve();
await expect(
page.getByText("1 chat archived", { exact: true })
).toBeVisible();
expect(submitted).toEqual([]);
await sidebar.getByRole("button", { name: /^Fix 6 / }).click();
await expect(
page.getByPlaceholder("Ask for follow-up changes or attach images")
).toBeEnabled();
} finally {
response.resolve();
}
});
for (const explicitNew of [false, true])
for (const mutation of ["archive", "restore"] as const) {
test(`${mutation} completion preserves an unrelated pending selection${explicitNew ? " and a later explicit new chat" : ""}`, async ({
page,
}) => {
const selecting = deferred();
const response = deferred();
let holdSelection = false;
const { sidebar } = await setupControlSidebar(page, {
selected: async (session) => {
if (holdSelection && session.id === "other") {
selecting.resolve();
await response.promise;
}
},
});
try {
if (mutation === "restore") {
await archiveSelected(page);
await expect(
page.getByText("1 chat archived", { exact: true })
).toBeVisible();
}
holdSelection = true;
await sidebar
.getByRole("button", { name: /^Another project / })
.click();
await selecting.promise;
if (mutation === "archive") {
await archiveSelected(page);
await expect(
page.getByText("1 chat archived", { exact: true })
).toBeVisible();
} else {
await sidebar
.getByRole("button", { name: "Archived chats", exact: true })
.click();
const restore = sidebar.getByRole("button", {
name: "Restore Zebra investigation",
});
await restore.click();
await expect(restore).toHaveCount(0);
}
if (explicitNew && mutation === "restore")
await sidebar
.getByRole("button", { name: "Back to chats", exact: true })
.click();
if (explicitNew)
await sidebar
.getByRole("button", { name: "New session", exact: true })
.click();
response.resolve();
await expect(page).toHaveURL(/mission=other/);
await (explicitNew
? expect(
page.getByRole("button", { name: "Start mission" })
).toBeVisible()
: expect(
page.getByPlaceholder(
"Ask for follow-up changes or attach images"
)
).toBeEnabled());
} finally {
response.resolve();
}
});
}
test("Undo waits for another archive instead of discarding the restore", async ({
page,
}) => {
const archiving = deferred();
const response = deferred();
const { sidebar } = await setupControlSidebar(page, {
updated: async (session) => {
if (session.id === "chat-6" && session.archived) {
archiving.resolve();
await response.promise;
}
},
});
try {
await archiveSelected(page);
await expect(
page.getByText("1 chat archived", { exact: true })
).toBeVisible();
await sidebar.getByRole("button", { name: /^Fix 6 / }).click();
// A visible Undo must not cover the menu needed for the next action.
expect(
await page
.getByRole("button", { name: "More options" })
.evaluate((button) => {
const bounds = button.getBoundingClientRect();
return button.contains(
document.elementFromPoint(
bounds.x + bounds.width / 2,
bounds.y + bounds.height / 2
)
);
})
).toBe(true);
await archiveSelected(page);
await archiving.promise;
await page.getByRole("button", { name: "Undo", exact: true }).click();
response.resolve();
await expect(
sidebar.getByRole("button", { name: /^Zebra investigation/ })
).toBeVisible();
await expect(sidebar.getByRole("button", { name: /^Fix 6 / })).toHaveCount(
0
);
} finally {
response.resolve();
}
});
test("Undo preserves older archives while the archived list is loading", async ({
page,
}) => {
const requested = deferred();
const response = deferred();
let archiveLoads = 0;
const { sidebar, sessions } = await setupControlSidebar(page, {
listed: async (archived) => {
if (archived && archiveLoads++ === 0) {
requested.resolve();
await response.promise;
}
},
});
sessions.push({
...sessions[0],
id: "older",
title: "Older archived chat",
archived: true,
});
try {
await archiveSelected(page);
await expect(
page.getByText("1 chat archived", { exact: true })
).toBeVisible();
await sidebar
.getByRole("button", { name: "Archived chats", exact: true })
.click();
await requested.promise;
await page.getByRole("button", { name: "Undo", exact: true }).click();
const older = sidebar.getByRole("button", {
name: "Restore Older archived chat",
});
await expect(older).toBeVisible();
await expect(
sidebar.getByRole("button", { name: "Restore Zebra investigation" })
).toHaveCount(0);
const initialLoad = page.waitForResponse((result) =>
result.url().includes("archived=true")
);
response.resolve();
await initialLoad;
await expect(older).toBeVisible();
} finally {
response.resolve();
}
});
test("a late selection response cannot reopen a chat that was archived", async ({
page,
}) => {
const selecting = deferred();
const response = deferred();
const { sidebar } = await setupControlSidebar(page, {
selected: async (session) => {
if (session.id === "chat-6") {
selecting.resolve();
await response.promise;
}
},
});
try {
await sidebar.getByRole("button", { name: /^Fix 6 / }).click();
await selecting.promise;
await sidebar
.getByRole("button", { name: "Actions for acme/widgets" })
.click();
await page
.getByRole("menuitem", { name: "Archive chats", exact: true })
.click();
await expect(
page.getByText("7 chats archived", { exact: true })
).toBeVisible();
const completed = page.waitForResponse((response) =>
response.url().includes("sessions?id=chat-6")
);
response.resolve();
await completed;
await expect(page).toHaveURL(/\/control$/);
await expect(
sidebar.getByRole("button", { name: "Actions for acme/widgets" })
).toHaveCount(0);
} finally {
response.resolve();
}
});