refactor(e2e): drive mock-LLM test interactions through the UI (#1222)

Replace direct API calls for seeding/configuring state in mock-LLM E2E
tests with UI-driven interactions wherever possible, ensuring downstream
API calls are covered by the test.

Changes:

- mock-llm-profile-management.spec.ts: All three scenarios (active
  profile deletion, same-model identity, litellm_proxy base_url
  preservation) now create and activate profiles through the Settings →
  LLM Profiles UI instead of raw POST/activate API calls. Cleanup in
  afterAll uses the UI delete flow via exported deleteProfileIfExists.

- mock-llm-model-switch.spec.ts: The switch-target profile B is now
  created through the Settings UI (createProfileViaUI) instead of a
  raw POST to /api/profiles. Cleanup uses UI-driven deletion.

- mock-llm-skills.spec.ts: Replaced the inlined configureMockLLM()
  helper (which did a raw PATCH to /api/settings) with the UI-driven
  ensureMockLLMProfile(page) that creates and activates the profile
  through the Settings screen.

- mock-llm-acp-agent.spec.ts: afterAll cleanup now resets agent type
  back to OpenHands via the Settings → Agent UI (resetToOpenHandsAgentViaUI)
  instead of a raw PATCH to /api/settings. The local selectDropdownOption
  is removed in favor of the shared export from mock-llm-helpers.

- mock-llm-conversation.spec.ts: Removed the redundant API pre-check
  in step 3 that verified the profile was active via GET /api/profiles.
  Steps 1+2 already verified this through the UI (Active badge check).

- mock-llm-helpers.ts:
  - Extracted createProfileViaUI() from ensureMockLLMProfile() as a
    standalone exported helper for tests that need to create profiles
    without activating them.
  - Exported deleteProfileIfExists() and activateProfileViaUI() so
    tests can compose profile lifecycle operations through the UI.
  - Added selectDropdownOption() (consolidated from ACP spec's local copy).
  - Added resetToOpenHandsAgentViaUI() for UI-driven agent type reset.
  - Marked the API-based resetToOpenHandsAgent() as @deprecated.

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Rohit Malhotra
2026-06-08 15:50:50 +00:00
committed by GitHub
co-authored by openhands
parent 032f441b1c
commit e6e61b0fa6
6 changed files with 202 additions and 292 deletions
+7 -38
View File
@@ -16,7 +16,7 @@
* round-trip without any real LLM.
*/
import { test, expect, type Page } from "@playwright/test";
import { test, expect } from "@playwright/test";
import {
ACP_REPLY_TOKEN,
MOCK_ACP_COMMAND_PYTHON,
@@ -29,9 +29,10 @@ import {
getConversationIdFromURL,
waitForNonUserMessageText,
deleteConversation,
resetToOpenHandsAgent,
resetToOpenHandsAgentViaUI,
resetMockLLM,
ensureMockLLMProfile,
selectDropdownOption,
setChatInput,
BACKEND_URL,
SESSION_API_KEY,
@@ -48,34 +49,6 @@ const USER_MESSAGE = "Hello ACP agent, please reply.";
*/
const ACP_COMMAND_TEXT = `${MOCK_ACP_COMMAND_PYTHON} ${MOCK_ACP_COMMAND_SCRIPT}`;
// ── UI interaction helpers for HeroUI Autocomplete dropdowns ──────────
/**
* Select an option from a HeroUI Autocomplete dropdown (SettingsDropdownInput).
*
* HeroUI Autocomplete does NOT forward `data-testid` to the underlying
* `<input>`, so we locate the combobox by its `aria-label` (which the
* component sets to the label prop or the name prop). We then click to
* open the listbox and click the matching option.
*/
async function selectDropdownOption(
page: Page,
/** aria-label of the combobox (= the SettingsDropdownInput label text) */
comboboxLabel: string | RegExp,
/** Visible text of the option to click */
optionText: string | RegExp,
) {
const combobox = page.getByRole("combobox", { name: comboboxLabel });
await expect(combobox).toBeVisible({ timeout: 10_000 });
// Clear any existing text (so all options show) and open
await combobox.click();
await combobox.fill("");
// Wait for the option to appear and click it
const option = page.getByRole("option", { name: optionText });
await expect(option).toBeVisible({ timeout: 5_000 });
await option.click();
}
test.describe.configure({ mode: "serial" });
test.describe("mock-LLM ACP agent conversation", () => {
@@ -95,17 +68,13 @@ test.describe("mock-LLM ACP agent conversation", () => {
}
}
// Reset agent-server back to OpenHands + restore mock LLM profile
// so subsequent test suites (which expect agent_kind=openhands) are
// not affected by our ACP configuration.
try {
await resetToOpenHandsAgent(request);
} catch {
// best-effort
}
// Reset agent-server back to OpenHands via the Settings → Agent UI
// + restore mock LLM profile so subsequent test suites (which expect
// agent_kind=openhands) are not affected by our ACP configuration.
const page = await browser.newPage();
try {
await seedLocalStorage(page);
await resetToOpenHandsAgentViaUI(page);
await ensureMockLLMProfile(page);
} catch {
// best-effort
@@ -248,23 +248,8 @@ test.describe("mock-LLM agent-server conversation", () => {
// that wasn't fully consumed due to earlier failures.
await resetMockLLM(request);
// Verify the mock LLM profile is active before creating a conversation.
// Steps 1+2 configure it via the UI; this API check ensures persistence.
await test.step("verify mock-llm profile is active via API", async () => {
const profilesResp = await request.get(`${BACKEND_URL}/api/profiles`, {
headers: { "X-Session-API-Key": SESSION_API_KEY },
});
expect(profilesResp.ok(), `GET /api/profiles returned ${profilesResp.status()}`).toBe(true);
const profiles = await profilesResp.json();
const activeProfile = profiles?.active_profile;
const profileNames = profiles?.profiles ? Object.keys(profiles.profiles) : [];
expect(
activeProfile,
`Expected active_profile="${PROFILE_NAME}" but got "${activeProfile}". ` +
`Available profiles: [${profileNames.join(", ")}]. ` +
`Full response: ${JSON.stringify(profiles).slice(0, 500)}`,
).toBe(PROFILE_NAME);
});
// Steps 1+2 already configured and verified the profile via the UI
// (including the "Active" badge check). No API pre-check needed.
// Passively observe POST /api/conversations to capture the request body.
// Using page.on('request') instead of page.route() avoids conflicts with
@@ -19,11 +19,8 @@
* working under the new profile.
*/
import { test, expect, type APIRequestContext } from "@playwright/test";
import { test, expect } from "@playwright/test";
import {
BACKEND_URL,
SESSION_API_KEY,
MOCK_LLM_AGENT_URL,
seedLocalStorage,
routeSessionApiKey,
dismissAnalyticsModal,
@@ -36,62 +33,18 @@ import {
activateTrajectory,
resetMockLLM,
ensureMockLLMProfile,
createProfileViaUI,
deleteProfileIfExists,
setChatInput,
} from "./utils/mock-llm-helpers";
/** Profile B is the switch target — created via the profiles API. */
/** Profile B is the switch target — created via the Settings UI. */
const PROFILE_B_NAME = "model-switch-profile-b";
const MODEL_B = "openai/mock-model-beta";
const INITIAL_REPLY_TOKEN = "MODEL_SWITCH_INITIAL_REPLY_OK";
const POST_SWITCH_REPLY_TOKEN = "MODEL_SWITCH_POST_SWITCH_REPLY_OK";
/**
* Create (or overwrite) a named LLM profile via the agent-server profiles API.
* Deletes first so setup is idempotent across re-runs — if a previous test
* crashed before afterAll cleanup, the stale profile won't cause a 409.
*/
async function saveProfile(
request: APIRequestContext,
name: string,
model: string,
) {
// Best-effort delete so a leftover profile doesn't block creation.
await request.delete(
`${BACKEND_URL}/api/profiles/${encodeURIComponent(name)}`,
{ headers: { "X-Session-API-Key": SESSION_API_KEY } },
);
const resp = await request.post(
`${BACKEND_URL}/api/profiles/${encodeURIComponent(name)}`,
{
headers: {
"X-Session-API-Key": SESSION_API_KEY,
"Content-Type": "application/json",
},
data: {
llm: {
model,
api_key: "mock-api-key-for-testing",
base_url: MOCK_LLM_AGENT_URL,
},
include_secrets: true,
},
},
);
expect(
resp.ok(),
`POST /api/profiles/${name} returned ${resp.status()}`,
).toBe(true);
}
/** Delete a profile (best-effort cleanup). */
async function deleteProfile(request: APIRequestContext, name: string) {
await request.delete(
`${BACKEND_URL}/api/profiles/${encodeURIComponent(name)}`,
{ headers: { "X-Session-API-Key": SESSION_API_KEY } },
);
}
test.describe.configure({ mode: "serial" });
test.describe("mock-LLM /model slash command", () => {
@@ -112,12 +65,20 @@ test.describe("mock-LLM /model slash command", () => {
}
});
test.afterAll(async ({ request }) => {
// Clean up the profile we created and reset mock LLM
test.afterAll(async ({ request, browser }) => {
// Best-effort cleanup via UI
const page = await browser.newPage();
try {
await deleteProfile(request, PROFILE_B_NAME);
await seedLocalStorage(page);
await routeSessionApiKey(page);
await page.goto("/settings/llm", { waitUntil: "domcontentloaded" });
await dismissAnalyticsModal(page);
await waitForTestId(page, "add-llm-profile");
await deleteProfileIfExists(page, PROFILE_B_NAME);
} catch {
// best-effort
} finally {
await page.close();
}
try {
await resetMockLLM(request);
@@ -136,20 +97,24 @@ test.describe("mock-LLM /model slash command", () => {
// flow used by mock-llm-conversation.spec.ts.
await ensureMockLLMProfile(page);
// Create profile B as the switch target — it has a different model name
// but the same mock LLM base_url so post-switch inference still works.
await saveProfile(request, PROFILE_B_NAME, MODEL_B);
// Create profile B as the switch target through the Settings UI — it has
// a different model name but the same mock LLM base_url so post-switch
// inference still works.
await routeSessionApiKey(page);
await page.goto("/settings/llm", { waitUntil: "domcontentloaded" });
await dismissAnalyticsModal(page);
await waitForTestId(page, "add-llm-profile");
// Verify profile B was created
const profilesResp = await request.get(`${BACKEND_URL}/api/profiles`, {
headers: { "X-Session-API-Key": SESSION_API_KEY },
});
expect(profilesResp.ok()).toBe(true);
const profiles = await profilesResp.json();
const profileNames: string[] = profiles.profiles.map(
(p: { name: string }) => p.name,
);
expect(profileNames).toContain(PROFILE_B_NAME);
await deleteProfileIfExists(page, PROFILE_B_NAME);
await createProfileViaUI(page, { profileName: PROFILE_B_NAME, model: MODEL_B });
// Verify profile B appears in the list
const profileRows = page.getByTestId("profile-row");
const profileTexts = await profileRows.allTextContents();
expect(
profileTexts.some((text) => text.includes(PROFILE_B_NAME)),
`Profile "${PROFILE_B_NAME}" should appear in the list`,
).toBe(true);
// Register a trajectory with THREE entries:
// Turn 0: padding — the agent-server makes an internal LLM call
@@ -24,7 +24,7 @@
* stranded and LiteLLM reroutes requests to the wrong endpoint.
*/
import { test, expect, type APIRequestContext } from "@playwright/test";
import { test, expect } from "@playwright/test";
import {
BACKEND_URL,
SESSION_API_KEY,
@@ -41,46 +41,19 @@ import {
resetMockLLM,
setChatInput,
waitForPath,
createProfileViaUI,
deleteProfileIfExists,
activateProfileViaUI,
} from "./utils/mock-llm-helpers";
// ═══════════════════════════════════════════════════════════════════════
// Profile API helpers
// ═══════════════════════════════════════════════════════════════════════
const MOCK_MODEL = "openai/mock-test-model";
async function saveProfile(
request: APIRequestContext,
name: string,
model: string,
baseUrl: string = MOCK_LLM_AGENT_URL,
) {
await request.delete(
`${BACKEND_URL}/api/profiles/${encodeURIComponent(name)}`,
{ headers: { "X-Session-API-Key": SESSION_API_KEY } },
);
const resp = await request.post(
`${BACKEND_URL}/api/profiles/${encodeURIComponent(name)}`,
{
headers: {
"X-Session-API-Key": SESSION_API_KEY,
"Content-Type": "application/json",
},
data: {
llm: {
model,
api_key: "mock-api-key-for-testing",
base_url: baseUrl,
},
include_secrets: true,
},
},
);
expect(resp.ok(), `POST /api/profiles/${name}: ${resp.status()}`).toBe(true);
}
/**
* Read-only helper: fetch a profile's persisted config via the API.
* Used to verify that UI-driven saves persisted the expected values.
*/
async function getProfileConfig(
request: APIRequestContext,
request: import("@playwright/test").APIRequestContext,
name: string,
): Promise<Record<string, unknown>> {
const resp = await request.get(
@@ -97,24 +70,6 @@ async function getProfileConfig(
return (data.config ?? {}) as Record<string, unknown>;
}
async function activateProfile(request: APIRequestContext, name: string) {
const resp = await request.post(
`${BACKEND_URL}/api/profiles/${encodeURIComponent(name)}/activate`,
{ headers: { "X-Session-API-Key": SESSION_API_KEY } },
);
expect(
resp.ok(),
`POST /api/profiles/${name}/activate: ${resp.status()}`,
).toBe(true);
}
async function deleteProfile(request: APIRequestContext, name: string) {
await request.delete(
`${BACKEND_URL}/api/profiles/${encodeURIComponent(name)}`,
{ headers: { "X-Session-API-Key": SESSION_API_KEY } },
);
}
test.describe.configure({ mode: "serial" });
// ═══════════════════════════════════════════════════════════════════════
@@ -129,30 +84,44 @@ test.describe("active profile deletion + reconciliation", () => {
await seedLocalStorage(page);
});
test.afterAll(async ({ request }) => {
for (const name of [ACTIVE_PROFILE, INACTIVE_PROFILE]) {
try {
await deleteProfile(request, name);
} catch {
// best-effort
}
test.afterAll(async ({ browser }) => {
// Best-effort cleanup via UI
const page = await browser.newPage();
try {
await seedLocalStorage(page);
await routeSessionApiKey(page);
await page.goto("/settings/llm", { waitUntil: "domcontentloaded" });
await dismissAnalyticsModal(page);
await waitForTestId(page, "add-llm-profile");
await deleteProfileIfExists(page, ACTIVE_PROFILE);
await deleteProfileIfExists(page, INACTIVE_PROFILE);
} catch {
// best-effort
} finally {
await page.close();
}
});
test("active profile is deletable and reconciliation activates another profile", async ({
page,
request,
}) => {
// ── Setup: create two profiles, activate one ──
await saveProfile(request, ACTIVE_PROFILE, MOCK_MODEL);
await saveProfile(request, INACTIVE_PROFILE, MOCK_MODEL);
await activateProfile(request, ACTIVE_PROFILE);
// ── Setup: create two profiles via the UI, activate one ──
await routeSessionApiKey(page);
await page.goto("/settings/llm", { waitUntil: "domcontentloaded" });
await dismissAnalyticsModal(page);
await waitForTestId(page, "add-llm-profile");
// Clean up any leftover profiles from prior runs
await deleteProfileIfExists(page, ACTIVE_PROFILE);
await deleteProfileIfExists(page, INACTIVE_PROFILE);
// Create both profiles through the Settings UI
await createProfileViaUI(page, { profileName: ACTIVE_PROFILE, model: MOCK_MODEL });
await createProfileViaUI(page, { profileName: INACTIVE_PROFILE, model: MOCK_MODEL });
// Activate the first profile through the UI
await activateProfileViaUI(page, ACTIVE_PROFILE);
const rowFor = async (name: string) => {
const rows = page.getByTestId("profile-row");
const count = await rows.count();
@@ -281,13 +250,21 @@ test.describe("same-model profile identity", () => {
}
});
test.afterAll(async ({ request }) => {
for (const name of [PROFILE_ALPHA, PROFILE_BETA]) {
try {
await deleteProfile(request, name);
} catch {
// best-effort
}
test.afterAll(async ({ request, browser }) => {
// Best-effort cleanup via UI
const page = await browser.newPage();
try {
await seedLocalStorage(page);
await routeSessionApiKey(page);
await page.goto("/settings/llm", { waitUntil: "domcontentloaded" });
await dismissAnalyticsModal(page);
await waitForTestId(page, "add-llm-profile");
await deleteProfileIfExists(page, PROFILE_ALPHA);
await deleteProfileIfExists(page, PROFILE_BETA);
} catch {
// best-effort
} finally {
await page.close();
}
try {
await resetMockLLM(request);
@@ -302,10 +279,19 @@ test.describe("same-model profile identity", () => {
}) => {
test.setTimeout(120_000);
// ── Setup: create both profiles with the same model, activate BETA ──
await saveProfile(request, PROFILE_ALPHA, SHARED_MODEL);
await saveProfile(request, PROFILE_BETA, SHARED_MODEL);
await activateProfile(request, PROFILE_BETA);
// ── Setup: create both profiles with the same model via the UI,
// then activate BETA through the profile menu ──
await routeSessionApiKey(page);
await page.goto("/settings/llm", { waitUntil: "domcontentloaded" });
await dismissAnalyticsModal(page);
await waitForTestId(page, "add-llm-profile");
await deleteProfileIfExists(page, PROFILE_ALPHA);
await deleteProfileIfExists(page, PROFILE_BETA);
await createProfileViaUI(page, { profileName: PROFILE_ALPHA, model: SHARED_MODEL });
await createProfileViaUI(page, { profileName: PROFILE_BETA, model: SHARED_MODEL });
await activateProfileViaUI(page, PROFILE_BETA);
// Register a trajectory for the conversation.
// Turn 0 is padding: the agent-server makes an internal LLM call
@@ -316,19 +302,6 @@ test.describe("same-model profile identity", () => {
]);
await activateTrajectory(request, "profile-identity");
// ── Verify: active_profile is BETA via the API ──
await test.step("verify active profile is BETA via API", async () => {
const resp = await request.get(`${BACKEND_URL}/api/profiles`, {
headers: { "X-Session-API-Key": SESSION_API_KEY },
});
expect(resp.ok()).toBe(true);
const data = await resp.json();
expect(
data.active_profile,
`Expected active_profile="${PROFILE_BETA}" but got "${data.active_profile}"`,
).toBe(PROFILE_BETA);
});
// ── Start a conversation ──
await routeSessionApiKey(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -392,11 +365,19 @@ test.describe("litellm_proxy proxy base_url preservation", () => {
await seedLocalStorage(page);
});
test.afterAll(async ({ request }) => {
test.afterAll(async ({ browser }) => {
const page = await browser.newPage();
try {
await deleteProfile(request, PROXY_PROFILE);
await seedLocalStorage(page);
await routeSessionApiKey(page);
await page.goto("/settings/llm", { waitUntil: "domcontentloaded" });
await dismissAnalyticsModal(page);
await waitForTestId(page, "add-llm-profile");
await deleteProfileIfExists(page, PROXY_PROFILE);
} catch {
// best-effort
} finally {
await page.close();
}
});
@@ -405,29 +386,21 @@ test.describe("litellm_proxy proxy base_url preservation", () => {
request,
}) => {
// ── Setup: create a profile with the SDK-rewritten litellm_proxy
// model + proxy base_url, exactly as the agent-server persists it
// after an openhands/* model selection during onboarding. ──
await saveProfile(
request,
PROXY_PROFILE,
LITELLM_PROXY_MODEL,
OPENHANDS_PROXY_BASE_URL,
);
// ── Pre-check: verify the profile's base_url via API before the
// UI round-trip so we know the starting state is correct. ──
await test.step("verify initial profile has proxy base_url", async () => {
const config = await getProfileConfig(request, PROXY_PROFILE);
expect(config.model).toBe(LITELLM_PROXY_MODEL);
expect(config.base_url).toBe(OPENHANDS_PROXY_BASE_URL);
});
// ── Navigate to the LLM settings page ──
// model + proxy base_url through the Settings UI, exactly as
// the agent-server persists it after an openhands/* model
// selection during onboarding. ──
await routeSessionApiKey(page);
await page.goto("/settings/llm", { waitUntil: "domcontentloaded" });
await dismissAnalyticsModal(page);
await waitForTestId(page, "add-llm-profile");
await deleteProfileIfExists(page, PROXY_PROFILE);
await createProfileViaUI(page, {
profileName: PROXY_PROFILE,
model: LITELLM_PROXY_MODEL,
baseUrl: OPENHANDS_PROXY_BASE_URL,
});
// ── Find the profile row and open edit mode ──
await test.step("open profile in edit mode", async () => {
const profileRows = page.getByTestId("profile-row");
+4 -48
View File
@@ -26,7 +26,6 @@ import { test, expect, type APIRequestContext } from "@playwright/test";
import {
BACKEND_URL,
SESSION_API_KEY,
MOCK_LLM_AGENT_URL,
seedLocalStorage,
routeSessionApiKey,
dismissAnalyticsModal,
@@ -35,6 +34,7 @@ import {
registerTrajectory,
activateTrajectory,
resetMockLLM,
ensureMockLLMProfile,
setChatInput,
waitForPath,
getConversationIdFromURL,
@@ -48,50 +48,6 @@ import {
userSkillDirExists,
} from "./utils/skill-test-helpers";
/**
* Configure the mock LLM profile. Inlined from `ensureMockLLMProfile`
* to work around a CI-specific TS6/Node24 type inference bug (TS2345)
* where importing that function alongside `skill-test-helpers` causes
* TypeScript to incorrectly resolve its signature.
*/
async function configureMockLLM(
request: APIRequestContext,
model = "openai/mock-test-model",
) {
const settingsResp = await request.get(`${BACKEND_URL}/api/settings`, {
headers: {
"X-Session-API-Key": SESSION_API_KEY,
"X-Expose-Secrets": "encrypted",
},
});
if (settingsResp.ok()) {
const settings = (await settingsResp.json()) as Record<string, unknown>;
const llm = (
settings?.agent_settings as Record<string, unknown> | undefined
)?.llm as Record<string, unknown> | undefined;
if (llm?.model === model && llm?.base_url === MOCK_LLM_AGENT_URL) return;
}
const patchResp = await request.patch(`${BACKEND_URL}/api/settings`, {
headers: {
"X-Session-API-Key": SESSION_API_KEY,
"Content-Type": "application/json",
},
data: {
agent_settings_diff: {
llm: {
model,
api_key: "mock-api-key-for-testing",
base_url: MOCK_LLM_AGENT_URL,
},
},
},
});
expect(
patchResp.ok(),
`PATCH /api/settings failed: ${patchResp.status()}`,
).toBe(true);
}
/**
* Register a workspace on the agent-server so it appears in the UI dropdown.
*/
@@ -191,7 +147,7 @@ test.describe("skill loading: project, user, and deletion", () => {
page,
request,
}) => {
await configureMockLLM(request);
await ensureMockLLMProfile(page);
// Create a git repo with the skill committed
const { agentDir } = await test.step(
@@ -291,7 +247,7 @@ test.describe("skill loading: project, user, and deletion", () => {
page,
request,
}) => {
await configureMockLLM(request);
await ensureMockLLMProfile(page);
await test.step("create user skill file", () => {
writeUserSkill(USER_SKILL_NAME, USER_SKILL_TRIGGER);
@@ -362,7 +318,7 @@ test.describe("skill loading: project, user, and deletion", () => {
page,
request,
}) => {
await configureMockLLM(request);
await ensureMockLLMProfile(page);
await test.step("delete user skill file", () => {
removeUserSkill(USER_SKILL_NAME);
+73 -11
View File
@@ -322,7 +322,7 @@ export async function deleteConversation(
}
// ═══════════════════════════════════════════════════════════════════════
// LLM profile setup via API (for tests that can't depend on UI setup)
// LLM profile setup via the Settings UI
// ═══════════════════════════════════════════════════════════════════════
/**
@@ -330,8 +330,7 @@ export async function deleteConversation(
* UI — the same flow a real user follows.
*
* Exercises the full frontend save path (including `include_secrets`) so the
* api_key is persisted correctly. Callers that previously used the API-only
* `ensureMockLLMProfile(request)` should switch to this.
* api_key is persisted correctly.
*/
export async function ensureMockLLMProfile(
page: Page,
@@ -357,6 +356,33 @@ export async function ensureMockLLMProfile(
await deleteProfileIfExists(page, profileName);
// ── Create the profile ──────────────────────────────────────────────
await createProfileViaUI(page, { profileName, model, apiKey, baseUrl });
// ── Activate the profile ────────────────────────────────────────────
await activateProfileViaUI(page, profileName);
}
/**
* Create a new LLM profile through the Settings UI.
*
* Assumes the page is already on /settings/llm with profiles loaded
* (the "add-llm-profile" button is visible). Does NOT activate the
* profile — call `activateProfileViaUI` separately if needed.
*/
export async function createProfileViaUI(
page: Page,
{
profileName,
model,
apiKey = "mock-api-key-for-testing",
baseUrl = MOCK_LLM_AGENT_URL,
}: {
profileName: string;
model: string;
apiKey?: string;
baseUrl?: string;
},
) {
await page.getByTestId("add-llm-profile").click();
await waitForTestId(page, "profile-editor-title");
@@ -382,16 +408,13 @@ export async function ensureMockLLMProfile(
await page.getByTestId("save-profile-btn").click();
await waitForTestId(page, "add-llm-profile");
// ── Activate the profile ────────────────────────────────────────────
await activateProfileViaUI(page, profileName);
}
/**
* Delete a profile by name through the Settings UI if it exists.
* Assumes the page is already on /settings/llm with profiles loaded.
*/
async function deleteProfileIfExists(page: Page, profileName: string) {
export async function deleteProfileIfExists(page: Page, profileName: string) {
// Use the profile name span's `title` attribute for exact matching
// to avoid substring collisions (e.g. "mock-llm" vs "mock-llm-e2e").
const row = page
@@ -420,7 +443,7 @@ async function deleteProfileIfExists(page: Page, profileName: string) {
* Assumes the page is already on /settings/llm with profiles loaded.
* Polls until the "Active" badge is visible on the target profile row.
*/
async function activateProfileViaUI(page: Page, profileName: string) {
export async function activateProfileViaUI(page: Page, profileName: string) {
const exactRow = (p: Page) =>
p
.getByTestId("profile-row")
@@ -459,6 +482,46 @@ async function activateProfileViaUI(page: Page, profileName: string) {
.toBe(true);
}
/**
* Select an option from a HeroUI Autocomplete dropdown (SettingsDropdownInput).
*
* HeroUI Autocomplete does NOT forward `data-testid` to the underlying
* `<input>`, so we locate the combobox by its `aria-label` (which the
* component sets to the label prop or the name prop). We then click to
* open the listbox and click the matching option.
*/
export async function selectDropdownOption(
page: Page,
comboboxLabel: string | RegExp,
optionText: string | RegExp,
) {
const combobox = page.getByRole("combobox", { name: comboboxLabel });
await expect(combobox).toBeVisible({ timeout: 10_000 });
await combobox.click();
await combobox.fill("");
const option = page.getByRole("option", { name: optionText });
await expect(option).toBeVisible({ timeout: 5_000 });
await option.click();
}
/**
* Reset agent type back to OpenHands through the Settings → Agent UI.
* Used in afterAll cleanup to restore the default agent for subsequent tests.
*/
export async function resetToOpenHandsAgentViaUI(page: Page) {
await routeSessionApiKey(page);
await page.goto("/settings/agent", { waitUntil: "domcontentloaded" });
await dismissAnalyticsModal(page);
await waitForTestId(page, "agent-settings-screen");
await selectDropdownOption(page, /Agent/, /OpenHands/);
const saveBtn = page.getByTestId("agent-save-button");
await expect(saveBtn).toBeEnabled({ timeout: 5_000 });
await saveBtn.click();
await expect(saveBtn).toBeDisabled({ timeout: 10_000 });
}
/**
* Register a named trajectory on the mock LLM server.
* Each turn is: { tool_call: { name, arguments } } or { text: "..." }
@@ -615,8 +678,8 @@ export const MOCK_ACP_COMMAND_SCRIPT =
process.env.MOCK_ACP_CONTAINER_SCRIPT || MOCK_ACP_SERVER_PATH;
/**
* Reset the agent-server back to the default OpenHands agent.
* Used in afterAll cleanup to avoid polluting other test suites.
* @deprecated Use `resetToOpenHandsAgentViaUI(page)` to exercise the UI path.
* Kept only for callers that cannot open a page (should not exist in new tests).
*/
export async function resetToOpenHandsAgent(
request: APIRequestContext,
@@ -632,7 +695,6 @@ export async function resetToOpenHandsAgent(
},
},
});
// Best-effort — don't fail the test if cleanup fails
if (!resp.ok()) {
console.warn(`[cleanup] Reset to OpenHands failed: ${resp.status()}`);
}