Files
open-design/e2e/ui/settings-api-protocol.test.ts
75693e95f4 [OPEND-3114] feat(od-next): open registered agents and CLI BYOK (#8702)
* feat(od-next): open registered runtimes and CLI BYOK

Use the effective runtime registry for OD Next admission and remove retired
capability qualification. Keep native observation and existing resume/cold
recovery, with the evidence graph owned by observability contracts.

Add run-scoped BYOK connection flags with caller-selected environment key input
and reuse the existing provider bridge. Accept equivalent frozen Skill JSON
and Base64 representations while preserving content and ownership boundaries.

Validated with focused contracts, runtime, public Run and CLI regressions;
eight real native agents and a real BYOK request/production pair; guard and
workspace typecheck. Refs #8701.

* fix(web): describe BYOK project file tools

Explain that API-provider project runs use OpenCode file tools and require
OpenCode to be available. Update the existing notice in all 19 locales and
its component/browser regressions.

Validated with red/green notice checks, real settings and preview follow-ups,
guard and workspace typecheck. Refs #8701.

* fix(od-next): preserve hidden prototype form feedback

Keep HTML hidden semantics within the shared layout layer without overriding
authored product CSS or hidden-until-found behavior. This fixes name errors
remaining visible after a successful registration.

Validated with a red/green Chromium regression, three resource-boundary
checks, a real Claude follow-up with desktop/mobile form interaction, guard
and workspace typecheck. Refs #8701.

* test(od-next): make Run fixtures select their execution path

Explicitly select ordinary Runs for retry persistence, plain stdout replay
and the compatibility single-run telemetry case. Keep task hierarchy and
terminal fallback telemetry cases active, and assert the selected path.

Restore owner write permission to the leftover-agent fixture's frozen
directories only during teardown so recursive deletion works on POSIX.
Skip symlinks and preserve cleanup errors and every original behavior check.

Reproduced all four CI failures before the fix. Focused daemon and E2E
checks passed (12 tests), along with guard, workspace typecheck and focused
LSP diagnostics. Refs #8701.

* fix(daemon): avoid passing Pi resource directories as prompt files

Pi reads existing --append-system-prompt paths as files. Stop passing skills, design systems and task input directories there; their resource paths already travel in the composed RPC prompt.

Validated with 104 focused runtime/RPC tests, a real Pi 1.1.0 offline startup red/green probe, workspace typecheck and guard.

* fix(web): coalesce streamed calls in generation progress

Fold in-flight snapshots and terminal inputs through the existing tool-event reducer before rendering the Design Files progress feed. Keep the latest input, stable call identity and the limit of twelve distinct calls.

Reproduced the duplicate React key warning before the fix. Runtime and progress-feed regressions now pass alongside existing in-flight tool-row tests.

* fix(web): preserve forms and browser storage in prototype previews

Permit client-side form submission in opaque interactive previews. Route Web Storage artifacts through the existing isolated powered preview, including daemon hints for large files, and retain guards when isolation is unavailable.

Verified URL and srcDoc form interaction, persisted values after reload, and host isolation with focused Chromium regressions. Focused viewer/runtime and daemon tests, workspace typecheck and guard passed.

* test(web): keep srcDoc fixtures on the opaque preview path

Use an existing non-storage sandbox-shim signal in transport lifecycle fixtures now that Web Storage artifacts select the isolated preview origin. Preserve every reload, activation, recovery and manual-save assertion.

Validated all 338 cases in the three affected FileViewer test files, workspace guard and typecheck, and focused diagnostics.

* fix(byok): accept keyless IPv6 loopback providers

Recognize the bracketed IPv6 hostname returned by WHATWG URL parsing in both settings validation and the OpenCode BYOK runtime. Keep remote hosts key-required.

Validated with focused daemon and Web tests, settings browser checks, workspace typecheck and guard.

* fix(daemon): restore Pi sessions and preserve completed RPC turns

Load prior Pi history with switch_session and capture the authoritative sessionFile through nonblocking get_state. Treat a host-requested SIGTERM after a clean completed turn as shutdown, including Pi builds that report exit 143, while preserving real errors and cancellation.

Validated with focused protocol and public Run lifecycle tests, a real Pi generation and native resumed edit, workspace typecheck and guard.

* fix(preview): isolate project storage and preserve hidden controls

Give powered previews stable per-project loopback origins and reserve those hosts for matching powered GETs before API or static exceptions. Advertise the capability so newer Web clients retain the opaque fallback with older daemons.

Reuse the active powered transport in Present. Share a low-specificity hidden-control guard across URL and srcDoc previews without overriding explicit author rules or until-found; preserve doctype mode when synthesizing a missing head.

Validated with actual two-project storage, Workers, reload, Present and API isolation browser witnesses, form/hidden regressions, focused Web and daemon checks, workspace typecheck and guard.

* test(e2e): align public canaries with best-effort OD Next

Read the remaining recipe identity marker without the retired serialized package hash. Make the fake OpenCode help probe finite. Exercise the current public question-form action, completed question turns/new answer tasks, and no-artifact normal endings while retaining cancellation, artifact and deck assertions.

Validated with six fake-runtime cases and focused active/off public browser canaries with explicit rollout flags; no production completion policy or timeout was relaxed.

* fix(od-next): guide focus restoration after list updates

Prefer native modal lifecycle and restore focus to a current control after list rerenders, falling back to the primary action when a filtered item disappears. Real OpenCode and Pi prototypes exposed detached-trigger focus loss.

Keep this as best-effort build guidance, without output contracts or automatic repair rounds. Validated the actual artifacts through normal public edit tasks and browser interaction checks; profile resource tests passed.

* fix(od-next): retain focus through inline task updates

Extend the existing live-control guidance to inline list actions. Real Copilot and Qoder boards also lost keyboard focus when status updates rebuilt their cards; hidden filtered items need a visible fallback.

* fix: align Discovery integration tests and presentation storage

* test(daemon): tolerate transient memory origin cleanup races

---------

Co-authored-by: zhouzheng <zhouzheng@refly.ai>
2026-10-22 05:14:59 +00:00

1079 lines
38 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from '@/playwright/suite';
import type { Locator, Page } from '@playwright/test';
import { openSettingsDialog, settingsSurface } from '../lib/playwright/amr.js';
import { routeAgents, suppressWhatsNew } from '../lib/playwright/mock-factory.js';
import { T } from '@/timeouts';
const STORAGE_KEY = 'open-design:config';
const OPEN_SETTINGS_LABEL = /Open settings|打开设置|開啟設定|Account & settings/i;
const LOCAL_CLI_LABEL = /Local CLI|本机 CLI|本地 CLI/i;
const MODEL_POPOVER_SELECTOR = '.model-select-searchable__popover';
test.describe.configure({ timeout: T.xlong });
test.beforeEach(async ({ page }) => {
await suppressWhatsNew(page);
});
async function waitForLoadingToClear(page: Page) {
await expect(page.getByText('Loading OpenDesign…')).toHaveCount(0, { timeout: T.long });
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve OpenDesign' });
if (await privacyDialog.isVisible()) {
await privacyDialog.getByRole('button', { name: /I get it|not now|got it|don't share/i }).click();
}
const settingsButton = page
.getByTestId('entry-settings-button')
.or(page.getByTestId('entry-nav-settings'))
.or(page.getByRole('button', { name: OPEN_SETTINGS_LABEL }))
.first();
await expect(settingsButton).toBeVisible();
}
async function openSettingsDialogFromEntry(page: Page) {
const dialog = await openSettingsDialog(page);
await dialog.getByTestId('settings-nav-execution').click();
return dialog;
}
async function closeSettingsDialogIfOpen(page: Page) {
const dialog = settingsSurface(page);
if ((await dialog.count()) === 0) return;
await closeEntrySettings(page, dialog);
}
async function closeEntrySettings(page: Page, dialog = settingsSurface(page)) {
const backToHome = dialog.getByRole('button', { name: /Back to home/i });
await expect(backToHome).toBeEnabled();
await backToHome.click();
await expect(settingsSurface(page)).toHaveCount(0);
}
async function openExecutionSettings(
page: Page,
config: Record<string, unknown>,
) {
let appConfig = { ...config };
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: config },
);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'PUT') {
appConfig = {
...appConfig,
...(route.request().postDataJSON() as Record<string, unknown>),
};
await route.fulfill({ json: { config: appConfig } });
return;
}
await route.fulfill({ json: { config: appConfig } });
});
await page.route('**/api/health', async (route) => {
await route.fulfill({ status: 503, body: 'offline' });
});
await gotoEntryHome(page);
await openSettingsDialogFromEntry(page);
}
async function readSavedConfig(page: Page) {
return page.evaluate((key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
}, STORAGE_KEY);
}
function modelCombobox(scope: Page | Locator) {
return scope.getByRole('combobox', { name: 'Model', exact: true });
}
function providerPresetCombobox(scope: Page | Locator) {
return scope.getByLabel(/Provider preset|Gateway preset|Quick fill provider/i);
}
async function selectComboboxOption(
page: Page,
combobox: Locator,
optionName: RegExp | string,
popoverSelector: string,
) {
await combobox.click();
const popover = page.locator(popoverSelector).last();
await expect(popover).toBeVisible();
await popover.getByRole('option', { name: optionName }).click();
}
async function expectModelComboboxText(
scope: Page | Locator,
pattern: RegExp | string,
) {
await expect(modelCombobox(scope)).toContainText(pattern);
}
async function openExecutionSettingsWithAgents(
page: Page,
config: Record<string, unknown>,
agents: Array<{
id: string;
name: string;
bin: string;
available: boolean;
version?: string | null;
models?: Array<{ id: string; label: string }>;
}>,
) {
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: config },
);
await page.route('**/api/health', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
});
await routeAgents(page, agents);
await gotoEntryHome(page);
await openSettingsDialogFromEntry(page);
}
test('[P1] known OpenAI provider is selected and can switch to Anthropic defaults', async ({ page }) => {
await openExecutionSettings(page, {
mode: 'api',
apiKey: 'sk-test',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-v4-flash',
apiProviderBaseUrl: 'https://api.deepseek.com',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
});
const dialog = settingsSurface(page);
const protocolTabs = dialog.getByRole('tablist', { name: 'API protocol' });
const deepSeekTab = protocolTabs.getByRole('tab', { name: 'DeepSeek', exact: true });
const anthropicTab = protocolTabs.getByRole('tab', { name: 'Anthropic', exact: true });
const baseUrlInput = dialog.getByLabel('Base URL');
// Use getByRole + exact so we only match the chat "Model" picker and
// not the inline "Memory model" picker that sits next to it.
const modelSelect = modelCombobox(dialog);
await expect(deepSeekTab).toHaveAttribute('aria-selected', 'true');
await expect(dialog.getByRole('heading', { name: 'OpenAI API' })).toBeVisible();
await expect(baseUrlInput).toHaveValue('https://api.deepseek.com');
await expect(modelSelect).toContainText(/deepseek-v4-flash/i);
await anthropicTab.click();
await expect(anthropicTab).toHaveAttribute('aria-selected', 'true');
await expect(dialog.getByRole('heading', { name: 'Anthropic API' })).toBeVisible();
await expect(baseUrlInput).toHaveValue('https://api.anthropic.com');
await expect(modelSelect).toContainText(/claude-sonnet-4-5/i);
});
test('[P1] custom OpenAI provider is selected and can switch to Anthropic defaults', async ({ page }) => {
await openExecutionSettings(page, {
mode: 'api',
apiKey: 'sk-test',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://my-proxy.example.com/v1',
model: 'my-custom-model',
apiProviderBaseUrl: null,
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
});
const dialog = settingsSurface(page);
const protocolTabs = dialog.getByRole('tablist', { name: 'API protocol' });
const customTab = protocolTabs.getByRole('tab', { name: 'Custom provider', exact: true });
const anthropicTab = protocolTabs.getByRole('tab', { name: 'Anthropic', exact: true });
const baseUrlInput = dialog.getByLabel('Base URL');
const customModelInput = dialog.getByLabel(/Custom model id/i);
await expect(customTab).toHaveAttribute('aria-selected', 'true');
await expect(dialog.getByRole('heading', { name: 'OpenAI API' })).toBeVisible();
await expect(baseUrlInput).toHaveValue('https://my-proxy.example.com/v1');
await expect(customModelInput).toHaveValue('my-custom-model');
await anthropicTab.click();
await expect(anthropicTab).toHaveAttribute('aria-selected', 'true');
await expect(dialog.getByRole('heading', { name: 'Anthropic API' })).toBeVisible();
await expect(baseUrlInput).toHaveValue('https://api.anthropic.com');
await expect(modelCombobox(dialog)).toContainText(/claude-sonnet-4-5/i);
});
test('[P0] @critical BYOK quick fill provider updates fields and saved settings persist after closing and reopening', async ({ page }) => {
await openExecutionSettings(page, {
mode: 'api',
apiKey: '',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
});
const dialog = settingsSurface(page);
await dialog.getByRole('tab', { name: 'OpenAI', exact: true }).click();
const providerPicker = providerPresetCombobox(dialog);
await selectComboboxOption(page, providerPicker, /DeepSeek — OpenAI/i, '[data-testid="settings-byok-provider-preset-popover"]');
await expectModelComboboxText(dialog, /deepseek-v4-flash/i);
await expect(dialog.getByLabel('Base URL')).toHaveValue('https://api.deepseek.com');
await dialog.getByRole('button', { name: 'Show' }).click();
const apiKeyInput = dialog.getByLabel('API key');
await expect(apiKeyInput).toHaveAttribute('type', 'text');
await apiKeyInput.fill('sk-openai-test');
await expect
.poll(async () => readSavedConfig(page))
.toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: 'sk-openai-test',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-v4-flash',
apiProviderBaseUrl: 'https://api.deepseek.com',
});
await closeEntrySettings(page, dialog);
const savedConfig = await readSavedConfig(page);
expect(savedConfig).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: 'sk-openai-test',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-v4-flash',
apiProviderBaseUrl: 'https://api.deepseek.com',
});
await openSettingsDialogFromEntry(page);
const reopenedDialog = settingsSurface(page);
await expect(reopenedDialog.getByRole('tab', { name: 'DeepSeek', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(providerPresetCombobox(reopenedDialog)).toContainText(/DeepSeek — OpenAI/i);
await expectModelComboboxText(reopenedDialog, /deepseek-v4-flash/i);
await expect(reopenedDialog.getByLabel('Base URL')).toHaveValue('https://api.deepseek.com');
await expect(reopenedDialog.getByLabel('API key')).toHaveValue('sk-openai-test');
});
test('[P1] BYOK Anthropic gateway preset updates fields and persists after reopening', async ({ page }) => {
await openExecutionSettings(page, {
mode: 'api',
apiKey: 'sk-test',
apiProtocol: 'anthropic',
apiVersion: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
});
const dialog = settingsSurface(page);
const protocolTabs = dialog.getByRole('tablist', { name: 'API protocol' });
const anthropicTab = protocolTabs.getByRole('tab', { name: 'Anthropic', exact: true });
await expect(anthropicTab).toHaveAttribute('aria-selected', 'true');
await selectComboboxOption(
page,
providerPresetCombobox(dialog),
/DeepSeek — Anthropic/i,
'[data-testid="settings-byok-provider-preset-popover"]',
);
await expect(providerPresetCombobox(dialog)).toContainText(/DeepSeek — Anthropic/i);
await expect(dialog.getByLabel('Base URL')).toHaveValue('https://api.deepseek.com/anthropic');
await expectModelComboboxText(dialog, /deepseek-v4-flash/i);
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
apiProtocol: 'anthropic',
baseUrl: 'https://api.deepseek.com/anthropic',
model: 'deepseek-v4-flash',
apiProviderBaseUrl: 'https://api.deepseek.com/anthropic',
});
await closeEntrySettings(page, dialog);
await openSettingsDialogFromEntry(page);
const reopenedDialog = settingsSurface(page);
await expect(providerPresetCombobox(reopenedDialog)).toContainText(/DeepSeek — Anthropic/i);
await expect(reopenedDialog.getByLabel('Base URL')).toHaveValue('https://api.deepseek.com/anthropic');
await expectModelComboboxText(reopenedDialog, /deepseek-v4-flash/i);
});
test('[P1] BYOK Ollama Cloud exposes refreshed model choices and persists selection', async ({ page }) => {
await openExecutionSettings(page, {
mode: 'api',
apiKey: 'ollama-key',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
});
const dialog = settingsSurface(page);
const protocolTabs = dialog.getByRole('tablist', { name: 'API protocol' });
await protocolTabs.getByRole('tab', { name: 'Ollama Cloud', exact: true }).click();
await expect(protocolTabs.getByRole('tab', { name: 'Ollama Cloud', exact: true })).toHaveAttribute(
'aria-selected',
'true',
);
await expect(providerPresetCombobox(dialog)).toContainText(/Ollama Cloud \(managed\)/i);
await expectModelComboboxText(dialog, /gpt-oss:120b/i);
await expect(dialog.getByLabel('Base URL')).toHaveValue('https://ollama.com');
await dialog.getByLabel('API key').fill('ollama-key');
await modelCombobox(dialog).click();
const popover = page.getByTestId('settings-byok-model-popover');
await expect(popover).toBeVisible();
await page.getByTestId('settings-byok-model-search').fill('kimi-k2.7');
await popover.getByRole('option', { name: /^kimi-k2\.7-code$/i }).click();
await expectModelComboboxText(dialog, /kimi-k2\.7-code/i);
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
apiProtocol: 'ollama',
baseUrl: 'https://ollama.com',
model: 'kimi-k2.7-code',
apiProviderBaseUrl: 'https://ollama.com',
});
await closeEntrySettings(page, dialog);
await openSettingsDialogFromEntry(page);
const reopenedDialog = settingsSurface(page);
const reopenedTabs = reopenedDialog.getByRole('tablist', { name: 'API protocol' });
await expect(reopenedTabs.getByRole('tab', { name: 'Ollama Cloud', exact: true })).toHaveAttribute(
'aria-selected',
'true',
);
await expect(providerPresetCombobox(reopenedDialog)).toContainText(/Ollama Cloud \(managed\)/i);
await expectModelComboboxText(reopenedDialog, /kimi-k2\.7-code/i);
});
test('[P1] BYOK connection test surfaces NVIDIA degraded provider detail', async ({ page }) => {
await page.route('**/api/provider/models', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
kind: 'success',
latencyMs: 15,
models: [
{
id: 'nvidia/llama-3.1-nemotron-ultra-253b-v1',
label: 'nvidia/llama-3.1-nemotron-ultra-253b-v1',
},
],
}),
});
});
await page.route('**/api/test/connection', async (route) => {
const payload = route.request().postDataJSON() as Record<string, unknown>;
expect(payload).toMatchObject({
protocol: 'openai',
baseUrl: 'https://integrate.api.nvidia.com/v1',
model: 'nvidia/llama-3.1-nemotron-ultra-253b-v1',
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: false,
kind: 'upstream_unavailable',
latencyMs: 42,
status: 400,
detail:
'The selected NVIDIA model instance is currently unavailable at the provider. Try a different model or retry later.',
}),
});
});
await openExecutionSettings(page, {
mode: 'api',
apiKey: 'nvapi-test',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://integrate.api.nvidia.com/v1',
model: 'nvidia/llama-3.1-nemotron-ultra-253b-v1',
apiProviderBaseUrl: 'https://integrate.api.nvidia.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
});
const dialog = settingsSurface(page);
await dialog.getByRole('button', { name: 'Test', exact: true }).click();
await expect(dialog.getByRole('alert').filter({ hasText: /Provider returned 400/i })).toContainText(
/Provider returned 400\. Try again in a moment\. The selected NVIDIA model instance is currently unavailable/,
);
});
test('[P0] BYOK autosave waits until required fields are valid', async ({ page }) => {
await openExecutionSettings(page, {
mode: 'api',
apiKey: '',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
});
const dialog = settingsSurface(page);
await expect(dialog.getByRole('button', { name: /Back to home/i })).toBeEnabled();
await dialog.getByLabel('API key').fill('sk-openai-test');
await expect.poll(async () => readSavedConfig(page)).toMatchObject({ apiKey: 'sk-openai-test' });
const baseUrlInput = dialog.getByLabel('Base URL');
// A non-http scheme is still rejected client-side. (An internal-IP URL is no
// longer rejected here — it is syntactically valid and the daemon owns the
// OD_ALLOWED_INTERNAL_HOSTS decision; see #3225.)
await baseUrlInput.fill('ftp://api.example.com');
await expect(dialog.locator('#settings-base-url-error')).toContainText(/public http:\/\/ or https:\/\//i);
await baseUrlInput.fill('http://localhost:11434/v1');
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
apiKey: 'sk-openai-test',
baseUrl: 'http://localhost:11434/v1',
});
});
test('[P1] BYOK OpenCode file-access notice is reachable from Settings', async ({ page }) => {
await openExecutionSettingsWithAgents(
page,
{
mode: 'api',
apiKey: 'sk-test',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: 'mock-agent',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
},
[
{
id: 'mock-agent',
name: 'Mock Agent',
bin: 'mock-agent',
available: true,
version: 'test',
models: [{ id: 'default', label: 'Default' }],
},
],
);
const dialog = settingsSurface(page);
await dialog.getByRole('tab', { name: 'OpenAI', exact: true }).click();
const trigger = dialog.getByTestId('settings-byok-no-file-tools-trigger');
const notice = dialog.getByTestId('settings-byok-no-file-tools-notice');
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAccessibleName('BYOK uses OpenCode to read, write, and edit project files. OpenCode must be installed and available.');
await trigger.hover();
await expect(notice).toBeVisible();
await expect(notice).toContainText('BYOK uses OpenCode to read, write, and edit project files');
await expect(notice).toContainText('OpenCode must be installed and available');
await dialog.getByRole('tab', { name: /Google Gemini/i }).click();
await expect(dialog.getByTestId('settings-byok-no-file-tools-trigger')).toBeVisible();
await dialog.getByRole('tab', { name: LOCAL_CLI_LABEL }).click();
await expect(dialog.getByTestId('settings-byok-no-file-tools-trigger')).toHaveCount(0);
await expect(dialog.getByTestId('settings-byok-no-file-tools-notice')).toHaveCount(0);
});
test('[P0] BYOK auto-loads provider models and reuses cached results for the same config', async ({ page }) => {
const providerModelRequests: Array<Record<string, unknown>> = [];
await page.route('**/api/provider/models', async (route) => {
const payload = route.request().postDataJSON() as Record<string, unknown>;
providerModelRequests.push(payload);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
kind: 'success',
latencyMs: 15,
models: [
{ id: 'aa-prerelease-model', label: 'AA Prerelease Model' },
{ id: 'mm-prerelease-model', label: 'MM Prerelease Model' },
{ id: 'zz-prerelease-model', label: 'ZZ Prerelease Model' },
],
}),
});
});
await openExecutionSettings(page, {
mode: 'api',
apiKey: '',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
});
const dialog = settingsSurface(page);
const modelSelect = modelCombobox(dialog);
const apiKeyInput = dialog.getByLabel('API key');
await expect(dialog.getByRole('button', { name: 'Fetch models' })).toHaveCount(0);
await expect(page.locator(MODEL_POPOVER_SELECTOR).getByRole('option', { name: 'AA Prerelease Model (aa-prerelease-model)' })).toHaveCount(0);
await apiKeyInput.fill('sk-openai-test');
await apiKeyInput.blur();
await expect(dialog.getByText('Loaded 3 models from your account.')).toBeVisible();
await expect.poll(() => providerModelRequests.length).toBe(1);
expect(providerModelRequests[0]).toMatchObject({
protocol: 'openai',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-openai-test',
});
await modelSelect.click();
const modelPopover = page.locator(MODEL_POPOVER_SELECTOR).last();
await expect(modelPopover.getByRole('option', { name: 'AA Prerelease Model (aa-prerelease-model)' })).toHaveCount(1);
await expect(modelPopover.getByRole('option', { name: 'MM Prerelease Model (mm-prerelease-model)' })).toHaveCount(1);
await expect(modelPopover.getByRole('option', { name: 'ZZ Prerelease Model (zz-prerelease-model)' })).toHaveCount(1);
await page.keyboard.press('Escape');
await closeSettingsDialogIfOpen(page);
await openSettingsDialogFromEntry(page);
const reopenedDialog = settingsSurface(page);
await expect(reopenedDialog.getByRole('tab', { name: 'OpenAI', exact: true })).toHaveAttribute('aria-selected', 'true');
await modelCombobox(reopenedDialog).click();
await expect(page.locator(MODEL_POPOVER_SELECTOR).last().getByRole('option', { name: 'AA Prerelease Model (aa-prerelease-model)' })).toHaveCount(1);
await page.keyboard.press('Escape');
await expect.poll(() => providerModelRequests.length).toBe(1);
});
test('[P0] @critical BYOK clearing the API key restores the suggested OpenAI model list', async ({ page }) => {
const providerModelRequests: Array<Record<string, unknown>> = [];
await page.route('**/api/provider/models', async (route) => {
const payload = route.request().postDataJSON() as Record<string, unknown>;
providerModelRequests.push(payload);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
kind: 'success',
latencyMs: 15,
models: [
{ id: 'account-only-model', label: 'Account Only Model' },
],
}),
});
});
await openExecutionSettings(page, {
mode: 'api',
apiKey: '',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
});
const dialog = settingsSurface(page);
const apiKeyInput = dialog.getByLabel('API key');
const modelSelect = modelCombobox(dialog);
await apiKeyInput.fill('sk-openai-test');
await apiKeyInput.blur();
await expect.poll(() => providerModelRequests.length).toBe(1);
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
apiKey: 'sk-openai-test',
});
await modelSelect.click();
await expect(page.locator(MODEL_POPOVER_SELECTOR).last().getByRole('option', {
name: 'Account Only Model (account-only-model)',
})).toBeVisible();
await dialog.getByRole('heading', { name: 'OpenAI API' }).click();
await expect(page.locator(MODEL_POPOVER_SELECTOR)).toHaveCount(0);
await expect(apiKeyInput).toBeVisible();
await apiKeyInput.fill('');
await apiKeyInput.blur();
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
apiKey: '',
model: 'gpt-4o',
});
await modelSelect.click();
const popover = page.locator(MODEL_POPOVER_SELECTOR).last();
await expect(popover.getByText('gpt-4o', { exact: true })).toBeVisible();
await expect(popover.getByText('gpt-4o-mini', { exact: true })).toBeVisible();
await expect(popover.getByRole('option', { name: 'Account Only Model (account-only-model)' })).toHaveCount(0);
});
test('[P0] @critical BYOK fetched models are searchable inside the Settings model dropdown', async ({ page }) => {
const providerModelRequests: Array<Record<string, unknown>> = [];
await page.route('**/api/provider/models', async (route) => {
const payload = route.request().postDataJSON() as Record<string, unknown>;
providerModelRequests.push(payload);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
kind: 'success',
latencyMs: 15,
models: [
{ id: 'aa-prerelease-model', label: 'AA Prerelease Model' },
{ id: 'bb-prerelease-model', label: 'BB Prerelease Model' },
{ id: 'cc-prerelease-model', label: 'CC Prerelease Model' },
{ id: 'dd-prerelease-model', label: 'DD Prerelease Model' },
{ id: 'ee-prerelease-model', label: 'EE Prerelease Model' },
{ id: 'ff-prerelease-model', label: 'FF Prerelease Model' },
{ id: 'gg-prerelease-model', label: 'GG Prerelease Model' },
{ id: 'hh-prerelease-model', label: 'HH Prerelease Model' },
{ id: 'mm-prerelease-model', label: 'MM Prerelease Model' },
{ id: 'zz-prerelease-model', label: 'ZZ Prerelease Model' },
],
}),
});
});
await openExecutionSettings(page, {
mode: 'api',
apiKey: '',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
});
const dialog = settingsSurface(page);
await dialog.getByLabel('API key').fill('sk-openai-test');
await dialog.getByLabel('API key').blur();
await expect(dialog.getByText('Loaded 10 models from your account.')).toBeVisible();
await expect.poll(() => providerModelRequests.length).toBe(1);
await modelCombobox(dialog).click();
const popover = page.getByTestId('settings-byok-model-popover');
const search = page.getByTestId('settings-byok-model-search');
await expect(popover).toBeVisible();
await expect(search).toBeVisible();
await search.fill('mm-prerelease');
await expect(popover.getByRole('option', { name: 'MM Prerelease Model (mm-prerelease-model)' })).toBeVisible();
await expect(popover.getByRole('option', { name: 'BB Prerelease Model (bb-prerelease-model)' })).toHaveCount(0);
});
test('[P1] BYOK model fetch failure keeps the current model and recovers after key update', async ({ page }) => {
const providerModelRequests: Array<Record<string, unknown>> = [];
await page.route('**/api/provider/models', async (route) => {
const payload = route.request().postDataJSON() as Record<string, unknown>;
providerModelRequests.push(payload);
if (providerModelRequests.length === 1) {
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: { message: 'provider temporarily unavailable' } }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
kind: 'success',
latencyMs: 12,
models: [
{ id: 'retry-nightly-model', label: 'Retry Nightly Model' },
{ id: 'stable-nightly-model', label: 'Stable Nightly Model' },
],
}),
});
});
await openExecutionSettings(page, {
mode: 'api',
apiKey: '',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
});
const dialog = settingsSurface(page);
const modelSelect = modelCombobox(dialog);
await dialog.getByLabel('API key').fill('sk-openai-test');
await dialog.getByLabel('API key').blur();
await expect(dialog.getByText(/Could not fetch models: provider temporarily unavailable/i)).toBeVisible();
await expectModelComboboxText(dialog, /gpt-4o/i);
await expect.poll(() => providerModelRequests.length).toBe(1);
await dialog.getByLabel('API key').fill('sk-openai-retry');
await dialog.getByLabel('API key').blur();
await expect(dialog.getByText('Loaded 2 models from your account.')).toBeVisible();
await expect.poll(() => providerModelRequests.length).toBe(2);
await modelSelect.click();
await page.locator(MODEL_POPOVER_SELECTOR).last().getByRole('option', { name: 'Retry Nightly Model (retry-nightly-model)' }).click();
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
model: 'retry-nightly-model',
});
});
test('[P1] Settings autosave failure surfaces an error instead of reporting saved changes', async ({ page }) => {
const config = {
mode: 'api',
apiKey: 'sk-openai-test',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
};
const putBodies: Array<Record<string, unknown>> = [];
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: config },
);
await page.route('**/api/health', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
});
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { config } });
return;
}
if (route.request().method() === 'PUT') {
putBodies.push(route.request().postDataJSON() as Record<string, unknown>);
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'daemon unavailable' }),
});
return;
}
await route.continue();
});
await gotoEntryHome(page);
await openSettingsDialogFromEntry(page);
const dialog = settingsSurface(page);
await dialog.getByLabel('Base URL').fill('https://proxy.example.com/v1');
await dialog.getByLabel('Base URL').blur();
await expect.poll(() => putBodies.length).toBeGreaterThan(0);
await expect(dialog.getByText("Couldn’t save changes. The local daemon may be offline.")).toBeVisible();
await expect(dialog.getByText('All changes saved')).toHaveCount(0);
});
test('[P1] Settings autosave recovers after a later successful daemon sync', async ({ page }) => {
const config = {
mode: 'api',
apiKey: 'sk-openai-test',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
};
const putBodies: Array<Record<string, unknown>> = [];
let daemonSyncRecovered = false;
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: config },
);
await page.route('**/api/health', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
});
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { config } });
return;
}
if (route.request().method() === 'PUT') {
const payload = route.request().postDataJSON() as Record<string, unknown>;
putBodies.push(payload);
if (!daemonSyncRecovered) {
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'daemon unavailable' }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ config: payload }),
});
return;
}
await route.continue();
});
await gotoEntryHome(page);
await openSettingsDialogFromEntry(page);
const dialog = settingsSurface(page);
const baseUrl = dialog.getByLabel('Base URL');
await baseUrl.fill('https://proxy.example.com/v1');
await baseUrl.blur();
await expect.poll(() => putBodies.length).toBeGreaterThan(0);
await expect(dialog.getByText("Couldn’t save changes. The local daemon may be offline.")).toBeVisible();
const failureRequestCount = putBodies.length;
daemonSyncRecovered = true;
await baseUrl.fill('https://proxy-recovered.example.com/v1');
await baseUrl.blur();
await expect.poll(() => putBodies.length).toBeGreaterThan(failureRequestCount);
await expect(dialog.getByText('All changes saved')).toBeVisible();
await expect(dialog.getByText("Couldn’t save changes. The local daemon may be offline.")).toHaveCount(0);
});
test('[P0] @critical saving Local CLI updates the entry status pill with the selected agent', async ({ page }) => {
test.setTimeout(60_000);
await openExecutionSettingsWithAgents(
page,
{
mode: 'api',
apiKey: 'sk-openai-test',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
},
[
{
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
available: true,
version: '0.80.0',
models: [{ id: 'default', label: 'Default' }],
},
{
id: 'gemini',
name: 'Gemini CLI',
bin: 'gemini',
available: false,
version: null,
models: [],
},
],
);
const dialog = settingsSurface(page);
await dialog.getByRole('tab', { name: LOCAL_CLI_LABEL }).click();
const codexAgent = dialog.getByTestId('settings-agent-select-codex');
await expect(codexAgent).toBeVisible();
await codexAgent.click();
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
mode: 'daemon',
agentId: 'codex',
});
await closeEntrySettings(page, dialog);
const executionPill = page.getByTestId('inline-model-switcher-chip');
await expect(executionPill).toHaveAccessibleName(/Codex CLI · default/i);
});
test('[P0] @critical Settings keeps Local CLI and BYOK model choices isolated after reopening', async ({ page }) => {
test.setTimeout(60_000);
await openExecutionSettingsWithAgents(
page,
{
mode: 'api',
apiKey: 'sk-openai-test',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
},
[
{
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
available: true,
version: '0.80.0',
models: [
{ id: 'default', label: 'Default' },
{ id: 'gpt-5.5', label: 'GPT 5.5' },
],
},
],
);
const dialog = settingsSurface(page);
await dialog.getByRole('tab', { name: LOCAL_CLI_LABEL }).click();
await dialog.getByTestId('settings-agent-select-codex').click();
await dialog.getByRole('combobox', { name: 'Model', exact: true }).click();
await page.getByTestId('settings-agent-model-popover-codex').getByRole('option', { name: /GPT 5\.5/i }).click();
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
mode: 'daemon',
agentId: 'codex',
agentModels: {
codex: { model: 'gpt-5.5' },
},
});
await dialog.getByRole('tab', { name: 'API providers' }).click();
await dialog.getByRole('tab', { name: 'OpenAI', exact: true }).click();
await modelCombobox(dialog).click();
await page.getByTestId('settings-byok-model-popover').getByRole('option', { name: /^gpt-4o-mini$/i }).click();
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
mode: 'api',
model: 'gpt-4o-mini',
agentModels: {
codex: { model: 'gpt-5.5' },
},
});
await closeEntrySettings(page, dialog);
await openSettingsDialogFromEntry(page);
const reopened = settingsSurface(page);
await expect(reopened.getByRole('tab', { name: 'OpenAI', exact: true })).toHaveAttribute('aria-selected', 'true');
await expectModelComboboxText(reopened, /gpt-4o-mini/i);
await reopened.getByRole('tab', { name: LOCAL_CLI_LABEL }).click();
await reopened.getByTestId('settings-agent-select-codex').click();
await expect(reopened.getByRole('combobox', { name: 'Model', exact: true })).toContainText(/GPT 5\.5/i);
});