mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(daemon): expose Vela login through CLI and MCP * feat(mcp): add interactive local brief card * feat(packaged): bootstrap local MCP headlessly * feat(daemon): secure local BYOK credential profiles * feat(cli): manage secure BYOK profiles * feat(web): store BYOK keys in secure profiles * fix(runs): deduplicate cloud generation requests * feat(daemon): add end-to-end plugin observability * fix(pack): include native PTY runtime in desktop packages Lazy-load node-pty in the daemon so missing native binaries only disable Terminal and interactive Keychain operations instead of crashing startup. Ship and validate target prebuilds in macOS and Windows packages. Repair the macOS spawn helper before signing, and invalidate stale Windows packaging caches. Cover the packaged PTY path in macOS and Windows smoke tests. Require Vela CLI for every beta desktop build target. * fix(mcp): localize and stabilize plugin briefs Keep collect and confirm on one MCP Apps card, report intrinsic size changes, and remove the fragile widget-state fallback. Localize brief forms, summaries, status, and public errors from the current request or Host locale while preserving stable protocol ids and selections. Keep internal runtime identifiers out of user-facing MCP guidance. * fix(packaged): register live Studio URLs with daemon Register the packaged web sidecar loopback origin over namespace-scoped daemon IPC after dynamic binding. Invalidate install-info by the live web port and remove the second MCP URL cache so run and project responses use the current Studio address. Validate loopback origins and cover registration, rebind, and cache refresh. * fix(mcp): stabilize brief confirmation lifecycle Keep confirmed briefs locked after Host publication failures and retry only the cached follow-up message instead of confirming twice. Replace blank initialization with a compact loading state, accept immediate and delayed tool results, and bump the MCP Apps resource to v3 so Codex does not reuse the stale card. Add an executable JSDOM Host bridge harness covering double-submit, publication retry, delayed results, and repeated draft notifications. Validated with the full daemon suite, workspace typecheck, and guard. * fix(mcp): prevent brief app resize feedback loops Replace the self-observed intrinsic-height loop with deduplicated body measurements so Codex MCP app iframes do not repeatedly resize themselves and fail sandbox startup. Bump the brief resource URI to v4 to avoid cached v3 cards and cover the regression with the focused brief-app spec. Validated with daemon tests/build, workspace guard/typecheck, a verified beta DMG, and a direct packaged MCP resource smoke. * fix(mcp): use the final Open Design plugin identity Make open-design the only accepted external plugin ID across MCP tools, run attribution, artifact version origins, and telemetry. Rename the Brief MCP App resource to v5 and reject the unpublished old ID instead of aliasing it. Validated with 171 daemon tests, 5 web tests, daemon/web typechecks, and guard. * chore(pack): bump Vela CLI to 0.0.27 * chore(nix): refresh pnpm dependency hashes * fix(mcp): honor host and plugin request contracts Respect independently advertised MCP Apps capabilities and prefer the Codex-native follow-up bridge so confirmed briefs render as readable user turns instead of context-only messages. Bump the brief resource to v6 to invalidate stale cards.\n\nMark Vela login and account probes as open-world interactions, and require stable request IDs for attributed generation so lost responses cannot create a second logical run. * fix(cli): support keyless BYOK profiles Add an explicit --no-api-key save mode for local and self-hosted providers while keeping credential-bearing profiles stdin-only. Reject missing or conflicting credential modes and cover both paths in the CLI tests. * fix(pack): replace headless owner when desktop opens Advertise headless packaged runtimes as having no visible window, then shut them down before a normal desktop launch continues instead of treating SHOW as a successful focus. Validated with the focused packaged headless and launcher lifecycle tests (20 passed). * fix(pack): clean up failed headless startup Acquire the headless identity, sidecars, and IPC server through one failure-atomic lifecycle so later bootstrap errors close every previously acquired owner in reverse order. Cover MCP-install and web-identity publication failures; validated with 22 focused tests and the packaged typecheck. * fix(byok): remove raw provider request contract Expose only daemon-owned BYOK profile references from shared run types and the web transport, while retaining the daemon's fail-closed rejection for legacy or untyped raw credential payloads. Validated with 76 web provider tests plus web and contracts typechecks. * fix(daemon): preserve interrupted run idempotency Hydrate queued or running durable requests as DAEMON_RESTARTED failures before idempotency reuse, preserving the original request and workflow mappings instead of creating a second cloud operation. Share the restart transition with boot reconciliation and expose synthetic terminal events to reattaching clients. Validated with 48 focused tests and the daemon typecheck. * fix(byok): roll back failed credential metadata writes Snapshot secure-store values before mutation and compensate create, update, and delete operations when profile metadata cannot be persisted, preventing orphaned or lost credentials. Validated with nine focused credential-service tests and the daemon typecheck. * fix(web): reconcile selected BYOK profile metadata Treat the daemon's selected secure profile as authoritative for protocol, endpoint, model, and API version so CLI profile edits cannot leave the browser showing stale execution settings. Validated with 66 config tests and the web typecheck. * fix(byok): migrate legacy credentials before cleanup Import each supported legacy browser credential into a stable daemon-owned secure profile before removing plaintext copies. Keep the original browser record on secure-store or unsupported-provider failures and surface a Settings recovery path. Cover successful migration, duplicate projections, and injected secure-backend failure while preserving existing Bedrock downgrade behavior in memory. * fix(byok): secure Windows profiles with DPAPI Dispatch native Windows BYOK profiles to a CurrentUser DPAPI backend rooted under the resolved daemon data directory. Secrets enter PowerShell only through stdin, encrypted blobs stay separate from non-secret profile metadata, and API responses remain secret-free. Add platform dispatch coverage, a Windows-only real DPAPI round-trip test, and the packaged Windows smoke path for create, resolve, response-redaction, and delete behavior. * fix(web): preserve config writes during hydration * fix(byok): persist secure credentials across entry flows * test(byok): cover secure onboarding persistence --------- Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com> Co-authored-by: lefarcen <935902669@qq.com>
1106 lines
39 KiB
TypeScript
1106 lines
39 KiB
TypeScript
import { expect, test } from '@/playwright/suite';
|
||
import type { Locator, Page } from '@playwright/test';
|
||
import { openSettingsDialog } from '../lib/playwright/amr.js';
|
||
import { routeAgents } 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 });
|
||
|
||
async function waitForLoadingToClear(page: Page) {
|
||
await expect(page.getByText('Loading Open Design…')).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 Open Design' });
|
||
if (await privacyDialog.isVisible()) {
|
||
await privacyDialog.getByRole('button', { name: /I get it|not now|got it|don't share/i }).click();
|
||
}
|
||
await expect(page.getByRole('button', { name: OPEN_SETTINGS_LABEL })).toBeVisible();
|
||
}
|
||
|
||
async function openSettingsDialogFromEntry(page: Page) {
|
||
return openSettingsDialog(page);
|
||
}
|
||
|
||
async function closeSettingsDialogIfOpen(page: Page) {
|
||
const dialog = page.getByRole('dialog');
|
||
if ((await dialog.count()) === 0) return;
|
||
await page.keyboard.press('Escape');
|
||
try {
|
||
await expect(dialog).toHaveCount(0, { timeout: T.short });
|
||
return;
|
||
} catch {
|
||
// Fall back to the chrome button if focus is inside a nested popover or
|
||
// another transient surface swallowed Escape.
|
||
}
|
||
const closeButton = dialog.getByRole('button', { name: 'Close', exact: true });
|
||
if ((await closeButton.count()) > 0) {
|
||
await closeButton.click({ force: true, timeout: T.short });
|
||
}
|
||
await expect(dialog).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(/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}' });
|
||
});
|
||
if (typeof config.byokProfileId === 'string') {
|
||
await page.route('**/api/byok/profiles', async (route) => {
|
||
await route.fulfill({
|
||
json: {
|
||
available: true,
|
||
backend: 'test',
|
||
profiles: [{
|
||
id: config.byokProfileId,
|
||
label: 'OpenAI',
|
||
protocol: config.apiProtocol ?? 'openai',
|
||
baseUrl: config.baseUrl ?? '',
|
||
model: config.model ?? '',
|
||
requiresApiKey: true,
|
||
configured: true,
|
||
keyTail: config.byokCredentialTail ?? 'test',
|
||
createdAt: 1,
|
||
updatedAt: 1,
|
||
}],
|
||
},
|
||
});
|
||
});
|
||
}
|
||
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 = page.getByRole('dialog');
|
||
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 = page.getByRole('dialog');
|
||
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 = page.getByRole('dialog');
|
||
|
||
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: '',
|
||
baseUrl: 'https://api.deepseek.com',
|
||
model: 'deepseek-v4-flash',
|
||
apiProviderBaseUrl: 'https://api.deepseek.com',
|
||
});
|
||
|
||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
|
||
const savedConfig = await readSavedConfig(page);
|
||
expect(savedConfig).toMatchObject({
|
||
mode: 'api',
|
||
apiProtocol: 'openai',
|
||
apiKey: '',
|
||
baseUrl: 'https://api.deepseek.com',
|
||
model: 'deepseek-v4-flash',
|
||
apiProviderBaseUrl: 'https://api.deepseek.com',
|
||
});
|
||
|
||
await openSettingsDialogFromEntry(page);
|
||
const reopenedDialog = page.getByRole('dialog');
|
||
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 = page.getByRole('dialog');
|
||
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 dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
|
||
await openSettingsDialogFromEntry(page);
|
||
const reopenedDialog = page.getByRole('dialog');
|
||
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 = page.getByRole('dialog');
|
||
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 dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
|
||
await openSettingsDialogFromEntry(page);
|
||
const reopenedDialog = page.getByRole('dialog');
|
||
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 = page.getByRole('dialog');
|
||
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 save stays disabled 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 = page.getByRole('dialog');
|
||
const closeButton = dialog.getByRole('button', { name: 'Close', exact: true });
|
||
await expect(closeButton).toBeEnabled();
|
||
|
||
await dialog.getByLabel('API key').fill('sk-openai-test');
|
||
await expect.poll(async () => readSavedConfig(page)).toMatchObject({ apiKey: '' });
|
||
|
||
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: '',
|
||
baseUrl: 'http://localhost:11434/v1',
|
||
});
|
||
});
|
||
|
||
test('[P1] BYOK file-tools limitation 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 = page.getByRole('dialog');
|
||
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 can't read, write, or edit project files/i);
|
||
|
||
await trigger.hover();
|
||
await expect(notice).toBeVisible();
|
||
await expect(notice).toContainText("BYOK can't read, write, or edit project files");
|
||
await expect(notice).toContainText('Local CLI');
|
||
|
||
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 = page.getByRole('dialog');
|
||
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 = page.getByRole('dialog');
|
||
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 = page.getByRole('dialog');
|
||
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: '',
|
||
});
|
||
|
||
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 = page.getByRole('dialog');
|
||
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 = page.getByRole('dialog');
|
||
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 = page.getByRole('dialog');
|
||
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 = page.getByRole('dialog');
|
||
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 = page.getByRole('dialog');
|
||
|
||
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 dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
|
||
const executionPill = page.getByTestId('inline-model-switcher-chip');
|
||
await expect(executionPill).toContainText(LOCAL_CLI_LABEL);
|
||
await expect(executionPill).toContainText('Codex CLI');
|
||
await expect(executionPill).toContainText('default');
|
||
});
|
||
|
||
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: '',
|
||
apiProtocol: 'openai',
|
||
apiVersion: '',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
model: 'gpt-4o-mini',
|
||
apiProviderBaseUrl: 'https://api.openai.com/v1',
|
||
byokProfileId: 'byok-settings-model-isolation',
|
||
byokCredentialConfigured: true,
|
||
byokCredentialTail: 'test',
|
||
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 = page.getByRole('dialog');
|
||
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: 'BYOK' }).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 dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
|
||
await openSettingsDialogFromEntry(page);
|
||
const reopened = page.getByRole('dialog');
|
||
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);
|
||
});
|