Files
paseo/packages/app/e2e/support/helpers/agent-profiles.ts
T
Mohamed BoudraandGitHub 9c195feb9b Keep agent profile settings valid across providers (#3331)
* fix(app): keep profile modes scoped to providers

Applying a profile through sequential field setters allowed a provider change
to race mode persistence, poisoning modeless provider preferences with another
provider's mode. Apply profiles as one form transition and heal invalid
provider-scoped mode preferences when profiles are used.

* test(app): cover profile features across providers
2026-08-13 18:48:02 +02:00

568 lines
22 KiB
TypeScript

import { expect, type Locator, type Page } from "@playwright/test";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { gotoAppShell, openSettings } from "./app";
import { connectDaemonClient } from "./daemon-client-loader";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { openSettingsHost } from "./settings";
// ─── Daemon-side seeding ───────────────────────────────────────────────────
interface AgentProfilesDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
getDaemonConfig(): Promise<{ config: { agentProfiles?: AgentProfile[] } }>;
patchDaemonConfig(config: {
agentProfiles?: AgentProfile[];
providers?: Record<string, Record<string, unknown>>;
removeProviders?: string[];
}): Promise<unknown>;
}
export interface HostSeed {
/** Puts the host's config back the way the test found it and drops the client. */
restore(): Promise<void>;
}
async function connectAgentProfilesClient(): Promise<AgentProfilesDaemonClient> {
return connectDaemonClient<AgentProfilesDaemonClient>({ clientIdPrefix: "agent-profiles-e2e" });
}
/**
* Writes profiles into host config directly. Journeys that are *about* the
* settings UI create theirs through it; every other journey starts from a host
* that already has them.
*/
export async function seedAgentProfiles(profiles: AgentProfile[]): Promise<HostSeed> {
const client = await connectAgentProfilesClient();
const previous = (await client.getDaemonConfig()).config.agentProfiles ?? [];
await client.patchDaemonConfig({ agentProfiles: profiles });
return {
async restore() {
await client.patchDaemonConfig({ agentProfiles: previous }).catch(() => undefined);
await client.close().catch(() => undefined);
},
};
}
export async function stageLegacyFavoritesForHostMigration(
page: Page,
favorites: Array<{ provider: string; modelId: string }>,
): Promise<void> {
const serverId = getServerId();
await gotoAppShell(page);
await page.evaluate(
({ favorites: storedFavorites, serverId: hostId }) => {
const preferencesKey = "@paseo:create-agent-preferences";
const raw = localStorage.getItem(preferencesKey);
const preferences = raw ? JSON.parse(raw) : {};
localStorage.setItem(
preferencesKey,
JSON.stringify({ ...preferences, favoriteModels: storedFavorites }),
);
localStorage.removeItem(`@paseo:legacy-favorites-to-agent-profiles:v1:${hostId}`);
// Preserve this upgrade-shaped storage across the fixture's next-page
// default seed; a reload is the startup boundary under test.
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
if (nonce) {
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
}
},
{ favorites, serverId },
);
await page.reload();
}
export async function expectHostAgentProfiles(expected: AgentProfile[]): Promise<void> {
const client = await connectAgentProfilesClient();
try {
await expect
.poll(async () => (await client.getDaemonConfig()).config.agentProfiles ?? [], {
timeout: 30_000,
})
.toEqual(expected);
} finally {
await client.close().catch(() => undefined);
}
}
export interface SeededProviderModel {
id: string;
label: string;
description: string;
}
/**
* Registers a second provider that reports a fixed catalog. The E2E daemon has
* exactly one provider with models (`mock`), so nothing cross-provider can be
* proven without one more.
*
* `extends: "mock"` is rejected — the config validator only accepts the six
* shipped providers plus `acp`. Extending `claude` with a replacement `models`
* list is what avoids running anything: replacement models skip catalog
* discovery, and Claude's static modes mean the provider never spawns. The
* command only has to resolve for the availability probe, hence `node`.
* Providers with dynamic catalogs can pass a small RPC fixture as `command`.
*/
export async function seedModelProvider(input: {
id: string;
label: string;
models: SeededProviderModel[];
extends?: "claude" | "pi";
command?: string[];
}): Promise<HostSeed> {
const client = await connectAgentProfilesClient();
await client.patchDaemonConfig({
providers: {
[input.id]: {
extends: input.extends ?? "claude",
label: input.label,
description: `${input.label} test provider`,
enabled: true,
command: input.command ?? ["node"],
models: input.models,
},
},
});
return {
async restore() {
await client.patchDaemonConfig({ removeProviders: [input.id] }).catch(() => undefined);
await client.close().catch(() => undefined);
},
};
}
// ─── Settings: navigation ──────────────────────────────────────────────────
/** Reach agent profiles through the same visible Settings path a person uses. */
export async function openAgentProfileSettings(page: Page): Promise<void> {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await page.getByRole("button", { name: "Agents", exact: true }).click();
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "agents"));
await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 });
}
export async function expectNoAgentProfiles(page: Page): Promise<void> {
const card = page.getByTestId("agent-profiles-card");
await expect(card.getByTestId("agent-profiles-empty")).toBeVisible();
await expect(card.getByRole("button", { name: "New profile", exact: true })).toBeVisible();
}
// ─── Settings: the profile list ────────────────────────────────────────────
export function agentProfileRow(page: Page, name: string): Locator {
return page.locator('[data-testid^="agent-profile-row-"]').filter({ hasText: name });
}
export async function expectAgentProfile(
page: Page,
expected: { name: string; tags: string[]; notes?: string },
): Promise<void> {
const row = agentProfileRow(page, expected.name);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText(expected.tags.join(" · "), { exact: true })).toBeVisible();
if (expected.notes !== undefined) {
await expect(row.getByText(expected.notes, { exact: true })).toBeVisible();
}
}
export async function expectAgentProfileTagsGone(
page: Page,
input: { name: string; tags: string[] },
): Promise<void> {
const row = agentProfileRow(page, input.name);
for (const tag of input.tags) {
await expect(row.getByText(tag, { exact: true })).toHaveCount(0);
}
}
/** Order is what the user sees, so it is measured on screen rather than in the DOM. */
export async function expectAgentProfileOrder(page: Page, names: string[]): Promise<void> {
await expect
.poll(
async () => {
const measured = await Promise.all(
names.map(async (name) => ({ name, top: await boxTop(agentProfileRow(page, name)) })),
);
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.name);
},
{ timeout: 15_000 },
)
.toEqual(names);
}
export async function moveAgentProfileUp(page: Page, name: string): Promise<void> {
await agentProfileRow(page, name).getByRole("button", { name: "Move up", exact: true }).click();
}
export async function moveAgentProfileDown(page: Page, name: string): Promise<void> {
await agentProfileRow(page, name).getByRole("button", { name: "Move down", exact: true }).click();
}
/**
* The remove confirmation is `window.confirm` on browser web, so the dialog is
* consumed here — and its text is asserted, since naming the profile is the
* whole point of confirming.
*/
export async function removeAgentProfile(page: Page, name: string): Promise<void> {
const dialogMessage = page.waitForEvent("dialog").then(async (dialog) => {
const message = dialog.message();
await dialog.accept();
return message;
});
await agentProfileRow(page, name).getByRole("button", { name: "Remove", exact: true }).click();
expect(await dialogMessage).toContain(`Remove "${name}"?`);
await expect(agentProfileRow(page, name)).toHaveCount(0, { timeout: 30_000 });
}
// ─── Settings: the edit modal ──────────────────────────────────────────────
export interface AgentProfileDraft {
name?: string;
provider?: string;
model?: string;
thinking?: string;
mode?: string;
notes?: string;
}
function editModal(page: Page): Locator {
return page.getByTestId("agent-profile-edit-modal");
}
async function chooseSelectFieldOption(
page: Page,
input: { field: "provider" | "model" | "mode" | "thinking"; option: string },
): Promise<void> {
await editModal(page).getByTestId(`agent-profile-${input.field}-trigger`).click();
const option = page
.getByTestId("combobox-desktop-container")
.getByRole("button", { name: input.option, exact: true });
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
await expect(
page.getByRole("button", { name: `${capitalize(input.field)} (${input.option})` }),
).toBeVisible({ timeout: 30_000 });
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promise<void> {
const modal = editModal(page);
if (draft.name !== undefined) {
await modal.getByRole("textbox", { name: "Name", exact: true }).fill(draft.name);
}
// Provider first: it resets every provider-scoped field below it.
if (draft.provider !== undefined) {
await chooseSelectFieldOption(page, { field: "provider", option: draft.provider });
}
// Thinking after model: thinking levels hang off the model, and switching the
// model drops a level the new one does not offer.
if (draft.model !== undefined) {
await chooseSelectFieldOption(page, { field: "model", option: draft.model });
}
if (draft.thinking !== undefined) {
await chooseSelectFieldOption(page, { field: "thinking", option: draft.thinking });
}
if (draft.mode !== undefined) {
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
}
if (draft.notes !== undefined) {
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
}
}
async function saveAgentProfile(page: Page): Promise<void> {
await editModal(page).getByRole("button", { name: "Save", exact: true }).click();
await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 });
}
export async function createAgentProfile(page: Page, draft: AgentProfileDraft): Promise<void> {
await page.getByRole("button", { name: "Add agent profile", exact: true }).click();
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
await fillAgentProfileForm(page, draft);
await saveAgentProfile(page);
}
export async function createAgentProfileFromEmptyState(
page: Page,
draft: AgentProfileDraft,
): Promise<void> {
await page
.getByTestId("agent-profiles-card")
.getByRole("button", { name: "New profile", exact: true })
.click();
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
await fillAgentProfileForm(page, draft);
await saveAgentProfile(page);
}
export async function editAgentProfile(
page: Page,
name: string,
changes: AgentProfileDraft,
): Promise<void> {
await agentProfileRow(page, name)
.getByRole("button", { name: "Edit profile", exact: true })
.click();
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
await fillAgentProfileForm(page, changes);
await saveAgentProfile(page);
}
/** Opens the modal for a profile purely to read back what it stored. */
export async function expectAgentProfileForm(
page: Page,
name: string,
expected: { provider?: string; model?: string; mode?: string; notes?: string },
): Promise<void> {
await agentProfileRow(page, name)
.getByRole("button", { name: "Edit profile", exact: true })
.click();
const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
for (const [field, value] of Object.entries(expected)) {
if (field === "notes") {
await expect(
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
).toHaveValue(value);
continue;
}
await expect(
modal.getByRole("button", { name: `${capitalize(field)} (${value})` }),
).toBeVisible({ timeout: 30_000 });
}
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 });
}
// ─── The model picker ──────────────────────────────────────────────────────
/** Desktop web renders the model browser inside the combobox popover. */
function pickerViewport(page: Page): Locator {
return page.getByTestId("combobox-desktop-container");
}
export async function openModelPicker(page: Page): Promise<void> {
await page.getByTestId("combined-model-selector").filter({ visible: true }).first().click();
await expect(pickerViewport(page)).toBeVisible({ timeout: 30_000 });
}
export async function closeModelPicker(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 });
}
export function profilePickerRow(page: Page, name: string): Locator {
return pickerViewport(page)
.locator('[data-testid^="model-profile-row-"]')
.filter({ hasText: name });
}
/**
* Profiles are pinned above the provider list, so "pinned" is an ordering claim:
* the profile row's top edge sits above every provider row's.
*/
export async function expectProfilePinnedAboveProviders(
page: Page,
input: { name: string; summary: string },
): Promise<void> {
const viewport = pickerViewport(page);
await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 });
const row = profilePickerRow(page, input.name);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText(input.summary, { exact: true })).toBeVisible();
const profileTop = await boxTop(row);
const providerRows = viewport.locator('[data-testid^="model-provider-"]');
await expect(providerRows.first()).toBeVisible();
const providerCount = await providerRows.count();
for (let index = 0; index < providerCount; index += 1) {
expect(profileTop).toBeLessThan(await boxTop(providerRows.nth(index)));
}
}
export async function expectProfileVisibleForProvider(
page: Page,
input: { name: string; summary: string },
): Promise<void> {
const viewport = pickerViewport(page);
await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 });
const row = profilePickerRow(page, input.name);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText(input.summary, { exact: true })).toBeVisible();
}
export async function expectProfileEditIsPencilOnly(page: Page): Promise<void> {
const viewport = pickerViewport(page);
await expect(
viewport.getByRole("button", { name: "Edit agent profiles", exact: true }),
).toBeVisible();
await expect(viewport.getByText("Edit", { exact: true })).toHaveCount(0);
}
export async function expectAgentProfilesEmptyPrompt(page: Page): Promise<void> {
const viewport = pickerViewport(page);
await expect(viewport.getByText("Profiles", { exact: true })).toHaveCount(0);
await expect(
viewport.getByRole("button", { name: "Edit agent profiles", exact: true }),
).toHaveCount(0);
await expect(viewport.getByRole("button", { name: "Create profile", exact: true })).toBeVisible({
timeout: 30_000,
});
}
export async function expectProfileEditTooltip(page: Page): Promise<void> {
const edit = pickerViewport(page).getByRole("button", {
name: "Edit agent profiles",
exact: true,
});
await edit.hover();
await expect(page.getByText("Edit agent profiles", { exact: true })).toBeVisible();
}
export async function openAgentProfilesFromEmptyPrompt(page: Page): Promise<void> {
await pickerViewport(page).getByRole("button", { name: "Create profile", exact: true }).click();
await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 });
}
async function boxTop(locator: Locator): Promise<number> {
const box = await locator.boundingBox();
if (!box) {
throw new Error("Expected a laid-out element to measure");
}
return box.y;
}
export async function applyProfileFromPicker(page: Page, name: string): Promise<void> {
await profilePickerRow(page, name).click();
await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 });
}
/**
* Applying a profile materializes it and forgets it: nothing in the root view
* claims selection. Model rows do carry `aria-selected`, so a count of zero here
* is the absence of a checkmark, not the absence of the attribute everywhere.
*/
export async function expectNothingSelectedInPickerRoot(page: Page): Promise<void> {
await expect(pickerViewport(page).locator("[aria-selected]")).toHaveCount(0);
}
export async function expectAgentProfilesEditShortcut(page: Page): Promise<void> {
await expect(
pickerViewport(page).getByRole("button", { name: "Edit agent profiles", exact: true }),
).toBeVisible({ timeout: 30_000 });
}
export async function drillIntoProvider(page: Page, providerId: string): Promise<void> {
await pickerViewport(page).getByTestId(`model-provider-${providerId}`).click();
}
export async function expectModelRowSelected(
page: Page,
input: { provider: string; modelId: string },
): Promise<void> {
await expect(
pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`),
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
}
// ─── Cross-provider model search ───────────────────────────────────────────
export async function searchAllModels(page: Page, query: string): Promise<void> {
const input = page.getByTestId("model-search-all-input");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(query);
}
export async function expectModelSearchResult(
page: Page,
expected: { provider: string; modelId: string; providerLabel: string; modelLabel: string },
): Promise<void> {
const row = pickerViewport(page).getByTestId(
`model-row-${expected.provider}-${expected.modelId}`,
);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText(expected.modelLabel, { exact: true })).toBeVisible();
await expect(
row.getByText(new RegExp(`^${escapeForRegex(expected.providerLabel)}\\b`)),
).toBeVisible();
}
function escapeForRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Search results replace the whole root view, pinned profiles included. */
export async function expectPinnedProfilesHidden(page: Page): Promise<void> {
await expect(pickerViewport(page).locator('[data-testid^="model-profile-row-"]')).toHaveCount(0);
}
export async function expectModelSearchEmptyState(page: Page, query: string): Promise<void> {
const empty = page.getByTestId("model-search-empty");
await expect(empty).toBeVisible({ timeout: 30_000 });
await expect(empty.getByText(`No models match "${query}"`, { exact: true })).toBeVisible();
}
export async function readModelPickerHeight(page: Page): Promise<number> {
const box = await pickerViewport(page).boundingBox();
if (!box) {
throw new Error("Expected the model picker to be laid out");
}
return box.height;
}
export async function readModelPickerWidth(page: Page): Promise<number> {
const box = await pickerViewport(page).boundingBox();
if (!box) {
throw new Error("Expected the model picker to be laid out");
}
return box.width;
}
export async function expectModelPickerHeight(page: Page, expectedHeight: number): Promise<void> {
await expect.poll(() => readModelPickerHeight(page)).toBe(expectedHeight);
}
export async function expectModelPickerWidth(page: Page, expectedWidth: number): Promise<void> {
await expect.poll(() => readModelPickerWidth(page)).toBe(expectedWidth);
}
export async function expectSearchResultsVirtualized(
page: Page,
input: { provider: string; total: number },
): Promise<void> {
const mountedRows = pickerViewport(page).locator(`[data-testid^="model-row-${input.provider}-"]`);
await expect.poll(() => mountedRows.count()).toBeGreaterThan(0);
expect(await mountedRows.count()).toBeLessThan(input.total);
}
// ─── Composer agent controls ───────────────────────────────────────────────
export async function expectComposerModel(page: Page, modelLabel: string): Promise<void> {
await expect(
page.getByRole("button", { name: `Select model (${modelLabel})`, exact: true }),
).toBeVisible({ timeout: 30_000 });
}
export async function expectComposerMode(page: Page, modeLabel: string): Promise<void> {
await expect(
page.getByRole("button", { name: `Select agent mode (${modeLabel})`, exact: true }).first(),
).toBeVisible({ timeout: 30_000 });
}
/** The composer never shows the profile's name — a profile is not a selection. */
export async function expectComposerDoesNotName(page: Page, profileName: string): Promise<void> {
await expect(
page.locator('[data-testid="message-input-root"]:visible').getByText(profileName),
).toHaveCount(0);
}