Files
nexu-io-open-design/e2e/ui/settings-local-cli-codex-fallback.test.ts
Amy aad9771a6f fix(e2e): reduce noisy visual regression diffs (#4744)
* fix(e2e): reduce noisy visual regression diffs

* fix(e2e): keep visual captures full viewport

* test(e2e): add entry popover visual targets

* test(e2e): tighten model dropdown visual targets

* Revert "test(e2e): tighten model dropdown visual targets"

This reverts commit 52172f0559.

* fix(e2e): relax visual report diff ratio threshold

* fix(e2e): render visual report comparisons horizontally

* fix(e2e): tighten accepted visual diff ratio

* fix(e2e): surface missing visual baselines as new cases

* fix(e2e): render new visual baselines in a grid

* fix(e2e): compact visual report case layout

* fix(e2e): keep large visual diffs surfaced

Generated-By: looper 0.9.11 (runner=fixer, agent=codex)

* fix(e2e): seed privacy consent in project-runtime tests

* fix(e2e): harden project-runtime settings mocks

* fix(e2e): stabilize project-runtime follow-ups

---------

Co-authored-by: mrcfps <mrc@powerformer.com>
2026-06-26 10:30:22 +00:00

300 lines
9.6 KiB
TypeScript

import { expect, test } from '@/playwright/suite';
import type { Page } from '@playwright/test';
import { openSettingsDialog } from '../lib/playwright/amr.js';
import { routeAgents } from '../lib/playwright/mock-factory.js';
const STORAGE_KEY = 'open-design:config';
const LOCALE_KEY = 'open-design:locale';
const OPEN_SETTINGS_LABEL = /Open settings|打开设置|開啟設定|Account & settings/i;
const LOCAL_CLI_LABEL = /Local CLI|本机 CLI|本地 CLI/i;
test.describe.configure({ timeout: 30_000 });
type AppConfigSeed = Record<string, unknown>;
type AgentFixture = {
id: string;
name: string;
bin: string;
available: boolean;
version?: string | null;
models?: Array<{ id: string; label: string }>;
};
type ConnectionTestFixture = {
ok: boolean;
kind: string;
latencyMs: number;
agentName?: string;
sample?: string;
detail?: string;
configuredExecutablePath?: string;
detectedExecutablePath?: string;
usedExecutablePath?: string;
usedExecutableSource?: 'configured' | 'path' | 'fallback_invalid' | 'fallback_failed';
};
const CODEX_AGENT: AgentFixture = {
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
available: true,
version: '0.130.0',
models: [{ id: 'default', label: 'Default' }],
};
function baseConfig(overrides: Partial<AppConfigSeed> = {}): AppConfigSeed {
return {
mode: 'daemon',
apiKey: '',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
...overrides,
};
}
async function waitForLoadingToClear(page: Page) {
await page.getByText('Loading Open Design…').waitFor({ state: 'hidden', timeout: 15_000 }).catch(() => {});
}
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 openLocalCliSettings(
page: Page,
{
config,
locale = 'en',
agents = [CODEX_AGENT],
onConnectionTest,
}: {
config: AppConfigSeed;
locale?: string;
agents?: AgentFixture[];
onConnectionTest: (payload: Record<string, unknown>) => ConnectionTestFixture;
},
) {
let currentConfig: AppConfigSeed = {
...config,
agentCliEnv: { ...(config.agentCliEnv ?? {}) },
agentModels: { ...(config.agentModels ?? {}) },
};
let configSaveCount = 0;
await page.addInitScript(
({ key, value, localeKey, localeValue }) => {
window.localStorage.setItem(key, JSON.stringify(value));
window.localStorage.setItem(localeKey, localeValue);
},
{ key: STORAGE_KEY, value: config, localeKey: LOCALE_KEY, localeValue: locale },
);
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({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
config: currentConfig,
}),
});
return;
}
const payload = route.request().postDataJSON() as
| Partial<AppConfigSeed>
| { config?: Partial<AppConfigSeed> };
const nextConfig: Partial<AppConfigSeed> =
'config' in payload && payload.config ? payload.config : payload;
currentConfig = {
...currentConfig,
...nextConfig,
agentCliEnv: { ...(nextConfig.agentCliEnv ?? currentConfig.agentCliEnv ?? {}) },
agentModels: { ...(nextConfig.agentModels ?? currentConfig.agentModels ?? {}) },
};
configSaveCount += 1;
await page.evaluate(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: currentConfig },
);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
});
await routeAgents(page, agents);
await page.route('**/api/test/connection', async (route) => {
const payload = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onConnectionTest(payload)),
});
});
await gotoEntryHome(page);
const dialog = await openSettingsDialog(page);
await dialog.getByRole('tab', { name: LOCAL_CLI_LABEL }).click();
const codexCard = dialog
.locator('[data-testid="settings-agent-select-codex"], .agent-card-select', {
hasText: /Codex CLI/i,
})
.first();
await expect(codexCard).toBeVisible({ timeout: 20_000 });
await codexCard.click();
await dialog.getByTestId('settings-cli-env').evaluate((details) => {
if (details instanceof HTMLDetailsElement) details.open = true;
});
await expect(
dialog.getByLabel(/Codex executable path|Codex 可执行文件路径/i),
).toBeVisible();
return {
dialog,
waitForConfigSave: async (count = 1) => {
await expect.poll(() => configSaveCount, { timeout: 20_000 }).toBeGreaterThanOrEqual(count);
},
readCurrentConfig: () => currentConfig,
};
}
test.describe('Settings Local CLI Codex fallback UX', () => {
test('[P0] @critical shows fallback repair actions and can replace the saved path with the detected Codex binary', async ({ page }) => {
test.setTimeout(60_000);
const configuredPath = '/bad/codex';
const detectedPath = '/usr/local/bin/codex';
let lastRequest: Record<string, unknown> | null = null;
const { dialog, waitForConfigSave, readCurrentConfig } = await openLocalCliSettings(page, {
config: baseConfig({
agentCliEnv: { codex: { CODEX_BIN: configuredPath } },
}),
onConnectionTest: (payload) => {
lastRequest = payload;
return {
ok: true,
kind: 'success',
latencyMs: 18,
agentName: 'Codex CLI',
sample: 'ready',
configuredExecutablePath: configuredPath,
detectedExecutablePath: detectedPath,
usedExecutablePath: detectedPath,
usedExecutableSource: 'fallback_invalid',
};
},
});
await expect(
dialog.getByLabel(/Codex executable path|Codex 可执行文件路径/i),
).toHaveValue(configuredPath);
await dialog.getByRole('button', { name: 'Test' }).click();
const status = dialog.locator('.settings-test-status');
await expect(dialog.getByText('Use detected Codex')).toBeVisible();
await expect(dialog.getByText('Clear custom path')).toBeVisible();
await expect(dialog.getByText('The saved Codex path is not the binary this test should keep using.')).toBeVisible();
await expect(status).toContainText(configuredPath);
await expect(status).toContainText(detectedPath);
expect(lastRequest).toMatchObject({
mode: 'agent',
agentId: 'codex',
agentCliEnv: {
codex: {
CODEX_BIN: configuredPath,
},
},
});
await dialog.getByRole('button', { name: 'Use detected Codex' }).click();
await expect(
dialog.getByLabel(/Codex executable path|Codex 可执行文件路径/i),
).toHaveValue(detectedPath);
await expect(dialog.getByRole('button', { name: 'Use detected Codex' })).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await waitForConfigSave();
await expect.poll(() => readCurrentConfig(), { timeout: 20_000 }).toMatchObject({
agentCliEnv: {
codex: {
CODEX_BIN: detectedPath,
},
},
});
});
test('[P0] can clear an unusable custom Codex path after a fallback_failed test result', async ({ page }) => {
test.setTimeout(60_000);
const configuredPath = '/Applications/Codex.app/Contents/Resources/codex';
const detectedPath = '/opt/homebrew/bin/codex';
const { dialog, waitForConfigSave, readCurrentConfig } = await openLocalCliSettings(page, {
config: baseConfig({
agentCliEnv: { codex: { CODEX_BIN: configuredPath } },
}),
onConnectionTest: () => ({
ok: true,
kind: 'success',
latencyMs: 33,
agentName: 'Codex CLI',
sample: 'fallback ok',
configuredExecutablePath: configuredPath,
detectedExecutablePath: detectedPath,
usedExecutablePath: detectedPath,
usedExecutableSource: 'fallback_failed',
}),
});
await dialog.getByRole('button', { name: 'Test' }).click();
const status = dialog.locator('.settings-test-status');
await expect(dialog.getByRole('button', { name: 'Clear custom path' })).toBeVisible();
await expect(status).toContainText(configuredPath);
await expect(status).toContainText(detectedPath);
await dialog.getByRole('button', { name: 'Clear custom path' }).click();
await expect(
dialog.getByLabel(/Codex executable path|Codex 可执行文件路径/i),
).toHaveValue('');
await expect(dialog.getByRole('button', { name: 'Clear custom path' })).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await waitForConfigSave();
await expect.poll(() => readCurrentConfig(), { timeout: 20_000 }).toMatchObject({
agentCliEnv: {},
});
});
});