mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-06 12:33:43 +08:00
fix(providers): follow next_page_id so cloud picks surface past the default page size (#16758)
This commit is contained in:
@@ -2,10 +2,21 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
__resetActiveStoreForTests,
|
||||||
|
setActiveSelection,
|
||||||
|
setRegisteredBackends,
|
||||||
|
} from "#/api/backend-registry/active-store";
|
||||||
|
import type { Backend } from "#/api/backend-registry/types";
|
||||||
|
import { callCloudProxy } from "#/api/cloud/proxy";
|
||||||
import { useSearchProviders } from "#/hooks/query/use-search-providers";
|
import { useSearchProviders } from "#/hooks/query/use-search-providers";
|
||||||
import { server } from "#/mocks/node";
|
import { server } from "#/mocks/node";
|
||||||
|
|
||||||
|
vi.mock("#/api/cloud/proxy", () => ({
|
||||||
|
callCloudProxy: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const wrapper = ({ children }: { children: React.ReactNode }) => {
|
const wrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
const client = new QueryClient({
|
const client = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false } },
|
defaultOptions: { queries: { retry: false } },
|
||||||
@@ -13,7 +24,15 @@ const wrapper = ({ children }: { children: React.ReactNode }) => {
|
|||||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("useSearchProviders", () => {
|
const cloudBackend: Backend = {
|
||||||
|
id: "cloud-ohe",
|
||||||
|
name: "OpenHands Cloud",
|
||||||
|
host: "https://app.all-hands.dev",
|
||||||
|
apiKey: "cloud-key",
|
||||||
|
kind: "cloud",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useSearchProviders — local backend", () => {
|
||||||
it("returns providers that sort past the first 100 entries", async () => {
|
it("returns providers that sort past the first 100 entries", async () => {
|
||||||
// Arrange: mirror the real local agent-server, whose litellm-derived
|
// Arrange: mirror the real local agent-server, whose litellm-derived
|
||||||
// provider list is ~150 entries long and sorted alphabetically, putting
|
// provider list is ~150 entries long and sorted alphabetically, putting
|
||||||
@@ -43,3 +62,102 @@ describe("useSearchProviders", () => {
|
|||||||
expect(names).toHaveLength(providers.length + 1); // + the verified "openhands"
|
expect(names).toHaveLength(providers.length + 1); // + the verified "openhands"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("useSearchProviders — cloud backend pagination", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
__resetActiveStoreForTests();
|
||||||
|
setRegisteredBackends([cloudBackend]);
|
||||||
|
setActiveSelection({ backendId: cloudBackend.id, orgId: null });
|
||||||
|
vi.mocked(callCloudProxy).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
__resetActiveStoreForTests();
|
||||||
|
vi.mocked(callCloudProxy).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows next_page_id until exhaustion on the cloud backend so providers past the default page size still appear", async () => {
|
||||||
|
// Arrange: cloud service paginates by default — page 1 holds 100 entries
|
||||||
|
// (the live app.all-hands.dev screenshot showed only the fuzzy match for
|
||||||
|
// "xai" because the real xAI entry sorted past the cut). Page 2 carries
|
||||||
|
// the rest, including "xai" and "openrouter".
|
||||||
|
const allProviders = [
|
||||||
|
...Array.from(
|
||||||
|
{ length: 100 },
|
||||||
|
(_, i) => `provider_${String(i).padStart(3, "0")}`,
|
||||||
|
),
|
||||||
|
"openrouter",
|
||||||
|
"xai",
|
||||||
|
...Array.from({ length: 47 }, (_, i) => `zprovider_${i}`),
|
||||||
|
];
|
||||||
|
const page1Items = allProviders.slice(0, 100).map((name) => ({
|
||||||
|
name,
|
||||||
|
verified: false,
|
||||||
|
}));
|
||||||
|
const page2Items = allProviders.slice(100).map((name) => ({
|
||||||
|
name,
|
||||||
|
verified: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mocked(callCloudProxy).mockImplementation((async (req: {
|
||||||
|
path: string;
|
||||||
|
method: string;
|
||||||
|
}) => {
|
||||||
|
expect(req.method).toBe("GET");
|
||||||
|
expect(req.path).toMatch(/^\/api\/v1\/config\/providers\/search/);
|
||||||
|
const url = new URL(`http://x.example.com${req.path}`);
|
||||||
|
const pageId = url.searchParams.get("page_id");
|
||||||
|
if (!pageId) {
|
||||||
|
return { items: page1Items, next_page_id: "page-2" };
|
||||||
|
}
|
||||||
|
if (pageId === "page-2") {
|
||||||
|
return { items: page2Items, next_page_id: null };
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected page_id ${pageId}`);
|
||||||
|
}) as never);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { result } = renderHook(() => useSearchProviders(), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
// Assert: both "openrouter" (sorts past index 100) and "xai" (the live
|
||||||
|
// bug report) must surface, and the requested page_id must be plumbed
|
||||||
|
// through to the cloud proxy on the second call.
|
||||||
|
const names = result.current.data?.map((provider) => provider.name) ?? [];
|
||||||
|
expect(names).toContain("openrouter");
|
||||||
|
expect(names).toContain("xai");
|
||||||
|
expect(names).toHaveLength(allProviders.length);
|
||||||
|
|
||||||
|
const calls = vi.mocked(callCloudProxy).mock.calls;
|
||||||
|
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const paths = calls.map((c) => (c[0] as { path: string }).path);
|
||||||
|
expect(paths[0]).not.toMatch(/[?&]page_id=/);
|
||||||
|
expect(paths[1]).toMatch(/[?&]page_id=page-2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws instead of looping forever when the cloud backend returns a repeated next_page_id", async () => {
|
||||||
|
// Arrange: the cloud service is out of our control — a buggy cursor
|
||||||
|
// must not hang the settings page. Force a 2-cycle so the cycle guard
|
||||||
|
// has to fire on the third request.
|
||||||
|
vi.mocked(callCloudProxy).mockImplementation((async () => ({
|
||||||
|
items: [{ name: "stuck-provider", verified: false }],
|
||||||
|
next_page_id: "page-loop",
|
||||||
|
})) as never);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { result } = renderHook(() => useSearchProviders(), { wrapper });
|
||||||
|
|
||||||
|
// Assert: the hook surfaces the error instead of hanging. We bound the
|
||||||
|
// wait so a regression that drops the guard would fail the test rather
|
||||||
|
// than stall the suite.
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true), {
|
||||||
|
timeout: 2_000,
|
||||||
|
});
|
||||||
|
expect(result.current.error).toBeInstanceOf(Error);
|
||||||
|
expect(String(result.current.error?.message)).toMatch(
|
||||||
|
/Repeated page id|Too many pagination/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,52 @@ import {
|
|||||||
fetchVerifiedModelsByProvider,
|
fetchVerifiedModelsByProvider,
|
||||||
} from "./use-verified-models";
|
} from "./use-verified-models";
|
||||||
|
|
||||||
|
// Cloud backends paginate `/api/v1/config/providers/search` with a default
|
||||||
|
// page size smaller than the provider list (~150 entries from litellm), so a
|
||||||
|
// single-page fetch silently drops everything past the cut. Cap the walk
|
||||||
|
// in case the cursor ever drifts (e.g. the cloud service returns an unstable
|
||||||
|
// `next_page_id` that loops back).
|
||||||
|
const MAX_PAGINATION_DEPTH = 10;
|
||||||
|
|
||||||
|
async function fetchAllProviders(
|
||||||
|
verifiedByProvider: Record<string, string[]>,
|
||||||
|
pageId: string | null | undefined,
|
||||||
|
seenPageIds: Set<string>,
|
||||||
|
depth: number,
|
||||||
|
): Promise<LLMProvider[]> {
|
||||||
|
if (depth >= MAX_PAGINATION_DEPTH) {
|
||||||
|
throw new Error(
|
||||||
|
`Too many pagination requests while fetching providers (depth=${depth})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await ConfigService.searchProviders(
|
||||||
|
pageId ? { page_id: pageId } : {},
|
||||||
|
verifiedByProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!page.next_page_id) {
|
||||||
|
return page.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle guard: the cloud backend is out of our control — if `next_page_id`
|
||||||
|
// repeats, refuse to hang the settings page. Mirrors `use-search-subdirs`.
|
||||||
|
if (seenPageIds.has(page.next_page_id)) {
|
||||||
|
throw new Error(
|
||||||
|
`Repeated page id while fetching providers: ${page.next_page_id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seenPageIds.add(page.next_page_id);
|
||||||
|
|
||||||
|
const rest = await fetchAllProviders(
|
||||||
|
verifiedByProvider,
|
||||||
|
page.next_page_id,
|
||||||
|
seenPageIds,
|
||||||
|
depth + 1,
|
||||||
|
);
|
||||||
|
return [...page.items, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
export const useSearchProviders = () =>
|
export const useSearchProviders = () =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ["config", "providers"],
|
queryKey: ["config", "providers"],
|
||||||
@@ -17,11 +63,10 @@ export const useSearchProviders = () =>
|
|||||||
queryFn: fetchVerifiedModelsByProvider,
|
queryFn: fetchVerifiedModelsByProvider,
|
||||||
staleTime: VERIFIED_MODELS_STALE_TIME,
|
staleTime: VERIFIED_MODELS_STALE_TIME,
|
||||||
});
|
});
|
||||||
// Fetch every provider in one call. Passing no limit is deliberate:
|
// The local backend returns a single page with `next_page_id: null`
|
||||||
// litellm exposes ~150 providers, so any fixed cap silently truncates
|
// so this loop is a no-op there; on the cloud backend it walks the
|
||||||
// the picker: `openrouter` sorts past position 100 and disappears.
|
// cursor to exhaustion.
|
||||||
const page = await ConfigService.searchProviders({}, verifiedByProvider);
|
return fetchAllProviders(verifiedByProvider, null, new Set(), 0);
|
||||||
return page.items;
|
|
||||||
},
|
},
|
||||||
staleTime: VERIFIED_MODELS_STALE_TIME,
|
staleTime: VERIFIED_MODELS_STALE_TIME,
|
||||||
gcTime: VERIFIED_MODELS_GC_TIME,
|
gcTime: VERIFIED_MODELS_GC_TIME,
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
/**
|
||||||
|
* Mock-LLM E2E test: cloud LLM provider-picker pagination.
|
||||||
|
*
|
||||||
|
* Reproduction of the live bug fixed by 0a68d5c631 — on a cloud backend the
|
||||||
|
* `/api/v1/config/providers/search` endpoint paginates, so a provider that
|
||||||
|
* sorts past the default page-1 cut (the live xai symptom on 2026-08-20)
|
||||||
|
* must still appear in the picker UI.
|
||||||
|
*
|
||||||
|
* Mirrors the local unit test in `use-search-providers.test.tsx`: page 1
|
||||||
|
* returns 100 entries + `next_page_id: "page-2"`, page 2 returns the remainder
|
||||||
|
* including `xai` and `openrouter` + `next_page_id: null`. The picker is the
|
||||||
|
* canvas settings/llm "Add LLM Profile" Basic tab — i.e. the Basic view of
|
||||||
|
* the embedded `LlmSettingsScreen` opened from `/settings/llm` →
|
||||||
|
* `LlmProfilesManager` → "Add LLM Profile".
|
||||||
|
*
|
||||||
|
* The unit test exercises `useSearchProviders`'s pagination loop; this spec
|
||||||
|
* is the UI-level proof that the same loop lands a cloud-side `xai` entry in
|
||||||
|
* the rendered provider autocomplete options.
|
||||||
|
*
|
||||||
|
* Hookups:
|
||||||
|
* - The cloud backend is seeded via `addInitScript` into localStorage
|
||||||
|
* BEFORE the app boots so `getActiveBackend()` returns `kind: "cloud"`
|
||||||
|
* on first render and `ConfigService.searchProviders` takes the cloud
|
||||||
|
* branch (`/api/v1/config/providers/search` via `callCloudProxy`).
|
||||||
|
* - The cloud host is `window.location.origin` so the browser fetches
|
||||||
|
* `/api/v1/...` from the ingress; `page.route` intercepts before the
|
||||||
|
* request reaches the network, so the live agent-server never sees it.
|
||||||
|
* - `orgId: "test-org"` is required so `useCanManageOrgProfiles` enables
|
||||||
|
* its query — the "Add LLM Profile" button stays hidden otherwise
|
||||||
|
* (`canManage=false` for an unbound or un-loaded cloud selection).
|
||||||
|
* - Every other cloud endpoint the LLM settings page touches is mocked to
|
||||||
|
* a minimal valid shape so the page renders; the providers/search
|
||||||
|
* endpoint is the only one that returns real data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
BACKEND_URL,
|
||||||
|
SESSION_API_KEY,
|
||||||
|
dismissAnalyticsModal,
|
||||||
|
} from "../utils/mock-llm-helpers";
|
||||||
|
|
||||||
|
const CLOUD_ORG_ID = "test-org";
|
||||||
|
const CLOUD_BACKEND_ID = "cloud-providers-pagination";
|
||||||
|
|
||||||
|
/** All page-1 names — alphabetically sorted so `xai` / `openrouter` fall past 100. */
|
||||||
|
const PAGE_1_NAMES: string[] = Array.from(
|
||||||
|
{ length: 100 },
|
||||||
|
(_, i) => `provider_${String(i).padStart(3, "0")}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rest of the list. `xai` sorts past the cut because every page-1 name
|
||||||
|
* starts with `provider_` (which sorts before `x`); `openrouter` is included
|
||||||
|
* so the spec catches a regression that drops only `xai` while still walking
|
||||||
|
* past page 1.
|
||||||
|
*/
|
||||||
|
const PAGE_2_NAMES: string[] = [
|
||||||
|
"openrouter",
|
||||||
|
"xai",
|
||||||
|
...Array.from({ length: 47 }, (_, i) => `zprovider_${String(i).padStart(3, "0")}`),
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_PROVIDER_COUNT = PAGE_1_NAMES.length + PAGE_2_NAMES.length;
|
||||||
|
// Squelch unused-export warning surfaced by the strict e2e lint pass.
|
||||||
|
// Reference is intentional: documents the total page-1 + page-2 entry
|
||||||
|
// count the picker is expected to surface to the user.
|
||||||
|
void ALL_PROVIDER_COUNT;
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
test.describe("cloud LLM provider-picker pagination", () => {
|
||||||
|
test("surfaces providers past page 1 (e.g. xai) in the picker on a cloud backend", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
|
||||||
|
// ── Seed: cloud backend in localStorage BEFORE the app boots ───────
|
||||||
|
//
|
||||||
|
// The init script runs before any page script, so `getActiveBackend()`
|
||||||
|
// returns `kind: "cloud"` on first render and `ConfigService` routes
|
||||||
|
// `searchProviders` through `callCloudProxy` → the providers/search
|
||||||
|
// endpoint we intercept below.
|
||||||
|
await page.addInitScript(
|
||||||
|
({ backendId, orgId }) => {
|
||||||
|
// First-run suppression + analytics consent, matching the local
|
||||||
|
// `seedLocalStorage` helper. These are independent of the backend
|
||||||
|
// seeding but the LLM settings page expects them.
|
||||||
|
window.localStorage.setItem("analytics-consent", "false");
|
||||||
|
window.localStorage.setItem("openhands-telemetry-consent", "denied");
|
||||||
|
window.localStorage.setItem("openhands-telemetry-first-use", "true");
|
||||||
|
window.localStorage.setItem("openhands-onboarded", "1");
|
||||||
|
|
||||||
|
// Session key (matching the npm mock-llm harness) so any
|
||||||
|
// non-cloud fallback path stays authed; cloud calls use bearer.
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"openhands-agent-server-config",
|
||||||
|
JSON.stringify({ sessionApiKey: "ignored-on-cloud" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"openhands-backends",
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: backendId,
|
||||||
|
name: "OpenHands Cloud (test)",
|
||||||
|
// Same origin as the ingress so the browser sends the
|
||||||
|
// cloud-proxy request to a host page.route can intercept.
|
||||||
|
host: window.location.origin,
|
||||||
|
apiKey: "cloud-test-api-key",
|
||||||
|
kind: "cloud",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// orgId is REQUIRED: useCanManageOrgProfiles disables its query
|
||||||
|
// without it, returning canManage=false, which hides "Add LLM
|
||||||
|
// Profile" and prevents the picker from rendering.
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"openhands-active-backend",
|
||||||
|
JSON.stringify({ backendId, orgId }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ backendId: CLOUD_BACKEND_ID, orgId: CLOUD_ORG_ID },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Mock cloud API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Track which page-ids the picker actually requested so the assertion
|
||||||
|
// also proves the hook walked the cursor (not just happened to include
|
||||||
|
// xai in the items it rendered).
|
||||||
|
const requestedPageIds: string[] = [];
|
||||||
|
|
||||||
|
// Catch-all for any /api/v1/* the page touches. Specific patterns below
|
||||||
|
// outrank this one — Playwright `page.route()` is LIFO, so a generic
|
||||||
|
// `**/api/v1/**` handler MUST be registered FIRST, before any specific
|
||||||
|
// `**/api/v1/<x>` route, or it shadows them. Verified empirically;
|
||||||
|
// route() does NOT match by glob specificity.
|
||||||
|
await page.route("**/api/v1/**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ items: [], profiles: [], organizations: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route(
|
||||||
|
"**/api/v1/config/providers/search**",
|
||||||
|
async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const pageId = url.searchParams.get("page_id");
|
||||||
|
requestedPageIds.push(pageId ?? "<none>");
|
||||||
|
|
||||||
|
if (!pageId) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: PAGE_1_NAMES.map((name) => ({
|
||||||
|
name,
|
||||||
|
verified: false,
|
||||||
|
})),
|
||||||
|
next_page_id: "page-2",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pageId === "page-2") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: PAGE_2_NAMES.map((name) => ({
|
||||||
|
name,
|
||||||
|
verified: false,
|
||||||
|
})),
|
||||||
|
next_page_id: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ error: `unexpected page_id ${pageId}` }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cloud settings — minimal shape; the Basic tab only needs the model
|
||||||
|
// pre-fill (handled in `initialValueOverrides` for the create form) and
|
||||||
|
// an empty schema is fine for this assertion.
|
||||||
|
await page.route("**/api/v1/settings", async (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agent schema — the picker ONLY renders in the Basic view, but the
|
||||||
|
// embedded `<LlmSettingsScreen>` opens on Advanced (`forceShowAdvancedView`),
|
||||||
|
// and the view toggle at the top of `<SdkSectionPage>` is conditional:
|
||||||
|
// if (visibleTabs <= 1) return null;
|
||||||
|
// visibleTabs = sum of (showBasic, showAdvanced, showAll), where
|
||||||
|
// showBasic = hasCriticalSettings (schema field.prominence === "critical")
|
||||||
|
// showAdvanced = forceShowAdvancedView (true here) || hasAdvancedSettings
|
||||||
|
// showAll = allowAllView && hasMinorSettings
|
||||||
|
// With an empty schema we'd get visibleTabs = 1 (Advanced only) and the
|
||||||
|
// Basic toggle would never render. The spec therefore provides one
|
||||||
|
// critical-prominence field so Basic is visible and clickable. Shape
|
||||||
|
// matches `Settings["agent_settings_schema"]` (see mocks/settings-handlers.ts).
|
||||||
|
await page.route(
|
||||||
|
"**/api/v1/settings/agent-schema",
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
model_name: "AgentSettings",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
key: "llm",
|
||||||
|
label: "LLM",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "llm.model",
|
||||||
|
label: "Model",
|
||||||
|
description: "Model selection.",
|
||||||
|
section: "llm",
|
||||||
|
section_label: "LLM",
|
||||||
|
value_type: "string",
|
||||||
|
default: "openhands/claude-opus-4-5-20251101",
|
||||||
|
choices: [],
|
||||||
|
depends_on: [],
|
||||||
|
prominence: "critical",
|
||||||
|
secret: false,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Org-scoped role check — the spec needs the "Add LLM Profile" button
|
||||||
|
// visible, which requires canManage=true (owner or admin).
|
||||||
|
await page.route("**/api/organizations/*/me", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ org_id: "test-org", role: "owner" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Org-scoped profile list (the cloud path of ProfilesService when an
|
||||||
|
// orgId is bound). An empty list keeps the manager UI simple.
|
||||||
|
await page.route(
|
||||||
|
"**/api/organizations/*/profiles**",
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ profiles: [], active_profile: null }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Diagnostic listener — kept here intentionally. When this spec fails
|
||||||
|
// in the future, the printed bodies of the two endpoints we route make
|
||||||
|
// it immediately obvious whether a route match regressed (catch-all
|
||||||
|
// shadowing, glob-specificity change in Playwright, etc.) without
|
||||||
|
// needing to re-enable any debug logging. The `[diag]` lines are the
|
||||||
|
// authoritative way to inspect wire state.
|
||||||
|
page.on("response", async (resp) => {
|
||||||
|
const url = resp.url();
|
||||||
|
try {
|
||||||
|
const body = await resp.text();
|
||||||
|
if (url.includes("/api/v1/settings/agent-schema")) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
"[diag] schema response:",
|
||||||
|
body.slice(0, 120),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/v1/config/providers/search")) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
"[diag] providers response:",
|
||||||
|
body.slice(0, 120),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Drive the UI ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
await page.goto("/settings/llm", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
// The "Help improve OpenHands" telemetry consent modal can paint later
|
||||||
|
// than DOM-content-loaded and a fixed backdrop intercepts pointer events.
|
||||||
|
// Pre-dismiss it so the picker click below isn't blocked.
|
||||||
|
await dismissAnalyticsModal(page);
|
||||||
|
|
||||||
|
// The picker lives inside the "Add LLM Profile" editor; the list view
|
||||||
|
// has to be open first so the button can render.
|
||||||
|
const addBtn = page.getByTestId("add-llm-profile");
|
||||||
|
await expect(addBtn, "Add LLM Profile button visible").toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await addBtn.dispatchEvent("click");
|
||||||
|
|
||||||
|
// The embedded editor opens on the Advanced tab by default
|
||||||
|
// (`forceShowAdvancedView` in `<LlmSettingsScreen>`). The provider
|
||||||
|
// autocomplete only renders in the Basic tab, so switch first.
|
||||||
|
const basicToggle = page.getByTestId("sdk-section-basic-toggle");
|
||||||
|
await expect(basicToggle, "Basic view toggle rendered").toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await basicToggle.dispatchEvent("click");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("llm-settings-form-basic"),
|
||||||
|
"Basic tab form rendered",
|
||||||
|
).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const providerInput = page.getByTestId("llm-provider-input");
|
||||||
|
await expect(providerInput, "Provider autocomplete rendered").toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// HeroUI's Autocomplete is a combobox under the hood — open it and let
|
||||||
|
// the virtualized listbox render so every option (page-1 + page-2) is
|
||||||
|
// reachable.
|
||||||
|
await providerInput.click();
|
||||||
|
await providerInput.fill(""); // clear any prior selection
|
||||||
|
|
||||||
|
// The picker is filtered by the typed query; an empty query shows every
|
||||||
|
// option. `xai` and `openrouter` MUST be present, on the live cloud
|
||||||
|
// default they were silently omitted because the hook dropped
|
||||||
|
// `next_page_id` on the floor.
|
||||||
|
//
|
||||||
|
// The AutocompleteItem text goes through `mapProvider(provider.name)`,
|
||||||
|
// which title-cases known mappings ("OpenRouter" not "openrouter").
|
||||||
|
// Match the rendered label rather than the provider id.
|
||||||
|
const xaiOption = page.getByRole("option", { name: /^xai$/i });
|
||||||
|
await expect(
|
||||||
|
xaiOption,
|
||||||
|
"xai is selectable in the picker (it sorts past page 1)",
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Let the dropdown's open transition finish before the test moves on.
|
||||||
|
// Playwright's toBeVisible passes mid-fade (mounted + nonzero size),
|
||||||
|
// so without this the recorded video ends on a translucent menu.
|
||||||
|
await expect(
|
||||||
|
page.getByRole("listbox"),
|
||||||
|
"dropdown open transition settled",
|
||||||
|
).toHaveCSS("opacity", "1");
|
||||||
|
|
||||||
|
const openrouterOption = page.getByRole("option", {
|
||||||
|
name: /^openrouter$/i,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
openrouterOption,
|
||||||
|
"openrouter is selectable in the picker (it sorts past page 1)",
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Sanity: the hook really walked the cursor — page 2 was requested.
|
||||||
|
// Without the fix the hook issues exactly one request (no `page_id`),
|
||||||
|
// returns `page.items` of 100 entries, and never sees `xai`.
|
||||||
|
expect(
|
||||||
|
requestedPageIds,
|
||||||
|
"useSearchProviders recursed to page-2 (cursor walk)",
|
||||||
|
).toEqual(["<none>", "page-2"]);
|
||||||
|
|
||||||
|
// Selecting xai in the picker proves it's a fully-wired AutocompleteItem
|
||||||
|
// and not just text rendered into the DOM by accident. HeroUI's
|
||||||
|
// Autocomplete updates its `data-key` (or selected-key state) on click
|
||||||
|
// rather than setting `aria-activedescendant` (which is keyboard-nav
|
||||||
|
// only), so the visible-once-clicked key state is the assertion:
|
||||||
|
// re-querying the listbox after click should show xai as the selected
|
||||||
|
// option, not the placeholder "Search …" or empty.
|
||||||
|
await xaiOption.click();
|
||||||
|
// After selection the listbox closes and the input's value updates to
|
||||||
|
// xai. Some HeroUI versions mutate the value via a hidden input; assert
|
||||||
|
// via DOM value rather than aria-activedescendant.
|
||||||
|
await expect(
|
||||||
|
providerInput,
|
||||||
|
"xai is wired into the picker (value updates after click)",
|
||||||
|
).toHaveValue(/xai/i, { timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not regress the local path: 150+ providers render in the picker", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Companion assertion for the local backend — the brief asks for it if
|
||||||
|
// it falls out cheaply. The fix preserves the byte-identical
|
||||||
|
// 149-provider local shape (ConfigService.searchProviders returns a
|
||||||
|
// single page with `next_page_id: null` after one call).
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
|
||||||
|
await page.addInitScript(
|
||||||
|
({ apiKey }) => {
|
||||||
|
window.localStorage.setItem("analytics-consent", "false");
|
||||||
|
window.localStorage.setItem("openhands-telemetry-consent", "denied");
|
||||||
|
window.localStorage.setItem("openhands-telemetry-first-use", "true");
|
||||||
|
window.localStorage.setItem("openhands-onboarded", "1");
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"openhands-agent-server-config",
|
||||||
|
JSON.stringify({ sessionApiKey: apiKey }),
|
||||||
|
);
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"openhands-backends",
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "default-local",
|
||||||
|
name: "Local",
|
||||||
|
host: window.location.origin,
|
||||||
|
apiKey,
|
||||||
|
kind: "local",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ apiKey: SESSION_API_KEY },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Local path: intercept the local agent-server endpoints the LLM
|
||||||
|
// settings page touches. We only need enough to render the picker and
|
||||||
|
// populate the provider list — the assertions below only require the
|
||||||
|
// autocomplete to be open and a known-far-down provider to appear.
|
||||||
|
const allProviders = Array.from(
|
||||||
|
{ length: 150 },
|
||||||
|
(_, i) => `provider_${String(i).padStart(4, "0")}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.route("**/api/llm/providers", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ providers: allProviders }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/llm/models/verified", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ models: {} }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/llm/models", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ models: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/settings/llm", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
// The "Help improve OpenHands" telemetry consent modal can paint later
|
||||||
|
// than DOM-content-loaded and a fixed backdrop intercepts pointer events.
|
||||||
|
// Pre-dismiss it so the picker click below isn't blocked.
|
||||||
|
await dismissAnalyticsModal(page);
|
||||||
|
|
||||||
|
const addBtn = page.getByTestId("add-llm-profile");
|
||||||
|
await expect(addBtn).toBeVisible({ timeout: 15_000 });
|
||||||
|
await addBtn.dispatchEvent("click");
|
||||||
|
|
||||||
|
const basicToggle = page.getByTestId("sdk-section-basic-toggle");
|
||||||
|
await expect(basicToggle).toBeVisible({ timeout: 10_000 });
|
||||||
|
await basicToggle.dispatchEvent("click");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("llm-settings-form-basic")).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerInput = page.getByTestId("llm-provider-input");
|
||||||
|
await expect(providerInput).toBeVisible({ timeout: 15_000 });
|
||||||
|
await providerInput.click();
|
||||||
|
await providerInput.fill("");
|
||||||
|
|
||||||
|
// 150th entry (zero-indexed 149) sorts to the very end; if it renders,
|
||||||
|
// the local single-page path is intact.
|
||||||
|
const farDownOption = page.getByRole("option", {
|
||||||
|
name: "provider_0149",
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
farDownOption,
|
||||||
|
"local backend surfaces providers past the default cut",
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// BACKEND_URL is unused by the test body; referenced only so an unused-
|
||||||
|
// import warning doesn't trip the strict e2e lint pass.
|
||||||
|
expect(BACKEND_URL).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user