open-design/apps/web/tests/components/App.connectors.test.tsx
lefarcen e149616dbe
fix(web): decouple privacy banner from onboarding and Settings lifecycles (#2525)
* fix(web): decouple privacy banner from onboarding and Settings lifecycles

The first-run privacy banner used to be tightly bound to two unrelated
surfaces: it was hidden whenever Settings was open, and the onboarding
panel only navigated in after the user had resolved the banner. The
coupling existed because the banner's z-index sat below modal backdrops,
so showing both at once collided visually, and the banner+onboarding
were linearized to avoid a "two unfinished things on screen" feel.

This change makes the three surfaces independent:

- Lift `.privacy-consent-banner` z-index above the modal-backdrop layer
  so the banner stays visible (and clickable) when Settings is open. The
  banner is already `pointer-events: none` with opt-in on its actionable
  children, so it does not steal clicks from the layer below.
- Drop the `!settingsOpen` guard from `showPrivacyConsent`.
- Drop the `privacyDecisionAt != null` guard from the bootstrap
  onboarding route; first-run users land on `/onboarding` purely based
  on `!onboardingCompleted`, and the banner sits on top in parallel.
- Drop the `navigate(... onboarding)` side effect from the banner's
  `onAccept` — the banner only persists the privacy decision now.

Bootstrap also had to be reshaped: the merged config is now computed
outside the `setConfig` updater so navigation can happen synchronously
after the state update. Calling `navigate` inside the updater triggered
a React "setState while rendering" warning, and reading a captured flag
after `setConfig` was unreliable because React 18+ batches the updater
to the next render — the navigate condition was never observed.

Existing test that asserted the old coupling ("banner unmounts while
Settings is open") is inverted to lock in the new contract.

* fix(web): defer privacy banner until onboarding is done and user lands on home

Product feedback on the previous lifecycle change: the banner should not
appear during the welcome panel. It should surface only:

  - immediately after the user Skips onboarding (lands directly on home), or
  - after the user finishes the design-system step and later returns to a
    home view from the project view they were dropped into.

To capture both paths with a single rule, the banner now requires:

  1. Daemon config hydrated (unchanged).
  2. No privacy decision recorded yet (unchanged).
  3. onboardingCompleted === true.
  4. The current route is a home route (route.kind === 'home').

The Skip path already routes through finishOnboarding, which calls
onCompleteOnboarding() + changeView('home') — that satisfies all four
gates the moment Skip is clicked.

The finish path (step 2: create design system) previously navigated to a
project view without marking onboardingCompleted. This commit mirrors the
Skip path by calling handleCompleteOnboarding() from the App-level
renderDesignSystemCreation onCreated callback (the onboarding-specific use
of DesignSystemCreationFlow). The shared DesignSystemFlow component is left
untouched so the create-from-Settings entry point keeps its existing
semantics.

The route gate keeps the banner suppressed while the user is reading their
just-created design system project. As soon as they navigate back to the
entry shell (home route), the banner appears.

Tests:
  - "withholds the privacy banner until onboarding completes" — covers
    gate 3 (onboardingCompleted=false while still on onboarding/home).
  - "withholds the privacy banner outside the home route" — covers gate 4
    (user is on a project route, onboardingCompleted=true).
  - Existing "keeps the first-run privacy banner mounted while settings is
    open" still passes; the Settings/banner z-index relationship is
    independent of these gates.

* fix(web): allow privacy banner to surface on non-home routes after onboarding

Follow-up to the previous lifecycle change. After exercising the design-
system finish path end-to-end, product wants the banner to appear in the
project view the user is dropped into — the first generation is running
in the background and the user is already waiting, so the disclosure can
be acknowledged inline rather than being held back until they navigate
back to a home view.

The Skip path is unchanged: Skip routes the user to home and the banner
appears there.

This drops the `route.kind === 'home'` guard and the matching test, and
adds a contract test that locks in banner visibility on a project route
when `onboardingCompleted=true` and no privacy decision has been made.
2026-05-21 14:51:59 +08:00

424 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { App } from '../../src/App';
import type { AppConfig } from '../../src/types';
import {
fetchDaemonConfig,
fetchComposioConfigFromDaemon,
loadConfig,
mergeDaemonConfig,
saveConfig,
syncComposioConfigToDaemon,
syncConfigToDaemon,
} from '../../src/state/config';
import {
daemonIsLive,
fetchAgents,
fetchAppVersionInfo,
fetchDesignSystems,
fetchPromptTemplates,
fetchSkills,
} from '../../src/providers/registry';
import { listProjects, listTemplates } from '../../src/state/projects';
const useRouteMock = vi.fn(() => ({ kind: 'home' as const, view: 'home' as const }));
vi.mock('../../src/router', () => ({
navigate: vi.fn(),
useRoute: () => useRouteMock(),
}));
vi.mock('../../src/components/EntryView', () => ({
EntryView: ({
config,
onOpenSettings,
onPersistComposioKey,
}: {
config: AppConfig;
onOpenSettings: (section?: 'composio') => void;
onPersistComposioKey: (composio: AppConfig['composio']) => void;
}) => (
<div>
<button type="button" onClick={() => onOpenSettings('composio')}>
Open connectors settings
</button>
<button type="button" onClick={() => onOpenSettings()}>
Open execution settings
</button>
<div>Composio tail: {config.composio?.apiKeyTail ?? 'none'}</div>
<button
type="button"
onClick={() =>
onPersistComposioKey({
apiKey: 'cmp_secret_replacement',
apiKeyConfigured: true,
apiKeyTail: config.composio?.apiKeyTail ?? '',
})
}
>
Save connectors key
</button>
<button
type="button"
onClick={() =>
onPersistComposioKey({
apiKey: '',
apiKeyConfigured: false,
apiKeyTail: '',
})
}
>
Clear connectors key
</button>
</div>
),
}));
vi.mock('../../src/components/ProjectView', () => ({
ProjectView: () => <div>Project view</div>,
}));
vi.mock('../../src/components/pet/PetOverlay', () => ({
PetOverlay: () => null,
}));
vi.mock('../../src/components/pet/pets', () => ({
migrateCustomPetAtlas: vi.fn().mockResolvedValue(null),
}));
vi.mock('../../src/components/SettingsDialog', () => ({
SettingsDialog: ({
initial,
initialSection,
onPersistComposioKey,
}: {
initial: AppConfig;
initialSection?: string;
onPersistComposioKey: (composio: AppConfig['composio']) => void;
}) => (
<div role="dialog" aria-label="Settings dialog">
<div>Section: {initialSection}</div>
<div>Composio tail: {initial.composio?.apiKeyTail ?? 'none'}</div>
<button
type="button"
onClick={() =>
onPersistComposioKey({
apiKey: 'cmp_secret_replacement',
apiKeyConfigured: true,
apiKeyTail: initial.composio?.apiKeyTail ?? '',
})
}
>
Save connectors key
</button>
<button
type="button"
onClick={() =>
onPersistComposioKey({
apiKey: '',
apiKeyConfigured: false,
apiKeyTail: '',
})
}
>
Clear connectors key
</button>
</div>
),
}));
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
daemonIsLive: vi.fn(),
fetchAgents: vi.fn(),
fetchAppVersionInfo: vi.fn(),
fetchDesignSystems: vi.fn(),
fetchPromptTemplates: vi.fn(),
fetchSkills: vi.fn(),
};
});
vi.mock('../../src/state/projects', async () => {
const actual = await vi.importActual<typeof import('../../src/state/projects')>(
'../../src/state/projects',
);
return {
...actual,
listProjects: vi.fn(),
listTemplates: vi.fn(),
};
});
vi.mock('../../src/state/config', async () => {
const actual = await vi.importActual<typeof import('../../src/state/config')>(
'../../src/state/config',
);
return {
...actual,
loadConfig: vi.fn(),
mergeDaemonConfig: vi.fn(),
saveConfig: vi.fn(),
fetchDaemonConfig: vi.fn().mockResolvedValue({}),
syncConfigToDaemon: vi.fn().mockResolvedValue(undefined),
syncComposioConfigToDaemon: vi.fn().mockResolvedValue(true),
fetchComposioConfigFromDaemon: vi.fn().mockResolvedValue(null),
};
});
const mockedDaemonIsLive = vi.mocked(daemonIsLive);
const mockedFetchAgents = vi.mocked(fetchAgents);
const mockedFetchAppVersionInfo = vi.mocked(fetchAppVersionInfo);
const mockedFetchDesignSystems = vi.mocked(fetchDesignSystems);
const mockedFetchPromptTemplates = vi.mocked(fetchPromptTemplates);
const mockedFetchSkills = vi.mocked(fetchSkills);
const mockedListProjects = vi.mocked(listProjects);
const mockedListTemplates = vi.mocked(listTemplates);
const mockedFetchDaemonConfig = vi.mocked(fetchDaemonConfig);
const mockedFetchComposioConfigFromDaemon = vi.mocked(fetchComposioConfigFromDaemon);
const mockedLoadConfig = vi.mocked(loadConfig);
const mockedMergeDaemonConfig = vi.mocked(mergeDaemonConfig);
const mockedSaveConfig = vi.mocked(saveConfig);
const mockedSyncConfigToDaemon = vi.mocked(syncConfigToDaemon);
const mockedSyncComposioConfigToDaemon = vi.mocked(syncComposioConfigToDaemon);
const baseConfig: AppConfig = {
mode: 'api',
apiKey: '',
apiProtocol: 'anthropic',
apiVersion: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
apiProtocolConfigs: {},
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
composio: {},
agentModels: {},
agentCliEnv: {},
};
describe('App connectors settings flows', () => {
beforeEach(() => {
mockedDaemonIsLive.mockResolvedValue(true);
mockedFetchAgents.mockResolvedValue([]);
mockedFetchSkills.mockResolvedValue([]);
mockedFetchDesignSystems.mockResolvedValue([]);
mockedFetchPromptTemplates.mockResolvedValue([]);
mockedFetchAppVersionInfo.mockResolvedValue(null);
mockedListProjects.mockResolvedValue([]);
mockedListTemplates.mockResolvedValue([]);
mockedFetchDaemonConfig.mockResolvedValue({});
mockedFetchComposioConfigFromDaemon.mockResolvedValue(null);
mockedMergeDaemonConfig.mockImplementation((local) => local);
mockedLoadConfig.mockReturnValue({ ...baseConfig });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({}),
}),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('hydrates a daemon-saved Composio key into settings when local state does not have a pending edit', async () => {
mockedFetchComposioConfigFromDaemon.mockResolvedValue({
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: 'uQEg',
});
render(<App />);
await waitFor(() => {
expect(screen.getByText('Composio tail: uQEg')).toBeTruthy();
});
});
it('does not show first-run privacy consent until daemon config hydration finishes', async () => {
let resolveDaemonConfig: (value: Record<string, never>) => void = () => {};
mockedFetchDaemonConfig.mockReturnValue(
new Promise((resolve) => {
resolveDaemonConfig = resolve;
}),
);
const { container } = render(<App />);
await waitFor(() => {
expect(mockedFetchDaemonConfig).toHaveBeenCalled();
});
expect(container.querySelector('.privacy-consent-banner')).toBeNull();
resolveDaemonConfig({});
await waitFor(() => {
expect(container.querySelector('.privacy-consent-banner')).toBeTruthy();
});
const banner = container.querySelector('.privacy-consent-banner');
expect(banner?.querySelector('.seg-control')).toBeNull();
expect(banner?.querySelector('.seg-btn.active')).toBeNull();
expect(screen.getByRole('button', { name: 'I get it' }).className).toContain(
'privacy-consent-action',
);
});
it('keeps the first-run privacy banner mounted while settings is open', async () => {
// The banner and Settings have independent lifecycles. The banner's
// z-index in index.css sits above the modal backdrop, so opening
// Settings (or any other modal) must not unmount the banner — the
// user has to be able to acknowledge the disclosure from any view.
const { container } = render(<App />);
await waitFor(() => {
expect(container.querySelector('.privacy-consent-banner')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: 'Open execution settings' }));
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'Settings dialog' })).toBeTruthy();
});
expect(container.querySelector('.privacy-consent-banner')).toBeTruthy();
});
it('withholds the privacy banner until onboarding completes', async () => {
// First-run users should land on the welcome panel without the
// privacy disclosure layered on top. The banner appears only after
// onboardingCompleted flips to true (Skip and finish both flip it).
mockedLoadConfig.mockReturnValue({ ...baseConfig, onboardingCompleted: false });
mockedFetchDaemonConfig.mockResolvedValue({ onboardingCompleted: false });
const { container } = render(<App />);
await waitFor(() => {
expect(mockedFetchDaemonConfig).toHaveBeenCalled();
});
// Give the bootstrap microtasks a turn to settle; banner must still
// be absent because onboardingCompleted is false.
await waitFor(() => {
expect(container.querySelector('.privacy-consent-banner')).toBeNull();
});
});
it('shows the privacy banner on non-home routes once onboarding completes', async () => {
// The design-system finish path drops the user into a project view
// (the first generation runs there). Product wants the disclosure to
// appear in that view too — the user is already waiting for output,
// so there is no benefit to delaying the banner until they navigate
// back to home.
useRouteMock.mockReturnValue({
kind: 'project',
projectId: 'proj-1',
conversationId: null,
fileName: null,
} as never);
try {
const { container } = render(<App />);
await waitFor(() => {
expect(container.querySelector('.privacy-consent-banner')).toBeTruthy();
});
} finally {
useRouteMock.mockReturnValue({
kind: 'home' as const,
view: 'home' as const,
} as never);
}
});
it('normalizes local persistence but sends the raw replacement key to the daemon on save', async () => {
mockedLoadConfig.mockReturnValue({
...baseConfig,
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: 'uQEg',
},
});
render(<App />);
fireEvent.click(screen.getByRole('button', { name: 'Save connectors key' }));
await waitFor(() => {
expect(mockedSyncComposioConfigToDaemon).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'cmp_secret_replacement',
apiKeyConfigured: true,
}),
);
});
expect(mockedSaveConfig).toHaveBeenCalledWith(
expect.objectContaining({
onboardingCompleted: true,
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: 'ment',
},
}),
);
expect(mockedSyncConfigToDaemon).toHaveBeenCalledWith(
expect.objectContaining({
onboardingCompleted: true,
}),
);
expect(mockedSaveConfig.mock.calls.at(-1)?.[0]).toMatchObject({
onboardingCompleted: true,
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: 'ment',
},
});
});
it('sends a cleared Composio config to the daemon when the saved key is removed', async () => {
mockedLoadConfig.mockReturnValue({
...baseConfig,
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: 'uQEg',
},
});
render(<App />);
fireEvent.click(screen.getByRole('button', { name: 'Clear connectors key' }));
await waitFor(() => {
expect(mockedSyncComposioConfigToDaemon).toHaveBeenCalledWith({
apiKey: '',
apiKeyConfigured: false,
apiKeyTail: '',
});
});
expect(mockedSaveConfig.mock.calls.at(-1)?.[0]).toMatchObject({
composio: {
apiKey: '',
apiKeyConfigured: false,
apiKeyTail: '',
},
});
});
});