open-design/apps/web/tests/App.test.ts
eggward han a0316d2599
fix(web): suppress autosave indicator for draft-only Connector key edits (#1232)
When the user typed a replacement Composio API key, the global Settings
autosave loop persisted `buildPersistedConfig(cfg)` — which intentionally
strips the in-flight secret — and then advanced the indicator through
'saving' -> 'saved' despite the key never actually being written. The
"All changes saved" status then contradicted the section-local "Save key"
gesture and eroded trust in the saved-state badge for a sensitive field.

The autosave effect now tracks the snapshot at the last successful save
(or the initial cfg on mount) and compares the next snapshot's persisted
shape against it via a new `isAutosaveDraftOnlyChange` helper. When the
only diffs since last save are fields that `buildPersistedConfig` strips
(today the Composio API key, generalizing to any future
save-on-explicit-confirm secret), the persist call is skipped and the
indicator settles to 'idle' instead of flashing 'saved'. The forced
media-provider sync path still runs because that is a real outbound
effect even when the persisted shape hasn't changed.

Refs #1187
2026-05-11 20:52:45 +08:00

125 lines
3.8 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import {
buildPersistedConfig,
isAutosaveDraftOnlyChange,
persistComposioConfigChange,
resolveSettingsCloseConfig,
shouldSyncMediaProvidersOnSave,
} from '../src/App';
import type { AppConfig } from '../src/types';
const baseConfig: AppConfig = {
mode: 'api',
apiKey: 'sk-test',
apiProtocol: 'anthropic',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
agentId: null,
skillId: null,
designSystemId: null,
};
describe('persistComposioConfigChange', () => {
it('does not update local saved state when the daemon save fails', async () => {
await expect(
persistComposioConfigChange(
baseConfig,
{ apiKey: 'cmp_new_key', apiKeyConfigured: false },
vi.fn(async () => false),
),
).rejects.toThrow('Composio config save failed');
});
it('normalizes the saved Composio key after a successful daemon save', async () => {
await expect(
persistComposioConfigChange(
baseConfig,
{ apiKey: 'cmp_new_key', apiKeyConfigured: false },
vi.fn(async () => true),
),
).resolves.toMatchObject({
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: '_key',
},
});
});
});
describe('shouldSyncMediaProvidersOnSave', () => {
it('keeps bootstrap-style empty media maps from syncing by default', () => {
expect(shouldSyncMediaProvidersOnSave({})).toBe(false);
});
it('syncs an explicit empty media map when the user save should force a clear', () => {
expect(shouldSyncMediaProvidersOnSave({}, { force: true })).toBe(true);
});
});
describe('buildPersistedConfig', () => {
it('preserves onboarding completion when a stale autosave snapshot says false', () => {
expect(
buildPersistedConfig(
{ ...baseConfig, onboardingCompleted: false },
{ ...baseConfig, onboardingCompleted: true },
),
).toMatchObject({ onboardingCompleted: true });
});
});
describe('isAutosaveDraftOnlyChange', () => {
const savedComposio: AppConfig = {
...baseConfig,
composio: { apiKey: '', apiKeyConfigured: true, apiKeyTail: 'beef' },
};
it('treats an in-flight Composio API key edit as draft-only', () => {
const typing: AppConfig = {
...savedComposio,
composio: { ...savedComposio.composio, apiKey: '111' },
};
expect(isAutosaveDraftOnlyChange(typing, savedComposio)).toBe(true);
});
it('flags a real change (non-draft field) as persist-worthy', () => {
const flipped: AppConfig = { ...savedComposio, model: 'claude-opus-4-7' };
expect(isAutosaveDraftOnlyChange(flipped, savedComposio)).toBe(false);
});
it('flags apiKeyConfigured / tail flips as persist-worthy', () => {
const cleared: AppConfig = {
...savedComposio,
composio: { apiKey: '', apiKeyConfigured: false, apiKeyTail: '' },
};
expect(isAutosaveDraftOnlyChange(cleared, savedComposio)).toBe(false);
});
it('returns true for an identical snapshot (no-op autosave tick)', () => {
expect(isAutosaveDraftOnlyChange(savedComposio, savedComposio)).toBe(true);
});
});
describe('resolveSettingsCloseConfig', () => {
it('marks onboarding complete without discarding the latest persisted draft', () => {
expect(
resolveSettingsCloseConfig(
{
...baseConfig,
onboardingCompleted: false,
orbit: { enabled: false, time: '09:00', templateSkillId: 'stale-template' },
},
{
...baseConfig,
onboardingCompleted: false,
orbit: { enabled: true, time: '11:30', templateSkillId: 'fresh-template' },
},
),
).toMatchObject({
onboardingCompleted: true,
orbit: { enabled: true, time: '11:30', templateSkillId: 'fresh-template' },
});
});
});