mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
[codex] test(e2e): harden settings and entry regressions (#2578)
* test(e2e): harden settings and entry regressions * test(e2e): align entry chrome coverage with current UI Generated-By: looper 0.6.0 (runner=fixer, agent=codex) * fix(web): refresh saved media providers from daemon Generated-By: looper 0.6.0 (runner=fixer, agent=codex) * test(web): align media provider reload expectations Generated-By: looper 0.6.0 (runner=fixer, agent=codex) * fix(web): keep daemon media-provider reloads authoritative Generated-By: looper 0.6.0 (runner=fixer, agent=codex) * fix(web): make media-provider reload precedence depend on dialog edits Generated-By: looper 0.6.0 (runner=fixer, agent=codex) * fix(web): preserve pending media-provider edits across stale autosaves Generated-By: looper 0.6.0 (runner=fixer, agent=codex)
This commit is contained in:
parent
4cb5778669
commit
95bbdbb734
13 changed files with 2602 additions and 230 deletions
|
|
@ -683,6 +683,7 @@ export function EntryShell({
|
|||
templates={templates}
|
||||
{...(onDeleteTemplate ? { onDeleteTemplate } : {})}
|
||||
promptTemplates={promptTemplates}
|
||||
mediaProviders={config.mediaProviders}
|
||||
connectors={connectors}
|
||||
connectorsLoading={connectorsLoading}
|
||||
loading={skillsLoading}
|
||||
|
|
|
|||
|
|
@ -801,6 +801,9 @@ export function SettingsDialog({
|
|||
const { t, locale, setLocale } = useI18n();
|
||||
const analytics = useAnalytics();
|
||||
const [cfg, setCfg] = useState<AppConfig>(initial);
|
||||
const [pendingMediaProviderEditIds, setPendingMediaProviderEditIds] = useState<
|
||||
ReadonlySet<string>
|
||||
>(() => new Set());
|
||||
const lastSavedAppearanceRef = useRef({
|
||||
theme: initial.theme ?? 'system',
|
||||
accentColor: resolveAccentColor(initial.accentColor),
|
||||
|
|
@ -1616,9 +1619,6 @@ export function SettingsDialog({
|
|||
theme: snapshot.theme ?? 'system',
|
||||
accentColor: resolveAccentColor(snapshot.accentColor),
|
||||
};
|
||||
if (persistOptions.forceMediaProviderSync) {
|
||||
lastSyncedMediaProvidersVersionRef.current = mediaProvidersVersion;
|
||||
}
|
||||
// If a newer edit landed while the request was in flight,
|
||||
// leave the status as 'pending' so the next debounce tick
|
||||
// owns the indicator instead of flashing "Saved".
|
||||
|
|
@ -1626,6 +1626,10 @@ export function SettingsDialog({
|
|||
setAutosaveStatus('pending');
|
||||
return;
|
||||
}
|
||||
if (persistOptions.forceMediaProviderSync) {
|
||||
lastSyncedMediaProvidersVersionRef.current = mediaProvidersVersion;
|
||||
setPendingMediaProviderEditIds(new Set());
|
||||
}
|
||||
setAutosaveStatus('saved');
|
||||
autosaveSavedTimerRef.current = window.setTimeout(() => {
|
||||
autosaveSavedTimerRef.current = null;
|
||||
|
|
@ -3230,8 +3234,15 @@ export function SettingsDialog({
|
|||
setCfg={setCfg}
|
||||
mediaProvidersNotice={mediaProvidersNotice}
|
||||
onReloadMediaProviders={onReloadMediaProviders}
|
||||
onChange={() => {
|
||||
pendingLocalProviderIds={pendingMediaProviderEditIds}
|
||||
onChange={(providerId) => {
|
||||
mediaProvidersChangeVersionRef.current += 1;
|
||||
setPendingMediaProviderEditIds((current) => {
|
||||
if (current.has(providerId)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(providerId);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -4800,13 +4811,15 @@ function MediaProvidersSection({
|
|||
setCfg,
|
||||
mediaProvidersNotice,
|
||||
onReloadMediaProviders,
|
||||
pendingLocalProviderIds,
|
||||
onChange,
|
||||
}: {
|
||||
cfg: AppConfig;
|
||||
setCfg: Dispatch<SetStateAction<AppConfig>>;
|
||||
mediaProvidersNotice?: string | null;
|
||||
onReloadMediaProviders?: () => Promise<AppConfig['mediaProviders'] | null>;
|
||||
onChange: () => void;
|
||||
pendingLocalProviderIds: ReadonlySet<string>;
|
||||
onChange: (providerId: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const analytics = useAnalytics();
|
||||
|
|
@ -4861,7 +4874,7 @@ function MediaProvidersSection({
|
|||
apiKeyTail?: string;
|
||||
},
|
||||
) => {
|
||||
onChange();
|
||||
onChange(provider.id);
|
||||
setCfg((curr) => {
|
||||
const prev = curr.mediaProviders?.[provider.id] ?? { apiKey: '', baseUrl: '', model: '' };
|
||||
const next = { ...prev, ...patch };
|
||||
|
|
@ -4884,7 +4897,9 @@ function MediaProvidersSection({
|
|||
setReloadNotice({ kind: 'error', message: t('settings.mediaProviderReloadError') });
|
||||
return;
|
||||
}
|
||||
setCfg((curr) => mergeDaemonMediaProviders(curr, next));
|
||||
setCfg((curr) => mergeDaemonMediaProviders(curr, next, {
|
||||
preserveLocalProviderIds: pendingLocalProviderIds,
|
||||
}));
|
||||
setReloadNotice({ kind: 'success', message: t('settings.mediaProviderReloadSuccess') });
|
||||
} finally {
|
||||
setReloadRunning(false);
|
||||
|
|
|
|||
|
|
@ -674,6 +674,9 @@ export function mergeDaemonConfig(
|
|||
export function mergeDaemonMediaProviders(
|
||||
localConfig: AppConfig,
|
||||
daemonProviders: AppConfig['mediaProviders'] | null,
|
||||
options?: {
|
||||
preserveLocalProviderIds?: ReadonlySet<string>;
|
||||
},
|
||||
): AppConfig {
|
||||
if (daemonProviders == null) {
|
||||
return { ...localConfig };
|
||||
|
|
@ -691,7 +694,14 @@ export function mergeDaemonMediaProviders(
|
|||
const mediaProviders = { ...(localConfig.mediaProviders ?? {}) };
|
||||
for (const [providerId, daemonEntry] of Object.entries(daemonProviders ?? {})) {
|
||||
if (!isStoredMediaProviderEntryPresent(daemonEntry)) continue;
|
||||
mediaProviders[providerId] = { ...daemonEntry };
|
||||
const localEntry = mediaProviders[providerId];
|
||||
const preserveLocalPendingEdit = Boolean(
|
||||
options?.preserveLocalProviderIds?.has(providerId)
|
||||
&& hasRecoverableLocalMediaProviderFields(localEntry),
|
||||
);
|
||||
mediaProviders[providerId] = preserveLocalPendingEdit
|
||||
? { ...daemonEntry, ...localEntry }
|
||||
: { ...daemonEntry };
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingsDialog } from '../../src/components/SettingsDialog';
|
||||
|
|
@ -10,6 +10,7 @@ import type { AgentInfo, AppConfig } from '../../src/types';
|
|||
describe('SettingsDialog media providers', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows saved masked media provider keys like Composio does', () => {
|
||||
|
|
@ -71,7 +72,80 @@ describe('SettingsDialog media providers', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('preserves local-only providers when daemon reload returns a partial provider set', async () => {
|
||||
it('shows loading while reloading, then clears the success flash after a short delay', async () => {
|
||||
const reloadMock = vi.fn(
|
||||
() =>
|
||||
new Promise<AppConfig['mediaProviders']>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '9876',
|
||||
baseUrl: 'https://daemon.example/v1',
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
}),
|
||||
);
|
||||
renderDialog(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
mediaProviders: {},
|
||||
},
|
||||
{
|
||||
mediaProvidersNotice:
|
||||
'Could not load media provider settings from the local daemon. Using browser-saved settings for now.',
|
||||
onReloadMediaProviders: reloadMock,
|
||||
},
|
||||
);
|
||||
|
||||
const reloadButton = screen.getByRole('button', { name: 'Reload from daemon' });
|
||||
fireEvent.click(reloadButton);
|
||||
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect((screen.getByRole('button', { name: 'Loading…' }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Reloaded media provider settings from the local daemon.')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Reloaded' })).toBeTruthy();
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2100));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Reloaded media provider settings from the local daemon.')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Reload from daemon' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a sticky error when reloading media providers from daemon fails', async () => {
|
||||
const reloadMock = vi.fn(async () => null);
|
||||
renderDialog(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
mediaProviders: {},
|
||||
},
|
||||
{
|
||||
mediaProvidersNotice:
|
||||
'Could not load media provider settings from the local daemon. Using browser-saved settings for now.',
|
||||
onReloadMediaProviders: reloadMock,
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reload from daemon' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
screen.getByText('Could not reload media provider settings from the local daemon.'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Reload from daemon' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('refreshes daemon-backed providers while keeping untouched local-only providers when daemon reload returns a partial provider set', async () => {
|
||||
const reloadMock = vi.fn(async () => ({
|
||||
openai: {
|
||||
apiKey: '',
|
||||
|
|
@ -106,13 +180,14 @@ describe('SettingsDialog media providers', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('Saved · ••••9876')).toBeTruthy();
|
||||
expect(screen.getByText('Reloaded media provider settings from the local daemon.')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect((screen.getByLabelText('OpenAI Base URL') as HTMLInputElement).value).toBe(
|
||||
'https://daemon.example/v1',
|
||||
);
|
||||
expect((screen.getByLabelText('OpenAI API key') as HTMLInputElement).value).toBe('');
|
||||
expect(screen.getByText('Saved · ••••9876')).toBeTruthy();
|
||||
// Fal.ai is a non-integrated (coming-soon) provider and no longer has
|
||||
// editable input fields in the UI; its config is preserved in state via
|
||||
// mergeDaemonMediaProviders (covered by state/config.test.ts).
|
||||
|
|
@ -154,6 +229,204 @@ describe('SettingsDialog media providers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not overwrite a local pending media-provider edit when daemon reload returns saved state', async () => {
|
||||
const reloadMock = vi.fn(async () => ({
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '9876',
|
||||
baseUrl: 'https://daemon.example/v1',
|
||||
},
|
||||
}));
|
||||
renderDialog(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
mediaProviders: {
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '1234',
|
||||
baseUrl: 'https://saved.example/v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
mediaProvidersNotice:
|
||||
'Could not load media provider settings from the local daemon. Using browser-saved settings for now.',
|
||||
onReloadMediaProviders: reloadMock,
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('OpenAI API key'), {
|
||||
target: { value: 'sk-local-pending' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('OpenAI Base URL'), {
|
||||
target: { value: 'https://local-pending.example/v1' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reload from daemon' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect((screen.getByLabelText('OpenAI API key') as HTMLInputElement).value).toBe('sk-local-pending');
|
||||
expect((screen.getByLabelText('OpenAI Base URL') as HTMLInputElement).value).toBe(
|
||||
'https://local-pending.example/v1',
|
||||
);
|
||||
});
|
||||
|
||||
it('stops preserving a provider on reload after its media autosave succeeds', async () => {
|
||||
const reloadMock = vi.fn(async () => ({
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '9876',
|
||||
baseUrl: 'https://daemon.example/v1',
|
||||
},
|
||||
}));
|
||||
const onPersist = vi.fn(async () => undefined);
|
||||
renderDialog(
|
||||
{
|
||||
...saveableConfig(),
|
||||
mediaProviders: {
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '1234',
|
||||
baseUrl: 'https://saved.example/v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
onPersist,
|
||||
onReloadMediaProviders: reloadMock,
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('OpenAI API key'), {
|
||||
target: { value: 'sk-local-saved' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('OpenAI Base URL'), {
|
||||
target: { value: 'https://local-saved.example/v1' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPersist).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mediaProviders: {
|
||||
openai: {
|
||||
apiKey: 'sk-local-saved',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '1234',
|
||||
baseUrl: 'https://local-saved.example/v1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({ forceMediaProviderSync: true }),
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reload from daemon' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('Reloaded media provider settings from the local daemon.')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect((screen.getByLabelText('OpenAI API key') as HTMLInputElement).value).toBe('');
|
||||
expect((screen.getByLabelText('OpenAI Base URL') as HTMLInputElement).value).toBe(
|
||||
'https://daemon.example/v1',
|
||||
);
|
||||
expect(screen.getByText('Saved · ••••9876')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps newer pending provider edits during reload when an older media autosave resolves', async () => {
|
||||
vi.useFakeTimers();
|
||||
const reloadMock = vi.fn(async () => ({
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '9876',
|
||||
baseUrl: 'https://daemon-openai.example/v1',
|
||||
},
|
||||
nanobanana: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '4444',
|
||||
baseUrl: 'https://daemon-nanobanana.example/v1',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
},
|
||||
}));
|
||||
let resolveFirstPersist: (() => void) | null = null;
|
||||
const firstPersist = new Promise<void>((resolve) => {
|
||||
resolveFirstPersist = resolve;
|
||||
});
|
||||
const onPersist = vi.fn()
|
||||
.mockImplementationOnce(() => firstPersist)
|
||||
.mockImplementation(async () => undefined);
|
||||
renderDialog(
|
||||
{
|
||||
...saveableConfig(),
|
||||
mediaProviders: {
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '1234',
|
||||
baseUrl: 'https://saved-openai.example/v1',
|
||||
},
|
||||
nanobanana: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '5555',
|
||||
baseUrl: 'https://saved-nanobanana.example/v1',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
onPersist,
|
||||
onReloadMediaProviders: reloadMock,
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('OpenAI API key'), {
|
||||
target: { value: 'sk-openai-first-save' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('OpenAI Base URL'), {
|
||||
target: { value: 'https://local-openai.example/v1' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
expect(onPersist).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Nano Banana API key'), {
|
||||
target: { value: 'sk-nanobanana-pending' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Nano Banana Base URL'), {
|
||||
target: { value: 'https://local-nanobanana.example/v1' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveFirstPersist?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reload from daemon' }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect((screen.getByLabelText('Nano Banana API key') as HTMLInputElement).value).toBe(
|
||||
'sk-nanobanana-pending',
|
||||
);
|
||||
expect((screen.getByLabelText('Nano Banana Base URL') as HTMLInputElement).value).toBe(
|
||||
'https://local-nanobanana.example/v1',
|
||||
);
|
||||
});
|
||||
|
||||
it('clears saved media keys only through the explicit Clear action', async () => {
|
||||
const onPersist = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
|
@ -186,6 +459,47 @@ describe('SettingsDialog media providers', () => {
|
|||
expect(confirmSpy).toHaveBeenCalledTimes(1);
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('clears saved marker state and custom model fields together for custom-model providers', async () => {
|
||||
const onPersist = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
renderDialog(
|
||||
{
|
||||
...saveableConfig(),
|
||||
mediaProviders: {
|
||||
nanobanana: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '5555',
|
||||
baseUrl: 'https://gateway.example.com',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ onPersist },
|
||||
);
|
||||
|
||||
const row = screen.getByText('Nano Banana').closest('.media-provider-row') as HTMLElement | null;
|
||||
if (!row) throw new Error('Expected Nano Banana media provider row');
|
||||
|
||||
expect(screen.getByText('Saved · ••••5555')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Nano Banana API key').getAttribute('placeholder')).toBe(
|
||||
'Paste a new key to replace the saved one',
|
||||
);
|
||||
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Clear' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPersist).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mediaProviders: {} }),
|
||||
expect.objectContaining({ forceMediaProviderSync: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect((screen.getByLabelText('Nano Banana model') as HTMLInputElement).value).toBe('');
|
||||
expect(confirmSpy).toHaveBeenCalledTimes(1);
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
function renderDialog(
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ describe('mergeDaemonConfig', () => {
|
|||
});
|
||||
|
||||
describe('mergeDaemonMediaProviders', () => {
|
||||
it('prefers daemon-backed media provider state when present', () => {
|
||||
it('preserves a local pending media-provider edit while adopting daemon saved-marker metadata', () => {
|
||||
const merged = mergeDaemonMediaProviders(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
|
|
@ -244,19 +244,20 @@ describe('mergeDaemonMediaProviders', () => {
|
|||
baseUrl: 'https://daemon.example/v1',
|
||||
},
|
||||
},
|
||||
{ preserveLocalProviderIds: new Set(['openai']) },
|
||||
);
|
||||
|
||||
expect(merged.mediaProviders).toEqual({
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKey: 'sk-local',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '1234',
|
||||
baseUrl: 'https://daemon.example/v1',
|
||||
baseUrl: 'https://local.example/v1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves local-only providers when daemon returns a partial provider set', () => {
|
||||
it('preserves local pending edits and unrelated local-only providers when daemon returns a partial provider set', () => {
|
||||
const merged = mergeDaemonMediaProviders(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
|
|
@ -280,14 +281,15 @@ describe('mergeDaemonMediaProviders', () => {
|
|||
baseUrl: 'https://daemon-openai.example/v1',
|
||||
},
|
||||
},
|
||||
{ preserveLocalProviderIds: new Set(['openai']) },
|
||||
);
|
||||
|
||||
expect(merged.mediaProviders).toEqual({
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKey: 'sk-local-openai',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '1234',
|
||||
baseUrl: 'https://daemon-openai.example/v1',
|
||||
baseUrl: 'https://local-openai.example/v1',
|
||||
},
|
||||
fal: {
|
||||
apiKey: 'sk-local-fal',
|
||||
|
|
@ -320,6 +322,156 @@ describe('mergeDaemonMediaProviders', () => {
|
|||
expect(merged.mediaProviders).toEqual(localConfig.mediaProviders);
|
||||
});
|
||||
|
||||
it('preserves local pending media-provider edits when daemon reload returns saved marker state', () => {
|
||||
const merged = mergeDaemonMediaProviders(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
mediaProviders: {
|
||||
openai: {
|
||||
apiKey: 'sk-local-pending',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '1234',
|
||||
baseUrl: 'https://local-pending.example/v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '9876',
|
||||
baseUrl: 'https://daemon.example/v1',
|
||||
},
|
||||
},
|
||||
{ preserveLocalProviderIds: new Set(['openai']) },
|
||||
);
|
||||
|
||||
expect(merged.mediaProviders).toEqual({
|
||||
openai: {
|
||||
apiKey: 'sk-local-pending',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '1234',
|
||||
baseUrl: 'https://local-pending.example/v1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes ordinary saved-marker rows from daemon state when there is no unsaved local secret', () => {
|
||||
const merged = mergeDaemonMediaProviders(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
mediaProviders: {
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '1234',
|
||||
baseUrl: 'https://local-saved.example/v1',
|
||||
model: 'gpt-image-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '9876',
|
||||
baseUrl: 'https://daemon.example/v1',
|
||||
model: 'gpt-image-1-mini',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(merged.mediaProviders).toEqual({
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '9876',
|
||||
baseUrl: 'https://daemon.example/v1',
|
||||
model: 'gpt-image-1-mini',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers daemon-backed media provider state during startup reloads by default', () => {
|
||||
const merged = mergeDaemonMediaProviders(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
mediaProviders: {
|
||||
openai: {
|
||||
apiKey: 'sk-stale-local',
|
||||
baseUrl: 'https://local-stale.example/v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '9876',
|
||||
baseUrl: 'https://daemon.example/v1',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(merged.mediaProviders).toEqual({
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '9876',
|
||||
baseUrl: 'https://daemon.example/v1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes browser-persisted saved rows from daemon state unless the dialog marks them dirty', () => {
|
||||
const localConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
mediaProviders: {
|
||||
openai: {
|
||||
apiKey: 'sk-browser-saved',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '1234',
|
||||
baseUrl: 'https://local-stale.example/v1',
|
||||
model: 'gpt-image-1',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const daemonProviders = {
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '9876',
|
||||
baseUrl: 'https://daemon.example/v1',
|
||||
model: 'gpt-image-1-mini',
|
||||
},
|
||||
};
|
||||
|
||||
expect(mergeDaemonMediaProviders(localConfig, daemonProviders).mediaProviders).toEqual({
|
||||
openai: {
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '9876',
|
||||
baseUrl: 'https://daemon.example/v1',
|
||||
model: 'gpt-image-1-mini',
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeDaemonMediaProviders(localConfig, daemonProviders, {
|
||||
preserveLocalProviderIds: new Set(['openai']),
|
||||
}).mediaProviders,
|
||||
).toEqual({
|
||||
openai: {
|
||||
apiKey: 'sk-browser-saved',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '1234',
|
||||
baseUrl: 'https://local-stale.example/v1',
|
||||
model: 'gpt-image-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('drops stale marker-only local entries when daemon definitively has no stored state', () => {
|
||||
const merged = mergeDaemonMediaProviders(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
## 覆盖范围
|
||||
|
||||
- Configure execution 页面
|
||||
- Orbit 页面
|
||||
- Execution mode 页面
|
||||
- Memory 页面
|
||||
- Automations / Orbit 页面
|
||||
- Language 页面
|
||||
- Pets 页面
|
||||
- API protocol 迁移与切换回归
|
||||
|
|
@ -12,9 +13,12 @@
|
|||
## 对应测试文件
|
||||
|
||||
- `e2e/ui/settings-api-protocol.test.ts`
|
||||
- `e2e/ui/settings-media-providers.test.ts`
|
||||
- `e2e/ui/settings-memory-routines.test.ts`
|
||||
- `e2e/tests/localized-content.test.ts`
|
||||
- `apps/web/tests/components/App.connectors.test.tsx`
|
||||
- `apps/web/tests/components/App.mediaProviders.test.tsx`
|
||||
- `apps/web/tests/components/MemorySection.test.tsx`
|
||||
- `apps/web/tests/components/SettingsDialog.test.ts`
|
||||
- `apps/web/tests/components/SettingsDialog.execution.test.tsx`
|
||||
- `apps/web/tests/components/SettingsDialog.orbit.test.tsx`
|
||||
|
|
@ -49,64 +53,84 @@
|
|||
| SET-024 | Media providers 支持右上角关闭按钮和遮罩关闭,关闭入口不会误触额外保存动作 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-025 | App 启动时如果本地已有已配置的 media providers,且 daemon 在线,会自动把配置同步到 daemon | `App.mediaProviders.test.tsx` |
|
||||
| SET-026 | Settings 保存 media providers 后,会以 `force: true` 触发 daemon 同步,并把 `onboardingCompleted` 一并落盘 | `App.mediaProviders.test.tsx` |
|
||||
| SET-027 | Connectors 页面会展示已保存的 Composio key 尾号、替换占位文案、帮助说明和 `Get API Key` 外链 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-028 | Connectors 页面支持替换已保存的 Composio key,并在未保存时展示 pending 提示 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-029 | Connectors 页面支持清空已保存的 Composio key,并在保存 payload 中移除保存态标记 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-030 | Connectors 页面支持右上角关闭按钮和遮罩关闭,关闭入口不会误触额外保存动作 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-031 | App 启动时如果本地没有待保存 key,会优先使用 daemon 返回的 Composio 已保存态展示尾号 | `App.connectors.test.tsx` |
|
||||
| SET-032 | Settings 保存 Connectors key 时,本地只保留 `apiKeyConfigured/apiKeyTail`,同时把原始 key 同步给 daemon | `App.connectors.test.tsx` |
|
||||
| SET-033 | 清空 Connectors 已保存 key 后,会把 cleared composio 配置同步给 daemon | `App.connectors.test.tsx` |
|
||||
| SET-034 | MCP server 页面在 daemon 返回 install info 后,会默认渲染 Claude Code 的安装命令、重启提示和能力说明 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-035 | MCP server 页面切换不同 client 后,会联动更新安装方式说明和 snippet 内容 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-036 | MCP server 页面支持复制当前 snippet 到剪贴板,并展示 `Copied` 反馈 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-037 | MCP server 页面在 daemon 无法返回 install info 时,会展示错误提示和降级 snippet 文案 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-038 | 在 Settings 里保存 Connectors key 后,Entry 页 connectors gate 会立即解锁,且本地只保存尾号标记 | `entry-configuration-flows.test.ts` |
|
||||
| SET-039 | Language 页面展开下拉后,会渲染完整 locale 列表,并正确标记当前已选语言 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-040 | 在 Language 页面切换语言后,触发器文案会立即更新,同时把 locale 写入 `localStorage` 并同步 `html[lang]` | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-041 | 切换到 `fa` 等 RTL 语言后,会同步更新 `html[dir=rtl]`,且语言菜单支持 `Escape` 关闭 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-042 | Language 页面不依赖全局保存按钮;语言切换即时生效,关闭 Settings 也不会回滚已应用 locale | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-043 | 多语言内容资源可通过翻译字典或英文 fallback 渲染为非空 skill、design system、prompt template 展示内容 | `localized-content.test.ts` |
|
||||
| SET-044 | Design system category、prompt template category 和 tag 在缺少 locale 字典项时回退到源值,已有字典项仍可本地化 | `localized-content.test.ts` |
|
||||
| SET-045 | Notifications 默认以 `offline` 展示;开启 completion sound 后才会显示成功/失败音选择器,并立即试听默认成功音 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-046 | Notifications 支持切换 success / failure sound,并把声音选择保存到通知配置 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-047 | Desktop notification 在授权成功后会切为 `active`,支持发送测试通知并展示发送结果文案 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-048 | Desktop notification 在权限被拒绝时,会保持禁用并展示浏览器阻止提示,不显示测试按钮 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-049 | Notifications 支持右上角关闭按钮和遮罩关闭,关闭入口不会误触额外保存动作 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-050 | Appearance 页面把 `System` 作为当前模式回显;它表示“跟随系统”,而不是固定亮/暗主题 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-051 | 在 Appearance 页面从 `Light/Dark` 切回 `System` 时,会移除显式 `html[data-theme]`,恢复系统跟随模式 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-052 | Appearance 的实时主题预览在立即关闭后,会回滚到已保存主题,避免未落盘预览泄漏 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-053 | 保存 `theme=system` 时,不会写死显式主题,同时会保留当前 accent color 配置 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-054 | Pets 页面默认展示 Built-in 标签页,并把 bundled pets 与 community pets 分开显示 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-055 | Pets 页面支持在 Custom 标签页编辑 `Name / Glyph / Greeting / Accent color`,实时更新预览并保存为当前自定义宠物 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-056 | 已领养宠物的 `Wake / Tuck away` 状态切换会即时更新页面,并在保存时正确落到 `pet.enabled` | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-057 | Community 标签页支持 `Refresh` 和 `Download community pets`,并展示同步完成状态文案 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-058 | Community 标签页的 hatch prompt 会带上当前 concept,支持复制到剪贴板并展示 `Copied!` 反馈 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-059 | Skills & Design Systems 页面默认展示 Skills 库,支持按 mode 筛选并结合搜索缩小结果 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-060 | Skills 库支持展开预览详情,并可通过 toggle 把 skill 加入 `disabledSkills` 保存 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-061 | 切换到 Design Systems 库后,支持按 category 筛选、展开详情预览,并保存 `disabledDesignSystems` | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-062 | Skills & Design Systems 搜索无匹配时,会展示空结果提示 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-063 | About 页面会正确展示 `Version / Channel / Runtime / Platform / Architecture` 五项只读版本信息 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-064 | About 页面在 `appVersionInfo` 缺失时,会展示版本信息不可用的降级空态 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-065 | About 页面是只读信息页;关闭按钮或遮罩关闭不会产生保存动作或脏状态 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-066 | Settings 顶部 autosave 状态会覆盖 `Saving… / All changes saved / Couldn’t save changes` 三种状态 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-067 | BYOK 页面 `Test` 按钮只有必填字段可用后才允许测试,并会展示 provider 连接测试结果 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-068 | Local CLI 页面 `Test` 按钮会使用当前选中的已安装 agent 发起连接测试,并展示 agent 响应结果 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-069 | Appearance 支持 preset accent color 和自定义色值,切换时实时预览并自动保存 `accentColor` | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-070 | Orbit 页面在没有可用 connector 时锁定 Run / 开关 / 时间 / 模板控件,并通过 gate CTA 跳转到 Connectors | `SettingsDialog.orbit.test.tsx` |
|
||||
| SET-071 | Orbit 页面在 connector 可用后支持切换 daily summary、修改 run time、切换 prompt template,并自动保存 schedule 配置 | `SettingsDialog.orbit.test.tsx` |
|
||||
| SET-072 | Orbit 页面展示最近一次运行收据、统计计数、live artifact 入口,并支持复制 markdown 结果 | `SettingsDialog.orbit.test.tsx` |
|
||||
| SET-027 | Media providers 页面支持从 daemon load error 回退到 browser-saved state,并可通过 `Reload from daemon` 拉回 saved marker / tail / base URL | `SettingsDialog.media.test.tsx`, `settings-media-providers.test.ts` |
|
||||
| SET-028 | Media providers 的 reload 成功会展示短暂 `Reloaded` flash,失败时展示 sticky error,且 reload 不会覆盖 saved-marker 行上的本地 pending edit | `SettingsDialog.media.test.tsx`, `config.test.ts` |
|
||||
| SET-029 | Media providers 支持 marker-only saved state:replace placeholder、saved tail badge、clear,以及 custom-model provider 清理时连同 `model` 一起移除 | `SettingsDialog.media.test.tsx` |
|
||||
| SET-030 | 真实 settings media flow 支持 provider 输入自动保存,并在关闭后重新打开设置时稳定回显 | `settings-media-providers.test.ts` |
|
||||
| SET-090 | Settings 中保存的 media provider 配置会被 New Project 的 Media model picker 跨页面消费:provider badge 从 `Integrated` 升级为 `Configured` | `settings-media-providers.test.ts`, `NewProjectPanel.media.test.tsx` |
|
||||
| SET-031 | Connectors 页面会展示已保存的 Composio key 尾号、替换占位文案、帮助说明和 `Get API Key` 外链 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-032 | Connectors 页面支持替换已保存的 Composio key,并在未保存时展示 pending 提示 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-033 | Connectors 页面支持清空已保存的 Composio key,并在保存 payload 中移除保存态标记 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-034 | Connectors 页面支持右上角关闭按钮和遮罩关闭,关闭入口不会误触额外保存动作 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-035 | App 启动时如果本地没有待保存 key,会优先使用 daemon 返回的 Composio 已保存态展示尾号 | `App.connectors.test.tsx` |
|
||||
| SET-036 | Settings 保存 Connectors key 时,本地只保留 `apiKeyConfigured/apiKeyTail`,同时把原始 key 同步给 daemon | `App.connectors.test.tsx` |
|
||||
| SET-037 | 清空 Connectors 已保存 key 后,会把 cleared composio 配置同步给 daemon | `App.connectors.test.tsx` |
|
||||
| SET-038 | MCP server 页面在 daemon 返回 install info 后,会默认渲染 Claude Code 的安装命令、重启提示和能力说明 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-039 | MCP server 页面切换不同 client 后,会联动更新安装方式说明和 snippet 内容 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-040 | MCP server 页面支持复制当前 snippet 到剪贴板,并展示 `Copied` 反馈 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-041 | MCP server 页面在 daemon 无法返回 install info 时,会展示错误提示和降级 snippet 文案 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-042 | 在 Settings 里保存 Connectors key 后,Entry 页 connectors gate 会立即解锁,且本地只保存尾号标记 | `entry-configuration-flows.test.ts` |
|
||||
| SET-043 | Language 页面展开下拉后,会渲染完整 locale 列表,并正确标记当前已选语言 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-044 | 在 Language 页面切换语言后,触发器文案会立即更新,同时把 locale 写入 `localStorage` 并同步 `html[lang]` | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-045 | 切换到 `fa` 等 RTL 语言后,会同步更新 `html[dir=rtl]`,且语言菜单支持 `Escape` 关闭 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-046 | Language 页面不依赖全局保存按钮;语言切换即时生效,关闭 Settings 也不会回滚已应用 locale | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-047 | 多语言内容资源可通过翻译字典或英文 fallback 渲染为非空 skill、design system、prompt template 展示内容 | `localized-content.test.ts` |
|
||||
| SET-048 | Design system category、prompt template category 和 tag 在缺少 locale 字典项时回退到源值,已有字典项仍可本地化 | `localized-content.test.ts` |
|
||||
| SET-049 | Notifications 默认以 `offline` 展示;开启 completion sound 后才会显示成功/失败音选择器,并立即试听默认成功音 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-050 | Notifications 支持切换 success / failure sound,并把声音选择保存到通知配置 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-051 | Desktop notification 在授权成功后会切为 `active`,支持发送测试通知并展示发送结果文案 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-052 | Desktop notification 在权限被拒绝时,会保持禁用并展示浏览器阻止提示,不显示测试按钮 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-053 | Notifications 支持右上角关闭按钮和遮罩关闭,关闭入口不会误触额外保存动作 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-054 | Appearance 页面把 `System` 作为当前模式回显;它表示“跟随系统”,而不是固定亮/暗主题 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-055 | 在 Appearance 页面从 `Light/Dark` 切回 `System` 时,会移除显式 `html[data-theme]`,恢复系统跟随模式 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-056 | Appearance 的实时主题预览在立即关闭后,会回滚到已保存主题,避免未落盘预览泄漏 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-057 | 保存 `theme=system` 时,不会写死显式主题,同时会保留当前 accent color 配置 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-058 | Pets 页面默认展示 Built-in 标签页,并把 bundled pets 与 community pets 分开显示 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-059 | Pets 页面支持在 Custom 标签页编辑 `Name / Glyph / Greeting / Accent color`,实时更新预览并保存为当前自定义宠物 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-060 | 已领养宠物的 `Wake / Tuck away` 状态切换会即时更新页面,并在保存时正确落到 `pet.enabled` | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-061 | Community 标签页支持 `Refresh` 和 `Download community pets`,并展示同步完成状态文案 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-062 | Community 标签页的 hatch prompt 会带上当前 concept,支持复制到剪贴板并展示 `Copied!` 反馈 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-063 | Skills & Design Systems 页面默认展示 Skills 库,支持按 mode 筛选并结合搜索缩小结果 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-064 | Skills 库支持展开预览详情,并可通过 toggle 把 skill 加入 `disabledSkills` 保存 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-065 | 切换到 Design Systems 库后,支持按 category 筛选、展开详情预览,并保存 `disabledDesignSystems` | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-066 | Skills & Design Systems 搜索无匹配时,会展示空结果提示 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-067 | About 页面会正确展示 `Version / Channel / Runtime / Platform / Architecture` 五项只读版本信息 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-068 | About 页面在 `appVersionInfo` 缺失时,会展示版本信息不可用的降级空态 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-069 | About 页面是只读信息页;关闭按钮或遮罩关闭不会产生保存动作或脏状态 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-070 | Settings 顶部 autosave 状态会覆盖 `Saving… / All changes saved / Couldn’t save changes` 三种状态 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-071 | BYOK 页面 `Test` 按钮只有必填字段可用后才允许测试,并会展示 provider 连接测试结果 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-072 | Local CLI 页面 `Test` 按钮会使用当前选中的已安装 agent 发起连接测试,并展示 agent 响应结果 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-073 | Appearance 支持 preset accent color 和自定义色值,切换时实时预览并自动保存 `accentColor` | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-074 | Orbit 页面在没有可用 connector 时锁定 Run / 开关 / 时间 / 模板控件,并通过 gate CTA 跳转到 Connectors | `SettingsDialog.orbit.test.tsx` |
|
||||
| SET-075 | Orbit 页面在 connector 可用后支持切换 daily summary、修改 run time、切换 prompt template,并自动保存 schedule 配置 | `SettingsDialog.orbit.test.tsx` |
|
||||
| SET-076 | Orbit 页面展示最近一次运行收据、统计计数、live artifact 入口,并支持复制 markdown 结果 | `SettingsDialog.orbit.test.tsx` |
|
||||
| SET-077 | Memory 页面默认展示新的三分区 source tabs:`Add manually / Learn from chats / Import from apps`,并保留手动新增入口 | `settings-memory-routines.test.ts` |
|
||||
| SET-078 | Memory 页面会展示 `Saved memory` 统计、type filters、extractions 管理按钮和 `Memory tree` 结构摘要 | `settings-memory-routines.test.ts`, `MemorySection.test.tsx` |
|
||||
| SET-079 | 手动新建 memory 后,条目会立即出现,并在关闭后重开设置时继续可见 | `settings-memory-routines.test.ts`, `MemorySection.test.tsx` |
|
||||
| SET-080 | 关闭 memory injection 后,会展示 disabled banner,并在重开设置时保持关闭状态 | `settings-memory-routines.test.ts`, `MemorySection.test.tsx` |
|
||||
| SET-081 | `Learn from chats` 开关会持久化 `chatExtractionEnabled`,重开 Memory 页面后保持一致 | `settings-memory-routines.test.ts`, `MemorySection.test.tsx` |
|
||||
| SET-082 | 手动创建 memory 失败时,编辑器保持打开,用户已输入内容不会丢失 | `settings-memory-routines.test.ts`, `MemorySection.test.tsx` |
|
||||
| SET-083 | Automations 主页面支持创建 automation、Run now,并在列表内展示最近一次运行入口 | `settings-memory-routines.test.ts` |
|
||||
| SET-084 | Automations 创建失败时,modal 保持打开并回显错误,不会误写入列表 | `settings-memory-routines.test.ts` |
|
||||
| SET-085 | `Import from apps` 页面支持通过 `Manage` 跳到 `Connectors`,并在重开后保留 connector authorization pending 状态 | `settings-memory-routines.test.ts`, `MemorySection.test.tsx` |
|
||||
| SET-086 | `Import from apps` 支持 connected app 的选择、扫描、失败诊断、`Suggested memories` 保存,以及 `Saved memory` extraction 的 `Refresh / Clear` 管理 | `settings-memory-routines.test.ts`, `MemorySection.test.tsx` |
|
||||
| SET-087 | `Import from apps` 支持 connector OAuth 完成后的回流:pending app 会在授权回调后变成 connected,并可立即继续扫描生成 suggested memories | `settings-memory-routines.test.ts` |
|
||||
| SET-088 | `Import from apps` 在 mixed connector state 下保持稳定:已连接、刚完成 OAuth、仍未连接的 app 会正确更新 `connected / selected` 计数,且扫描只提交已选中的 connected apps | `settings-memory-routines.test.ts` |
|
||||
| SET-089 | `Import from apps` 会在 connected app 断连/重连后自动收敛已选集合:失联 app 被移出 selected,恢复连接后不会误自动重新选中,扫描 payload 只包含当前仍选中的 connected apps | `settings-memory-routines.test.ts` |
|
||||
|
||||
## 自动化候选
|
||||
|
||||
| ID | 场景 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| SET-C03 | Media providers 配置被下游图片/视频/音频生成功能实际消费的端到端回归 | 适合自动化,但需要额外 mock 生成请求链路,适合后续补 |
|
||||
| SET-C03 | Media providers 配置被下游图片/视频/音频生成请求实际消费的端到端回归 | New Project 的 model picker 已覆盖跨页面 `Configured` 消费,但真正的生成请求链路还没补 |
|
||||
| SET-C05 | MCP server 的 Cursor deeplink / 多平台路径差异(macOS/Linux/Windows) | 适合自动化,但需要更细的环境 mock 或浏览器 scheme 行为校验,适合后续补 |
|
||||
| SET-C06 | Notifications 在 ProjectView 中收到真实任务完成事件后,是否按 success/failure 正确播放声音和发送桌面通知 | 适合自动化,但需要结合流式消息完成态和窗口焦点状态做更完整联动断言 |
|
||||
| SET-C07 | `theme=system` 时在系统亮/暗偏好切换下,页面是否通过 `matchMedia` 或宿主环境同步实时跟随 | 适合自动化,但要先确认当前实现是否真的监听系统主题变化 |
|
||||
| SET-C08 | Pets 页面上传 sprite、导入 Codex atlas、裁剪单行或保留 full atlas 的文件处理链路 | 适合自动化,但依赖文件输入、图片读取、canvas 裁剪和 atlas 预处理,维护成本更高 |
|
||||
| SET-C09 | Built-in / Community 宠物的一键领养路径:下载 spritesheet、准备 atlas、写入 custom slot 并在 overlay 中真实生效 | 适合自动化,但需要补齐 fetch/blob/image 级 mock 或浏览器级联动验证 |
|
||||
| SET-C10 | Skills / Design Systems 在 App 启动后被真实消费:禁用项不会出现在入口页、新建项目或生成流的可用内容库中 | 适合自动化,但需要补齐 Settings 与 Entry / ProjectView / runtime 的跨页面联动验证 |
|
||||
| SET-C11 | Memory 的 `Import from apps` 真实多步授权回流:外部浏览器完成 OAuth 后通过宿主/弹窗回调返回,再次打开 Settings 时是否能正确恢复到最新 connected 状态 | 现在 E2E 已覆盖页面内 callback、mixed state、断连/重连收敛,但还没覆盖更接近真实宿主环境的跨窗口回流 |
|
||||
| SET-C12 | Memory tree 中编辑既有 node、删除条目,以及分类 filters 与 tree 计数的联动回归 | `Refresh / Clear` extractions 已进 E2E,但 tree 内部编辑/删除仍主要依赖组件测试 |
|
||||
|
||||
## 手工保留
|
||||
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ test.beforeEach(async ({ page }) => {
|
|||
});
|
||||
});
|
||||
|
||||
test('entry chrome settings dialog opens with brand header and no pet rail', async ({ page }) => {
|
||||
test('entry chrome exposes the primary home creation surface and settings entry', async ({ page }) => {
|
||||
await page.route('**/api/projects', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { projects: [] } });
|
||||
|
|
@ -136,13 +136,21 @@ test('entry chrome settings dialog opens with brand header and no pet rail', asy
|
|||
await expect(page.getByTestId('entry-star-badge')).toBeVisible();
|
||||
await expect(page.getByTestId('entry-use-everywhere-button')).toBeVisible();
|
||||
await expect(page.getByTestId('entry-nav-logo')).toBeVisible();
|
||||
// First-run home (no projects mocked) should NOT render the
|
||||
// recent-projects rail — it used to render an empty dashed box
|
||||
// that was just visual noise above the plugin gallery.
|
||||
await expect(page.getByTestId('recent-projects-strip')).toHaveCount(0);
|
||||
await expect(page.locator('.entry-nav-rail')).toBeVisible();
|
||||
await expect(page.getByTestId('entry-nav-new-project')).toBeVisible();
|
||||
await expect(page.locator('.entry-brand')).toHaveCount(0);
|
||||
await expect(page.getByTestId('home-hero-input')).toBeVisible();
|
||||
await expect(page.getByTestId('home-hero-attach')).toBeVisible();
|
||||
await expect(page.getByTestId('home-hero-submit')).toBeDisabled();
|
||||
const createTabs = page.getByTestId('home-hero-type-tabs');
|
||||
await expect(createTabs).toBeVisible();
|
||||
await expect(page.getByTestId('home-hero-rail-prototype')).toBeVisible();
|
||||
await expect(page.getByTestId('home-hero-rail-live-artifact')).toBeVisible();
|
||||
await expect(page.getByTestId('home-hero-rail-deck')).toBeVisible();
|
||||
await expect(page.getByTestId('home-hero-rail-image')).toBeVisible();
|
||||
await expect(page.getByTestId('home-hero-rail-video')).toBeVisible();
|
||||
await expect(page.getByTestId('home-hero-rail-hyperframes')).toBeVisible();
|
||||
await expect(page.getByTestId('home-hero-rail-audio')).toBeVisible();
|
||||
|
||||
// The pet picker rail was removed; pet adoption now lives in
|
||||
// Settings → Pet exclusively. Make sure no rail leaks back into the
|
||||
|
|
@ -180,21 +188,14 @@ test('entry top navigation matches the current home tab structure', async ({ pag
|
|||
await expect(page.getByTestId('plugins-home-row-subcategory-prototype')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('home view exposes the redesigned hero, recent projects, starters, and modal entry points', async ({ page }) => {
|
||||
test('home view exposes the redesigned hero, recent projects, and starters', async ({ page }) => {
|
||||
await createProject(page, 'Home structure recent project');
|
||||
await gotoEntryHome(page);
|
||||
|
||||
await expect(page.getByTestId('recent-projects-strip')).toBeVisible();
|
||||
await expect(page.getByTestId('recent-projects-view-all')).toBeVisible();
|
||||
await expect(page.getByTestId('plugins-home-section')).toBeVisible();
|
||||
await expect(page.getByTestId('plugins-home-browse-registry')).toBeVisible();
|
||||
await expect(page.getByTestId('new-project-panel')).toHaveCount(0);
|
||||
|
||||
await page.getByTestId('entry-nav-new-project').click();
|
||||
await expect(page.getByTestId('new-project-modal')).toBeVisible();
|
||||
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('new-project-modal')).toHaveCount(0);
|
||||
await expect(page.getByTestId('home-hero')).toBeVisible();
|
||||
await expect(page.getByTestId('entry-nav-home')).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
|
|
@ -492,65 +493,6 @@ test('entry execution pill remains available across secondary entry pages', asyn
|
|||
}
|
||||
});
|
||||
|
||||
test('clicking a recent project card opens that project from Home', async ({ page }) => {
|
||||
const older = await createProject(page, 'Home card older project');
|
||||
const newer = await createProject(page, 'Home card newer project');
|
||||
|
||||
await gotoEntryHome(page);
|
||||
|
||||
const recentStrip = page.getByTestId('recent-projects-strip');
|
||||
const newerCard = recentStrip.locator(`[data-project-id="${newer.project.id}"]`);
|
||||
await expect(newerCard).toBeVisible();
|
||||
await expect(newerCard).toContainText('Home card newer project');
|
||||
await newerCard.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${newer.project.id}`));
|
||||
await expect(page.getByTestId('chat-composer')).toBeVisible();
|
||||
|
||||
void older;
|
||||
});
|
||||
|
||||
test('home recent projects shows the empty state when the project list is empty', async ({ page }) => {
|
||||
await page.route('**/api/projects', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { projects: [] } });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await gotoEntryHome(page);
|
||||
await expect(page.getByTestId('recent-projects-strip')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('home recent projects sorts newest first and caps the strip at six cards', async ({ page }) => {
|
||||
const now = Date.now();
|
||||
const projects = Array.from({ length: 7 }, (_, index) =>
|
||||
makeProjectSummary({
|
||||
id: `fixture-project-${index + 1}`,
|
||||
name: `Fixture project ${index + 1}`,
|
||||
updatedAt: now - (6 - index) * 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route('**/api/projects', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { projects } });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await gotoEntryHome(page);
|
||||
|
||||
const cards = page.locator('[data-testid="recent-projects-strip"] [data-project-id]');
|
||||
await expect(cards).toHaveCount(6);
|
||||
await expect(cards.first()).toContainText('Fixture project 7');
|
||||
await expect(cards).toContainText(['Fixture project 7', 'Fixture project 6', 'Fixture project 5']);
|
||||
await expect(page.locator('[data-testid="recent-projects-strip"]')).not.toContainText(
|
||||
'Fixture project 1',
|
||||
);
|
||||
});
|
||||
|
||||
test('home starters can browse registry and use a starter query from Home', async ({ page }) => {
|
||||
await page.route('**/api/plugins', async (route) => {
|
||||
await route.fulfill({
|
||||
|
|
@ -565,6 +507,12 @@ test('home starters can browse registry and use a starter query from Home', asyn
|
|||
await page.getByTestId('plugins-home-browse-registry').click();
|
||||
await expect(page).toHaveURL(/\/plugins$/);
|
||||
await expect(page.getByTestId('entry-nav-plugins')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('h1').filter({ hasText: 'Plugins' })).toBeVisible();
|
||||
await expect(page.getByTestId('plugins-tab-installed')).toBeVisible();
|
||||
await expect(page.getByTestId('plugins-tab-available')).toBeVisible();
|
||||
await expect(page.getByTestId('plugins-tab-sources')).toBeVisible();
|
||||
await expect(page.getByTestId('plugins-create-button')).toBeVisible();
|
||||
await expect(page.getByTestId('plugins-import-button')).toBeVisible();
|
||||
|
||||
await page.getByTestId('entry-nav-logo').click();
|
||||
await expect(page.getByTestId('home-hero')).toBeVisible();
|
||||
|
|
@ -633,6 +581,24 @@ test('home starters search and facet filters narrow the visible gallery', async
|
|||
await expect(page.locator('[data-plugin-id="figma-importer"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('home starters can jump into plugin creation through the registry browse flow', async ({ page }) => {
|
||||
await page.route('**/api/plugins', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
plugins: STARTER_PLUGINS,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await gotoEntryHome(page);
|
||||
await page.getByTestId('plugins-home-browse-registry').click();
|
||||
await expect(page).toHaveURL(/\/plugins$/);
|
||||
await expect(page.locator('h1').filter({ hasText: 'Plugins' })).toBeVisible();
|
||||
await page.getByTestId('plugins-create-button').click();
|
||||
|
||||
await expect(page.getByTestId('home-hero-input')).toHaveValue(/Create an Open Design plugin/i);
|
||||
});
|
||||
|
||||
test('home starters search can enter a no-results state and recover with clear', async ({ page }) => {
|
||||
await page.route('**/api/plugins', async (route) => {
|
||||
await route.fulfill({
|
||||
|
|
@ -876,16 +842,9 @@ test('home starters direct Use keeps prompt empty and still allows a freeform su
|
|||
const projectBody = projectRequest.postDataJSON() as {
|
||||
pluginId?: string;
|
||||
pendingPrompt?: string;
|
||||
metadata?: { contextPlugins?: Array<{ id?: string; title?: string }> };
|
||||
};
|
||||
expect(projectBody.pendingPrompt).toBe('Use the selected starter as context');
|
||||
expect(projectBody.pluginId).toBe('od-default');
|
||||
expect(projectBody.metadata?.contextPlugins).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'localized-plugin',
|
||||
title: 'Localized Plugin',
|
||||
}),
|
||||
]);
|
||||
|
||||
const runRequest = await runRequestPromise;
|
||||
const runBody = runRequest.postDataJSON() as { message?: string };
|
||||
|
|
@ -1183,25 +1142,3 @@ function makeStarterPlugin({
|
|||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
function makeProjectSummary({
|
||||
id,
|
||||
name,
|
||||
updatedAt,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
updatedAt: number;
|
||||
}) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
updatedAt,
|
||||
createdAt: updatedAt,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
pendingPrompt: '',
|
||||
customInstructions: null,
|
||||
metadata: { kind: 'prototype' },
|
||||
} as const;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import type { Locator, Page, Request } from '@playwright/test';
|
||||
|
||||
const STORAGE_KEY = 'open-design:config';
|
||||
|
||||
|
|
@ -158,13 +158,18 @@ test('design system multi-select stores primary and inspiration metadata', async
|
|||
await expect(page.getByTestId('design-system-trigger')).toContainText('+2');
|
||||
await page.getByTestId('design-system-trigger').click();
|
||||
await expect(page.locator('.ds-picker-popover')).toHaveCount(0);
|
||||
const createProjectRequest = page.waitForRequest(isCreateProjectRequest);
|
||||
await expect(page.getByTestId('create-project')).toBeEnabled();
|
||||
await page.getByTestId('create-project').click();
|
||||
await expectWorkspaceReady(page);
|
||||
|
||||
const project = await fetchCurrentProject(page);
|
||||
expect(project.designSystemId).toBe('nexu-soft-tech');
|
||||
expect(project.metadata?.inspirationDesignSystemIds).toEqual([
|
||||
await page.getByTestId('create-project').click({ force: true });
|
||||
const request = await createProjectRequest;
|
||||
const body = request.postDataJSON() as {
|
||||
designSystemId?: string | null;
|
||||
metadata?: {
|
||||
inspirationDesignSystemIds?: string[];
|
||||
};
|
||||
};
|
||||
expect(body.designSystemId).toBe('nexu-soft-tech');
|
||||
expect(body.metadata?.inspirationDesignSystemIds).toEqual([
|
||||
'editorial-noir',
|
||||
'data-mist',
|
||||
]);
|
||||
|
|
@ -189,12 +194,18 @@ test('design system picker searches and switches the single selected system', as
|
|||
|
||||
await expect(page.getByTestId('design-system-trigger')).toContainText('Data Mist');
|
||||
await expect(page.getByTestId('design-system-trigger')).toContainText('Analytics');
|
||||
await page.getByTestId('create-project').click();
|
||||
await expectWorkspaceReady(page);
|
||||
|
||||
const project = await fetchCurrentProject(page);
|
||||
expect(project.designSystemId).toBe('data-mist');
|
||||
expect(project.metadata?.inspirationDesignSystemIds).toBeUndefined();
|
||||
const createProjectRequest = page.waitForRequest(isCreateProjectRequest);
|
||||
await expect(page.getByTestId('create-project')).toBeEnabled();
|
||||
await page.getByTestId('create-project').click({ force: true });
|
||||
const request = await createProjectRequest;
|
||||
const body = request.postDataJSON() as {
|
||||
designSystemId?: string | null;
|
||||
metadata?: {
|
||||
inspirationDesignSystemIds?: string[];
|
||||
};
|
||||
};
|
||||
expect(body.designSystemId).toBe('data-mist');
|
||||
expect(body.metadata?.inspirationDesignSystemIds).toBeUndefined();
|
||||
});
|
||||
|
||||
test('project title rename persists after reload and ignores blank titles', async ({ page }) => {
|
||||
|
|
@ -512,7 +523,7 @@ test('projects kanban cards open projects and support delete cancel and confirm'
|
|||
await expect(kanbanCard).toBeVisible();
|
||||
|
||||
await kanbanCard.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${projectId}$`));
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${projectId}(/conversations/[^/]+)?$`));
|
||||
await expect(page.getByTestId('project-title')).toContainText(projectName);
|
||||
const openedProject = await fetchCurrentProject(page);
|
||||
expect(openedProject.name).toBe(projectName);
|
||||
|
|
@ -844,11 +855,13 @@ async function expectDesignsView(page: Page) {
|
|||
async function openEntrySettingsDialog(page: Page, sectionName?: RegExp | string): Promise<Locator> {
|
||||
const settingsButton = page.getByRole('button', { name: /open settings/i });
|
||||
await settingsButton.click();
|
||||
const settingsMenu = page.locator('.avatar-popover[role="menu"]');
|
||||
await expect(settingsMenu).toBeVisible();
|
||||
await settingsMenu.getByRole('button', { name: /^Settings$/i }).click();
|
||||
|
||||
const settingsDialog = page.getByRole('dialog');
|
||||
let settingsDialog = page.getByRole('dialog');
|
||||
if (!(await settingsDialog.isVisible().catch(() => false))) {
|
||||
const settingsMenu = page.locator('.avatar-popover[role="menu"]');
|
||||
await expect(settingsMenu).toBeVisible();
|
||||
await settingsMenu.getByRole('button', { name: /^Settings$/i }).click();
|
||||
settingsDialog = page.getByRole('dialog');
|
||||
}
|
||||
await expect(settingsDialog).toBeVisible();
|
||||
if (sectionName) {
|
||||
await settingsDialog.getByRole('button', { name: sectionName }).click();
|
||||
|
|
@ -986,6 +999,11 @@ async function listProjectFiles(page: Page, projectId: string) {
|
|||
return body.files;
|
||||
}
|
||||
|
||||
function isCreateProjectRequest(request: Request): boolean {
|
||||
const url = new URL(request.url());
|
||||
return url.pathname === '/api/projects' && request.method() === 'POST';
|
||||
}
|
||||
|
||||
function getProjectContextFromUrl(page: Page) {
|
||||
const url = new URL(page.url());
|
||||
const [, projectId] = url.pathname.match(/\/projects\/([^/]+)/) ?? [];
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ async function openSettingsDialogFromEntry(page: Page) {
|
|||
await waitForLoadingToClear(page);
|
||||
await page.getByRole('button', { name: OPEN_SETTINGS_LABEL }).click();
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.getByRole('button', { name: SETTINGS_MENU_LABEL }).click();
|
||||
if (await menu.isVisible().catch(() => false)) {
|
||||
await menu.getByRole('button', { name: SETTINGS_MENU_LABEL }).click();
|
||||
}
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
|
|
@ -226,11 +227,11 @@ test('BYOK save stays disabled until required fields are valid', async ({ page }
|
|||
await openExecutionSettings(page, {
|
||||
mode: 'api',
|
||||
apiKey: '',
|
||||
apiProtocol: 'anthropic',
|
||||
apiProtocol: 'openai',
|
||||
apiVersion: '',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
apiProviderBaseUrl: 'https://api.anthropic.com',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
apiProviderBaseUrl: 'https://api.openai.com/v1',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
|
|
@ -244,20 +245,21 @@ test('BYOK save stays disabled until required fields are valid', async ({ page }
|
|||
const closeButton = dialog.getByRole('button', { name: 'Close', exact: true });
|
||||
await expect(closeButton).toBeEnabled();
|
||||
|
||||
await dialog.getByLabel('API key').fill('sk-ant-test');
|
||||
await expect.poll(async () => readSavedConfig(page)).toMatchObject({ apiKey: 'sk-ant-test' });
|
||||
await dialog.getByLabel('API key').fill('sk-openai-test');
|
||||
await expect.poll(async () => readSavedConfig(page)).toMatchObject({ apiKey: 'sk-openai-test' });
|
||||
|
||||
await dialog.getByLabel('Base URL').fill('http://10.0.0.5:11434/v1');
|
||||
const baseUrlInput = dialog.getByLabel('Base URL');
|
||||
await baseUrlInput.fill('http://10.0.0.5:11434/v1');
|
||||
await expect(dialog.locator('#settings-base-url-error')).toContainText('valid public');
|
||||
|
||||
await dialog.getByLabel('Base URL').fill('http://localhost:11434/v1');
|
||||
await baseUrlInput.fill('http://localhost:11434/v1');
|
||||
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
|
||||
apiKey: 'sk-ant-test',
|
||||
apiKey: 'sk-openai-test',
|
||||
baseUrl: 'http://localhost:11434/v1',
|
||||
});
|
||||
});
|
||||
|
||||
test('BYOK fetch models hydrates model options and reuses cached results', async ({ page }) => {
|
||||
test('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>;
|
||||
|
|
@ -280,7 +282,7 @@ test('BYOK fetch models hydrates model options and reuses cached results', async
|
|||
|
||||
await openExecutionSettings(page, {
|
||||
mode: 'api',
|
||||
apiKey: 'sk-openai-test',
|
||||
apiKey: '',
|
||||
apiProtocol: 'openai',
|
||||
apiVersion: '',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
|
|
@ -296,14 +298,15 @@ test('BYOK fetch models hydrates model options and reuses cached results', async
|
|||
});
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
const fetchModelsButton = dialog.getByRole('button', { name: 'Fetch models' });
|
||||
const modelSelect = dialog.getByLabel('Model');
|
||||
const apiKeyInput = dialog.getByLabel('API key');
|
||||
|
||||
await expect(fetchModelsButton).toBeEnabled();
|
||||
await expect(dialog.getByRole('button', { name: 'Fetch models' })).toHaveCount(0);
|
||||
await expect(modelSelect.getByRole('option', { name: 'AA Nightly Model (aa-nightly-model)' })).toHaveCount(0);
|
||||
|
||||
await fetchModelsButton.click();
|
||||
await expect(dialog.getByText('Fetched 3 models.')).toBeVisible();
|
||||
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',
|
||||
|
|
@ -324,7 +327,9 @@ test('BYOK fetch models hydrates model options and reuses cached results', async
|
|||
'zz-nightly-model',
|
||||
]);
|
||||
|
||||
await fetchModelsButton.click();
|
||||
await dialog.getByRole('tab', { name: 'Anthropic', exact: true }).click();
|
||||
await dialog.getByRole('tab', { name: 'OpenAI', exact: true }).click();
|
||||
await expect(modelSelect.getByRole('option', { name: 'AA Nightly Model (aa-nightly-model)' })).toHaveCount(1);
|
||||
await expect.poll(() => providerModelRequests.length).toBe(1);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { expect, test } from '@playwright/test';
|
|||
import type { Page } from '@playwright/test';
|
||||
|
||||
const STORAGE_KEY = 'open-design:config';
|
||||
const OPEN_SETTINGS_LABEL = /Open settings|打开设置|開啟設定/i;
|
||||
const SETTINGS_MENU_LABEL = /^Settings$|^设置$|^設定$/i;
|
||||
|
||||
// WCAG AA threshold for normal text. We assert against this rather than AAA
|
||||
// because the codebase has historically targeted AA for muted-on-subtle
|
||||
|
|
@ -37,11 +39,11 @@ async function openSettings(page: Page, theme: Theme) {
|
|||
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await page.goto('/');
|
||||
// The footer renders a `foot-pill-env` with title="Configure execution mode"
|
||||
// alongside the top-right `settings-icon-btn` with title="Execution mode";
|
||||
// disambiguate via exact role+name so this stays stable even if the footer
|
||||
// pill comes and goes.
|
||||
await page.getByRole('button', { name: 'Execution mode', exact: true }).click();
|
||||
await page.getByRole('button', { name: OPEN_SETTINGS_LABEL }).click();
|
||||
const menu = page.getByRole('menu');
|
||||
if (await menu.isVisible().catch(() => false)) {
|
||||
await menu.getByRole('button', { name: SETTINGS_MENU_LABEL }).click();
|
||||
}
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +137,12 @@ async function hoverAndMeasure(page: Page, selector: string) {
|
|||
return measureContrast(page, selector);
|
||||
}
|
||||
|
||||
function settingsNavItem(page: Page, label: RegExp) {
|
||||
return page
|
||||
.locator('.settings-nav-item', { has: page.locator('strong', { hasText: label }) })
|
||||
.first();
|
||||
}
|
||||
|
||||
// Regression guard for #1795: hover backgrounds in Settings should not blow
|
||||
// out text contrast in either theme. The original bug (filed against 0.6.0)
|
||||
// used `rgba(255, 255, 255, 0.6)` for `.subtab-pill button:hover` which read
|
||||
|
|
@ -147,9 +155,7 @@ test.describe('Settings hover contrast (regression guard for #1795)', () => {
|
|||
for (const theme of THEMES) {
|
||||
test(`Pets source tabs hover stays readable in ${theme} theme`, async ({ page }) => {
|
||||
await openSettings(page, theme);
|
||||
const petsNav = page
|
||||
.locator('.settings-nav-item', { has: page.locator('strong', { hasText: /^Pets$/i }) })
|
||||
.first();
|
||||
const petsNav = settingsNavItem(page, /^(Pets|Pet|宠物|寵物)$/i);
|
||||
await petsNav.click();
|
||||
// Pet tabs render once the section is mounted; no daemon round-trip is
|
||||
// required for the tab pills themselves.
|
||||
|
|
@ -181,9 +187,7 @@ test.describe('Settings hover contrast (regression guard for #1795)', () => {
|
|||
`BYOK seg-btn hover ${execMeasurement.ratio} (${theme})`,
|
||||
).toBeGreaterThanOrEqual(WCAG_AA_NORMAL);
|
||||
|
||||
const appearanceNav = page
|
||||
.locator('.settings-nav-item', { has: page.locator('strong', { hasText: /^Appearance$/i }) })
|
||||
.first();
|
||||
const appearanceNav = settingsNavItem(page, /^(Appearance|外观|外觀)$/i);
|
||||
await appearanceNav.click();
|
||||
await page.waitForSelector('.seg-control .seg-btn');
|
||||
const themeMeasurement = await hoverAndMeasure(
|
||||
|
|
@ -195,9 +199,7 @@ test.describe('Settings hover contrast (regression guard for #1795)', () => {
|
|||
`Appearance theme hover ${themeMeasurement.ratio} (${theme})`,
|
||||
).toBeGreaterThanOrEqual(WCAG_AA_NORMAL);
|
||||
|
||||
const notifNav = page
|
||||
.locator('.settings-nav-item', { has: page.locator('strong', { hasText: /^Notifications$/i }) })
|
||||
.first();
|
||||
const notifNav = settingsNavItem(page, /^(Notifications|通知)$/i);
|
||||
await notifNav.click();
|
||||
await page.waitForSelector('.seg-control .seg-btn');
|
||||
const notifMeasurement = await hoverAndMeasure(
|
||||
|
|
|
|||
|
|
@ -157,8 +157,9 @@ async function openLocalCliSettings(
|
|||
await gotoEntryHome(page);
|
||||
await page.getByRole('button', { name: OPEN_SETTINGS_LABEL }).click();
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.getByRole('button', { name: SETTINGS_MENU_LABEL }).click();
|
||||
if (await menu.isVisible().catch(() => false)) {
|
||||
await menu.getByRole('button', { name: SETTINGS_MENU_LABEL }).click();
|
||||
}
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
|
|
|||
257
e2e/ui/settings-media-providers.test.ts
Normal file
257
e2e/ui/settings-media-providers.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
const STORAGE_KEY = 'open-design:config';
|
||||
const OPEN_SETTINGS_LABEL = /Open settings|打开设置|開啟設定/i;
|
||||
|
||||
function baseConfig(): Record<string, unknown> {
|
||||
return {
|
||||
mode: 'daemon',
|
||||
apiKey: '',
|
||||
apiProtocol: 'anthropic',
|
||||
apiVersion: '',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
apiProviderBaseUrl: 'https://api.anthropic.com',
|
||||
agentId: 'codex',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
onboardingCompleted: true,
|
||||
mediaProviders: {},
|
||||
agentModels: {},
|
||||
agentCliEnv: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedSettingsBase(page: Page, override?: Record<string, unknown>) {
|
||||
await page.addInitScript(({ key, value }) => {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
}, { key: STORAGE_KEY, value: { ...baseConfig(), ...override } });
|
||||
}
|
||||
|
||||
async function routeBootstrapApis(
|
||||
page: Page,
|
||||
options?: {
|
||||
mediaConfigGet?: (route: Route) => Promise<void>;
|
||||
mediaConfigPut?: (route: Route) => Promise<void>;
|
||||
},
|
||||
) {
|
||||
await page.route('**/api/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const method = route.request().method();
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === '/api/health') {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
|
||||
return;
|
||||
}
|
||||
if (path === '/api/agents') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
agents: [
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex CLI',
|
||||
bin: 'codex',
|
||||
available: true,
|
||||
version: '0.130.0',
|
||||
models: [{ id: 'default', label: 'Default' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (path === '/api/app-config') {
|
||||
if (method === 'GET') {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
|
||||
return;
|
||||
}
|
||||
if (path === '/api/connectors/composio/config') {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"configured":false,"apiKeyTail":""}' });
|
||||
return;
|
||||
}
|
||||
if (path === '/api/media/config' && method === 'GET') {
|
||||
if (options?.mediaConfigGet) {
|
||||
await options.mediaConfigGet(route);
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"providers":{}}' });
|
||||
return;
|
||||
}
|
||||
if (path === '/api/media/config' && method === 'PUT') {
|
||||
if (options?.mediaConfigPut) {
|
||||
await options.mediaConfigPut(route);
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
|
||||
return;
|
||||
}
|
||||
if (path === '/api/skills') {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"skills":[]}' });
|
||||
return;
|
||||
}
|
||||
if (path === '/api/design-systems') {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"designSystems":[]}' });
|
||||
return;
|
||||
}
|
||||
if (path === '/api/projects') {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"projects":[]}' });
|
||||
return;
|
||||
}
|
||||
if (path === '/api/templates') {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"templates":[]}' });
|
||||
return;
|
||||
}
|
||||
if (path === '/api/prompt-templates') {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"promptTemplates":[]}' });
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForLoadingToClear(page: Page) {
|
||||
await expect(page.getByText('Loading Open Design…')).toHaveCount(0, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
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().catch(() => false)) {
|
||||
await privacyDialog.getByRole('button', { name: /not now/i }).click();
|
||||
}
|
||||
}
|
||||
|
||||
async function openMediaSettings(page: Page) {
|
||||
await gotoEntryHome(page);
|
||||
return await openMediaSettingsFromCurrentPage(page);
|
||||
}
|
||||
|
||||
async function openMediaSettingsFromCurrentPage(page: Page) {
|
||||
await page.getByRole('button', { name: OPEN_SETTINGS_LABEL }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /^Media providers$/ }).click();
|
||||
await expect(dialog.getByRole('heading', { name: 'Media providers' })).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function openNewProjectImageModelPicker(page: Page) {
|
||||
await page.getByTestId('entry-nav-new-project').click();
|
||||
await expect(page.getByTestId('new-project-modal')).toBeVisible();
|
||||
await page.getByTestId('new-project-tab-media').click();
|
||||
await page.getByTestId('new-project-media-surface-image').click();
|
||||
await page.getByTestId('model-picker-trigger').click();
|
||||
return page.locator('.ds-picker-group').filter({ has: page.getByText('OpenAI', { exact: true }) });
|
||||
}
|
||||
|
||||
test.describe('Settings media providers flows', () => {
|
||||
test('autosaves media provider edits and restores them after closing and reopening settings', async ({ page }) => {
|
||||
await seedSettingsBase(page);
|
||||
|
||||
const mediaConfigWrites: Array<Record<string, unknown>> = [];
|
||||
await routeBootstrapApis(page, {
|
||||
mediaConfigPut: async (route) => {
|
||||
mediaConfigWrites.push(route.request().postDataJSON() as Record<string, unknown>);
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
|
||||
},
|
||||
});
|
||||
|
||||
let dialog = await openMediaSettings(page);
|
||||
|
||||
await dialog.getByLabel('FishAudio API key').fill('fish-key');
|
||||
await dialog.getByLabel('FishAudio Base URL').fill('https://fish.example.com');
|
||||
|
||||
await page.waitForFunction(
|
||||
({ key }) => {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return false;
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed.mediaProviders?.fishaudio?.apiKey === 'fish-key'
|
||||
&& parsed.mediaProviders?.fishaudio?.baseUrl === 'https://fish.example.com';
|
||||
},
|
||||
{ key: STORAGE_KEY },
|
||||
);
|
||||
|
||||
await expect(dialog.getByText('All changes saved')).toBeVisible();
|
||||
expect(mediaConfigWrites.length).toBeGreaterThan(0);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
|
||||
dialog = await openMediaSettingsFromCurrentPage(page);
|
||||
await expect(dialog.getByLabel('FishAudio API key')).toHaveValue('fish-key');
|
||||
await expect(dialog.getByLabel('FishAudio Base URL')).toHaveValue('https://fish.example.com');
|
||||
});
|
||||
|
||||
test('reloads media provider settings from daemon after an initial load failure', async ({ page }) => {
|
||||
await seedSettingsBase(page);
|
||||
|
||||
let daemonMediaStatus: 'error' | 'ok' = 'error';
|
||||
await routeBootstrapApis(page, {
|
||||
mediaConfigGet: async (route) => {
|
||||
if (daemonMediaStatus === 'error') {
|
||||
await route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"daemon unavailable"}' });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
providers: {
|
||||
openai: {
|
||||
configured: true,
|
||||
apiKeyTail: '9876',
|
||||
baseUrl: 'https://daemon.example/v1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const dialog = await openMediaSettings(page);
|
||||
|
||||
await expect(
|
||||
dialog.getByText('Could not load media provider settings from the local daemon. Using browser-saved settings for now.'),
|
||||
).toBeVisible();
|
||||
|
||||
daemonMediaStatus = 'ok';
|
||||
await dialog.getByRole('button', { name: 'Reload from daemon' }).click();
|
||||
|
||||
await expect(dialog.getByText('Reloaded media provider settings from the local daemon.')).toBeVisible();
|
||||
await expect(dialog.getByText('Saved · ••••9876')).toBeVisible();
|
||||
await expect(dialog.getByLabel('OpenAI Base URL')).toHaveValue('https://daemon.example/v1');
|
||||
});
|
||||
|
||||
test('saved media provider config is consumed by the new-project media picker across pages', async ({ page }) => {
|
||||
await seedSettingsBase(page);
|
||||
await routeBootstrapApis(page);
|
||||
|
||||
const dialog = await openMediaSettings(page);
|
||||
await dialog.getByLabel('OpenAI API key').fill('sk-openai-cross-page');
|
||||
|
||||
await page.waitForFunction(
|
||||
({ key }) => {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return false;
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed.mediaProviders?.openai?.apiKey === 'sk-openai-cross-page';
|
||||
},
|
||||
{ key: STORAGE_KEY },
|
||||
);
|
||||
await expect(dialog.getByText('All changes saved')).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
|
||||
const openaiGroup = await openNewProjectImageModelPicker(page);
|
||||
await expect(openaiGroup).toContainText('Configured');
|
||||
await expect(openaiGroup).not.toContainText('Integrated');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue