256 lines
10 KiB
TypeScript
256 lines
10 KiB
TypeScript
import { test, expect } from "../../fixtures/test-base";
|
|
import { SessionPage } from "../../pages/session-page";
|
|
|
|
/**
|
|
* Verifies the chat-input model selector's failure path:
|
|
* when the backend rejects a model change, the UI shows a toast carrying the
|
|
* backend error message and the trigger label reverts to the previous model.
|
|
*
|
|
* mock-agent exposes "model" as a config option, so changing the model goes
|
|
* through POST /set-config-option. Agents without a model config option use
|
|
* POST /set-model — both routes are stubbed for resilience.
|
|
*/
|
|
test.describe("Chat model selector — RPC failure", () => {
|
|
test("shows error toast and reverts trigger label when model change fails", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Model Selector Error Test",
|
|
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: 30_000 });
|
|
|
|
const trigger = testPage.getByRole("button", { name: "Session model settings" });
|
|
await expect(trigger).toBeVisible({ timeout: 15_000 });
|
|
// mock-agent ships with model="mock-fast" + effort="medium", so the
|
|
// composite trigger label starts as "Mock Fast / Medium".
|
|
await expect(trigger).toContainText("Mock Fast", { timeout: 15_000 });
|
|
|
|
const backendErrorMessage = "mock backend rejected model change";
|
|
const fail = (route: import("@playwright/test").Route) =>
|
|
route.fulfill({
|
|
status: 500,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: backendErrorMessage }),
|
|
});
|
|
await testPage.route("**/set-model", fail);
|
|
await testPage.route("**/set-config-option", fail);
|
|
|
|
await trigger.click();
|
|
const smartRow = testPage.getByRole("option", { name: /Mock Smart/ });
|
|
await expect(smartRow).toBeVisible({ timeout: 5_000 });
|
|
await smartRow.click();
|
|
|
|
const toast = testPage
|
|
.getByTestId("toast-message")
|
|
.filter({ hasText: "Failed to change model" });
|
|
await expect(toast).toBeVisible({ timeout: 5_000 });
|
|
await expect(toast).toContainText(backendErrorMessage);
|
|
|
|
// After revert the trigger label should match the original (model + extras).
|
|
await expect(trigger).toContainText("Mock Fast", { timeout: 5_000 });
|
|
await expect(trigger).not.toContainText("Mock Smart");
|
|
});
|
|
|
|
test("stale failure does not revert a newer successful change", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Model Selector Race Test",
|
|
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: 30_000 });
|
|
|
|
const trigger = testPage.getByRole("button", { name: "Session model settings" });
|
|
await expect(trigger).toContainText("Mock Fast", { timeout: 15_000 });
|
|
|
|
// Hold the first config-option request open so we can fire a second one
|
|
// before the first settles. Resolve the first as a 500 after the second
|
|
// has already been issued — the stale rejection must NOT clobber the
|
|
// newer optimistic state.
|
|
let releaseFirst: (() => void) | null = null;
|
|
const firstHeld = new Promise<void>((resolve) => {
|
|
releaseFirst = resolve;
|
|
});
|
|
// Resolves once the first (stale) request has fully completed its
|
|
// route.fulfill, so the test can deterministically await propagation
|
|
// instead of using a fixed sleep.
|
|
let firstSettled: (() => void) | null = null;
|
|
const firstSettledPromise = new Promise<void>((resolve) => {
|
|
firstSettled = resolve;
|
|
});
|
|
let callCount = 0;
|
|
await testPage.route("**/set-config-option", async (route) => {
|
|
callCount += 1;
|
|
if (callCount === 1) {
|
|
await firstHeld;
|
|
await route.fulfill({
|
|
status: 500,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "stale failure" }),
|
|
});
|
|
firstSettled?.();
|
|
return;
|
|
}
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ ok: true }),
|
|
});
|
|
});
|
|
|
|
await trigger.click();
|
|
await testPage.getByRole("option", { name: /Mock Smart/ }).click();
|
|
// Re-open and pick again — second request will succeed. mock-agent only
|
|
// ships two models (Mock Fast / Mock Smart), so we go back to Mock Fast.
|
|
await trigger.click();
|
|
await testPage.getByRole("option", { name: /Mock Fast/ }).click();
|
|
|
|
// Now release the first (stale) request — its 500 rejection should be
|
|
// swallowed (no toast).
|
|
releaseFirst?.();
|
|
await firstSettledPromise;
|
|
await expect(testPage.getByTestId("toast-message")).toHaveCount(0);
|
|
// Trigger should still reflect the newer (successful) selection.
|
|
await expect(trigger).toContainText("Mock Fast", { timeout: 5_000 });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Verifies that a model change selected via the chat-input model selector
|
|
* survives a full page reload. The regression this guards against: the
|
|
* SetConfigOption RPC path used by mock-agent did not emit the session_models
|
|
* convergence event, so the orchestrator never persisted the new model to the
|
|
* session snapshot and SSR re-served the pre-change model on reload.
|
|
*/
|
|
test.describe("Chat model selector — persistence", () => {
|
|
test("model change persists across page reload", async ({ testPage, apiClient, seedData }) => {
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Model Selector Persistence Test",
|
|
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: 30_000 });
|
|
|
|
const trigger = testPage.getByRole("button", { name: "Session model settings" });
|
|
await expect(trigger).toContainText("Mock Fast", { timeout: 15_000 });
|
|
|
|
// Switch from Mock Fast to Mock Smart. mock-agent routes model changes
|
|
// through POST /set-config-option — the same path the persistence bug
|
|
// lived on (SetConfigOption did not emit the session_models convergence
|
|
// event, so the orchestrator never wrote the new model to the DB).
|
|
await trigger.click();
|
|
await testPage.getByRole("option", { name: /Mock Smart/ }).click();
|
|
await expect(trigger).toContainText("Mock Smart", { timeout: 5_000 });
|
|
|
|
await testPage.reload();
|
|
await session.waitForLoad();
|
|
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
|
|
|
// After reload the trigger label comes from SSR-hydrated session state.
|
|
// If SetConfigOption fails to emit the convergence event, the orchestrator
|
|
// never persists the change and SSR re-serves "Mock Fast" — this assertion
|
|
// is the regression guard.
|
|
await expect(trigger).toContainText("Mock Smart", { timeout: 15_000 });
|
|
await expect(trigger).not.toContainText("Mock Fast");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Verifies the chat-input model selector's popover stays open after picking a
|
|
* model when extra config options (reasoning effort, thought level, …) are
|
|
* present on the same popover. Picking a model is rarely the last thing a user
|
|
* wants to do when there are other knobs in the same surface — they typically
|
|
* want to adjust effort too. Auto-closing on model select forces a second open.
|
|
*
|
|
* mock-agent ships with model + effort config options, so the popover renders
|
|
* the Effort section alongside the model list — exactly the layout this guard
|
|
* protects.
|
|
*/
|
|
test.describe("Chat model selector — popover open/close behavior", () => {
|
|
test("stays open after picking a model when extra config options exist", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Model Selector Stay Open Test",
|
|
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: 30_000 });
|
|
|
|
const trigger = testPage.getByRole("button", { name: "Session model settings" });
|
|
await expect(trigger).toContainText("Mock Fast", { timeout: 15_000 });
|
|
|
|
await trigger.click();
|
|
// The effort config section is the rendered marker that the popover
|
|
// content is mounted — it sits below the model list inside the same
|
|
// PopoverContent. Selecting by testid mirrors the suite-wide convention
|
|
// (apps/web/e2e/README.md).
|
|
const effortSection = testPage.getByTestId("config-option-section-effort");
|
|
await expect(effortSection).toBeVisible({ timeout: 5_000 });
|
|
|
|
await testPage.getByRole("option", { name: /Mock Smart/ }).click();
|
|
|
|
// The trigger label updates optimistically — wait for that so we know the
|
|
// selection round-tripped through the handler.
|
|
await expect(trigger).toContainText("Mock Smart", { timeout: 5_000 });
|
|
|
|
// Popover must still be open so the user can also pick an effort level
|
|
// without re-opening. The effort section is rendered only when the
|
|
// PopoverContent is mounted (Radix unmounts it on close).
|
|
await expect(effortSection).toBeVisible();
|
|
});
|
|
});
|