open-design/apps/web/tests/components/App.connectors.test.tsx
lefarcen 9596a0ccd5
feat(privacy): collapse first-run consent banner to a single "I get it" button (#2202)
* feat(privacy): collapse first-run banner to a single "I get it" button

Replaces the first-run privacy disclosure's two-button decision picker
("Share usage data" / "Don't share") with a single "I get it"
acknowledgement. Clicking it accepts the same default telemetry surface
the previous "Share usage data" path enabled — the banner shifts from
binary consent picker to informed disclosure.

To keep the surface honest, the banner footer is rewritten to spell out
the new default and point at the off switch:

  "Data sharing is on by default. You can turn it off any time in
   Settings → Privacy. We never upload the contents of your generated
   artifact files."

Settings → Privacy (PrivacySection.tsx) is unchanged — that surface still
exposes both Share and Don't share buttons so users who arrive there
later (or come back to flip the choice) keep the explicit picker.

Mechanics:

* `PrivacyConsentModal.tsx`: drop `onDecline` prop and the second button;
  rename `onShare` → `onAccept` to match the new semantic. Footer hint
  now reads from `settings.privacyConsentBannerFooter` (new key) so the
  banner copy can speak in single-button voice without disturbing the
  reused `settings.privacyConsentFooter` that PrivacySection still
  displays.

* `App.tsx`: drop the `onDecline` handler. Single `onAccept` handler
  applies the same opt-in payload as the previous `onShare` branch
  (`telemetry.metrics = true`, `telemetry.content = true`, fresh
  `installationId`), so the wire format daemon-side is unchanged.

* `i18n/types.ts` + `locales/en.ts`: two new keys —
  `settings.privacyConsentAccept` ("I get it") and
  `settings.privacyConsentBannerFooter` (the default-on disclosure copy).

* `i18n/locales/*.ts` (all 18 non-en dictionaries): added the two new
  keys. zh-CN and zh-TW are translated; the remaining 16 locales follow
  the project convention of leaving the EN string as a fallback for
  later contributor passes (same shape used by privacyConsentShare /
  Decline today).

* `tests/components/PrivacyConsentModal.test.tsx`: rewritten. The four
  new tests lock the new contract — single "I get it" button, no
  Share/Decline labels, default-on disclosure text in the footer, the
  external privacy-policy link, and onAccept firing on click. Replaces
  the prior "equal-prominence" tests, which only made sense for the
  two-button shape.

Validation:

* `pnpm --filter @open-design/web exec vitest run tests/components/PrivacyConsentModal.test.tsx`
  → 4/4 passed
* `pnpm --filter @open-design/web exec vitest run tests/i18n/locales.test.ts`
  → 5/5 passed (every locale aligned with English keys + placeholders;
  the new keys ship to all 19 dictionaries)
* `pnpm --filter @open-design/web typecheck` clean

* test(privacy): update App.connectors fixture to match single-button banner

`App.connectors.test.tsx > does not show first-run privacy consent until
daemon config hydration finishes` hardcoded the previous "Share usage
data" affirmative button label. The single-button banner now renders
"I get it", so the assertion was looking for a button that no longer
exists.

CI signal: 26081467446 → Web workspace tests → `× does not show
first-run privacy consent until daemon config hydration finishes`. The
App workspace tests + Validate workspace failures cascaded from this
one — both are aggregator jobs.

Local: vitest run tests/components/App.connectors.test.tsx → 5/5 passed.
2026-05-19 15:26:56 +08:00

374 lines
10 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('hides first-run privacy consent while settings is open', async () => {
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')).toBeNull();
});
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: '',
},
});
});
});