Files
HealthLog/e2e/ai-provider-dropdown.spec.ts
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

455 lines
16 KiB
TypeScript

import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 — AI provider settings UX with single dropdown
* driving the form below. Three things to lock in here:
*
* 1. /settings/ai exposes the active-provider Pulldown
* (`data-testid="ai-active-provider-select"`).
* 2. Switching the dropdown changes the rendered config form below
* it: Codex form vs. OpenAI form vs. Anthropic form vs. Local
* form. Each form carries a stable `data-testid` so we don't
* lean on i18n labels.
* 3. The fallback chain card surfaces with stable
* `data-chain-row="<providerType>"` markers per row.
*/
test.describe("Settings → AI provider dropdown UX (B2)", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("dropdown switch changes the rendered config form", async ({ page }) => {
// Stable mocks for every endpoint the section reads. The chain
// mirrors the default order (codex first) so the rows render
// deterministically and the dropdown defaults to codex.
await page.route("**/api/insights/settings", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
codexStatus: "disconnected",
codexConnectedAt: null,
hasAdminKey: false,
codexOauthConfigured: true,
privacyMode: "aggregated",
lastInsightAt: null,
},
error: null,
}),
}),
);
await page.route("**/api/user/ai-provider", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
provider: null,
model: null,
baseUrl: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
},
error: null,
}),
}),
);
await page.route("**/api/insights/provider-chain", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
activeProvider: "codex",
cachedActiveProvider: null,
configuredChain: [
{ providerType: "codex", available: true },
{ providerType: "openai", available: true },
{ providerType: "anthropic", available: true },
{ providerType: "local", available: true },
],
},
error: null,
}),
}),
);
// Hydration gate — the provider query is fired by the same client
// boundary that renders the select, so its response proves React
// has hydrated the card and attached the select's change handler.
// `selectOption` on the SSR-painted element any earlier flips the
// native value without React noticing, and the config form below
// never switches (CI failure at slow hydration).
const cardHydrated = page.waitForResponse("**/api/user/ai-provider");
await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
await cardHydrated;
// 1. Dropdown surfaces on the page.
const select = page.getByTestId("ai-active-provider-select");
await expect(select).toBeVisible({ timeout: 10_000 });
// 2a. Default → Codex form rendered.
await expect(page.getByTestId("ai-provider-config-codex")).toBeVisible();
await expect(page.getByTestId("ai-provider-config-openai")).toHaveCount(0);
// 2b. Switch to OpenAI → OpenAI form rendered, Codex form gone.
await select.selectOption("openai");
await expect(page.getByTestId("ai-provider-config-openai")).toBeVisible();
await expect(page.getByTestId("ai-provider-config-codex")).toHaveCount(0);
await expect(page.getByTestId("ai-openai-api-key")).toBeVisible();
await expect(page.getByTestId("ai-openai-model")).toBeVisible();
// 2c. Switch to Anthropic → Anthropic form rendered.
await select.selectOption("anthropic");
await expect(
page.getByTestId("ai-provider-config-anthropic"),
).toBeVisible();
// 2d. Switch to Local → Local form rendered.
await select.selectOption("local");
await expect(page.getByTestId("ai-provider-config-local")).toBeVisible();
// 3. Fallback chain card surfaces with the four chain rows.
await expect(page.getByTestId("ai-fallback-chain")).toBeVisible();
for (const slug of ["codex", "openai", "anthropic", "local"]) {
await expect(page.locator(`[data-chain-row="${slug}"]`)).toBeVisible();
}
});
test("provider editors submit once by Enter or click and isolate connection tests", async ({
page,
}) => {
const patchBodies: Array<Record<string, unknown>> = [];
let releasePendingPatch: () => void = () => undefined;
let holdNextPatch = false;
let testConnectionRequests = 0;
let providerState: Record<string, unknown> = {
provider: null,
model: null,
baseUrl: null,
responseTimeoutSeconds: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
};
await page.route("**/api/insights/settings", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
codexStatus: "disconnected",
codexConnectedAt: null,
hasAdminKey: false,
codexOauthConfigured: true,
privacyMode: "aggregated",
lastInsightAt: null,
},
error: null,
}),
}),
);
await page.route("**/api/user/ai-provider", async (route) => {
if (route.request().method() === "PATCH") {
const body = route.request().postDataJSON() as Record<string, unknown>;
patchBodies.push(body);
providerState = { ...providerState, ...body };
if (holdNextPatch) {
holdNextPatch = false;
await new Promise<void>((resolve) => {
releasePendingPatch = resolve;
});
}
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: providerState, error: null }),
});
});
await page.route("**/api/insights/provider-chain", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
activeProvider: "codex",
cachedActiveProvider: null,
configuredChain: [
{ providerType: "codex", available: true },
{ providerType: "openai", available: true },
{ providerType: "anthropic", available: true },
{ providerType: "local", available: true },
],
},
error: null,
}),
}),
);
await page.route("**/api/ai/test", (route) => {
testConnectionRequests += 1;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { ok: true, providerType: "local", model: "test-model" },
error: null,
}),
});
});
const providerHydrated = page.waitForResponse("**/api/user/ai-provider");
await page.goto("/settings/ai?provider=openai", {
waitUntil: "domcontentloaded",
});
await providerHydrated;
const select = page.getByTestId("ai-active-provider-select");
async function assertEnterAndClickSubmitOnce({
provider,
formTestId,
inputId,
enterValue,
clickValue,
expectedField,
}: {
provider: "openai" | "anthropic" | "local";
formTestId: string;
inputId: string;
enterValue: string;
clickValue: string;
expectedField: string;
}) {
await select.selectOption(provider);
const form = page.getByTestId(formTestId);
await expect(form).toHaveJSProperty("tagName", "FORM");
const input = page.locator(`#${inputId}`);
const save = form.locator('button[type="submit"]');
await expect(save).toHaveCount(1);
await input.fill(enterValue);
const enterBaseline = patchBodies.length;
await input.press("Enter");
await expect.poll(() => patchBodies.length).toBe(enterBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(enterBaseline + 1);
expect(patchBodies.at(-1)?.[expectedField]).toBe(enterValue);
await input.fill(clickValue);
const clickBaseline = patchBodies.length;
await save.click();
await expect.poll(() => patchBodies.length).toBe(clickBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(clickBaseline + 1);
expect(patchBodies.at(-1)?.[expectedField]).toBe(clickValue);
}
await assertEnterAndClickSubmitOnce({
provider: "openai",
formTestId: "ai-provider-config-openai",
inputId: "ai-openai-key",
enterValue: "sk-enter-openai",
clickValue: "sk-click-openai",
expectedField: "openaiKey",
});
await assertEnterAndClickSubmitOnce({
provider: "anthropic",
formTestId: "ai-provider-config-anthropic",
inputId: "ai-anthropic-key",
enterValue: "sk-ant-enter",
clickValue: "sk-ant-click",
expectedField: "anthropicKey",
});
await assertEnterAndClickSubmitOnce({
provider: "local",
formTestId: "ai-provider-config-local",
inputId: "ai-local-base-url",
enterValue: "http://localhost:11434/v1",
clickValue: "http://localhost:11435/v1",
expectedField: "baseUrl",
});
const timeoutForm = page.locator("form").filter({
has: page.locator("#ai-response-timeout"),
});
const timeoutInput = page.locator("#ai-response-timeout");
const timeoutSave = timeoutForm.locator('button[type="submit"]');
await expect(timeoutForm).toHaveCount(1);
await expect(timeoutSave).toHaveCount(1);
await timeoutInput.fill("9");
const invalidTimeoutBaseline = patchBodies.length;
await timeoutInput.press("Enter");
await expect(timeoutForm.locator("p.text-destructive")).toBeVisible();
expect(patchBodies).toHaveLength(invalidTimeoutBaseline);
await timeoutInput.fill("180");
const timeoutEnterBaseline = patchBodies.length;
await timeoutInput.press("Enter");
await expect.poll(() => patchBodies.length).toBe(timeoutEnterBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(timeoutEnterBaseline + 1);
expect(patchBodies.at(-1)?.responseTimeoutSeconds).toBe(180);
await timeoutInput.fill("240");
const timeoutClickBaseline = patchBodies.length;
await timeoutSave.click();
await expect.poll(() => patchBodies.length).toBe(timeoutClickBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(timeoutClickBaseline + 1);
expect(patchBodies.at(-1)?.responseTimeoutSeconds).toBe(240);
holdNextPatch = true;
await page.locator("#ai-local-base-url").fill("http://localhost:11436/v1");
const pendingBaseline = patchBodies.length;
await page
.getByTestId("ai-provider-config-local")
.evaluate((form: HTMLFormElement) => {
form.requestSubmit();
form.requestSubmit();
});
await expect.poll(() => patchBodies.length).toBe(pendingBaseline + 1);
await expect(
page
.getByTestId("ai-provider-config-local")
.locator('button[type="submit"]'),
).toBeDisabled();
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(pendingBaseline + 1);
const testConnection = page.getByTestId("ai-test-active-provider");
await expect(testConnection).toHaveAttribute("type", "button");
await testConnection.click();
await expect.poll(() => testConnectionRequests).toBe(1);
expect(patchBodies).toHaveLength(pendingBaseline + 1);
releasePendingPatch();
await expect(
page
.getByTestId("ai-provider-config-local")
.locator('button[type="submit"]'),
).toBeEnabled();
});
// #1126 — the Reasoning select on the Local and OpenAI-compatible forms
// saves with the form, and at phone width it sits in the same column as
// the model field without pushing the page sideways.
test("reasoning setting saves from the Local and gateway forms at 390 px", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const patchBodies: Array<Record<string, unknown>> = [];
let providerState: Record<string, unknown> = {
provider: "LOCAL",
model: null,
baseUrl: "http://localhost:11434/v1",
responseTimeoutSeconds: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
compatBaseUrl: "https://gateway.example.org/v1",
compatModel: "qwen3",
hasCompatKey: false,
localReasoningEffort: null,
compatReasoningEffort: null,
};
await page.route("**/api/insights/settings", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
codexStatus: "disconnected",
codexConnectedAt: null,
hasAdminKey: false,
codexOauthConfigured: true,
privacyMode: "aggregated",
lastInsightAt: null,
},
error: null,
}),
}),
);
await page.route("**/api/user/ai-provider", async (route) => {
if (route.request().method() === "PATCH") {
const body = route.request().postDataJSON() as Record<string, unknown>;
patchBodies.push(body);
providerState = { ...providerState, ...body };
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: providerState, error: null }),
});
});
await page.route("**/api/insights/provider-chain", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
activeProvider: "local",
cachedActiveProvider: null,
configuredChain: [
{ providerType: "local", enabled: true, available: true },
],
},
error: null,
}),
}),
);
const providerHydrated = page.waitForResponse("**/api/user/ai-provider");
await page.goto("/settings/ai?provider=local", {
waitUntil: "domcontentloaded",
});
await providerHydrated;
const localForm = page.getByTestId("ai-provider-config-local");
const localReasoning = page.locator("#ai-local-reasoning");
await expect(localReasoning).toHaveValue("");
// Same column, same width as the model select above it.
const [modelBox, reasoningBox] = await Promise.all([
page.locator("#ai-local-model").boundingBox(),
localReasoning.boundingBox(),
]);
expect(reasoningBox?.x).toBe(modelBox?.x);
expect(reasoningBox?.width).toBe(modelBox?.width);
expect(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
document.documentElement.clientWidth,
),
).toBe(true);
await localReasoning.selectOption("none");
await localForm.locator('button[type="submit"]').click();
await expect.poll(() => patchBodies.length).toBe(1);
expect(patchBodies[0].localReasoningEffort).toBe("none");
await page
.getByTestId("ai-active-provider-select")
.selectOption("openai-compatible");
const compatForm = page.getByTestId("ai-provider-config-openai-compatible");
const compatReasoning = page.locator("#ai-compat-reasoning");
await expect(compatReasoning).toHaveValue("");
await compatReasoning.selectOption("high");
await compatForm.locator('button[type="submit"]').click();
await expect.poll(() => patchBodies.length).toBe(2);
expect(patchBodies[1].compatReasoningEffort).toBe("high");
expect(patchBodies[1]).not.toHaveProperty("localReasoningEffort");
});
});