mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
test: expand entry and settings automation coverage (#811)
* test: harden new project panel metadata coverage * test: add settings and connector sync coverage * test: expand entry e2e coverage * test: satisfy exact optional property types in entry connector flow * test: keep entry Playwright coverage under e2e/ui * test: tighten coverage docs and settings test cleanup * test: drop e2e docs from the guarded package * docs: move automation coverage docs out of e2e * test: restore clipboard cleanup without delete * test: match composio save dialog behavior * test: avoid placeholder assertion after composio save * test: expect closeModal on settings saves * test: align settings save assertions with closeModal flags * test: fix settings save mocks * test: align composio replacement hint
This commit is contained in:
parent
2bb029cb58
commit
7107623ee2
13 changed files with 3362 additions and 8 deletions
306
apps/web/tests/components/App.connectors.test.tsx
Normal file
306
apps/web/tests/components/App.connectors.test.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
// @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 {
|
||||
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 }));
|
||||
|
||||
vi.mock('../../src/router', () => ({
|
||||
navigate: vi.fn(),
|
||||
useRoute: () => useRouteMock(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/components/EntryView', () => ({
|
||||
EntryView: ({ onOpenSettings }: { onOpenSettings: (section?: 'composio') => void }) => (
|
||||
<button type="button" onClick={() => onOpenSettings('composio')}>
|
||||
Open connectors settings
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
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,
|
||||
onSave,
|
||||
}: {
|
||||
initial: AppConfig;
|
||||
initialSection?: string;
|
||||
onSave: (next: AppConfig) => void;
|
||||
}) => (
|
||||
<div role="dialog" aria-label="Settings dialog">
|
||||
<div>Section: {initialSection}</div>
|
||||
<div>Composio tail: {initial.composio?.apiKeyTail ?? 'none'}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSave({
|
||||
...initial,
|
||||
composio: {
|
||||
apiKey: 'cmp_secret_replacement',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: initial.composio?.apiKeyTail ?? '',
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Save connectors key
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSave({
|
||||
...initial,
|
||||
composio: {
|
||||
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(),
|
||||
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 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([]);
|
||||
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 />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open connectors settings' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Composio tail: uQEg')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
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: 'Open connectors settings' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: 'Settings dialog' })).toBeTruthy();
|
||||
});
|
||||
|
||||
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: 'Open connectors settings' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: 'Settings dialog' })).toBeTruthy();
|
||||
});
|
||||
|
||||
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: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
268
apps/web/tests/components/App.mediaProviders.test.tsx
Normal file
268
apps/web/tests/components/App.mediaProviders.test.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
// @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 {
|
||||
fetchComposioConfigFromDaemon,
|
||||
loadConfig,
|
||||
mergeDaemonConfig,
|
||||
saveConfig,
|
||||
syncConfigToDaemon,
|
||||
syncMediaProvidersToDaemon,
|
||||
} from '../../src/state/config';
|
||||
import {
|
||||
daemonIsLive,
|
||||
fetchAgents,
|
||||
fetchAppVersionInfo,
|
||||
fetchDesignSystems,
|
||||
fetchPromptTemplates,
|
||||
fetchSkills,
|
||||
} from '../../src/providers/registry';
|
||||
import { listProjects, listTemplates } from '../../src/state/projects';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const useRouteMock = vi.fn(() => ({ kind: 'home' as const }));
|
||||
|
||||
vi.mock('../../src/router', () => ({
|
||||
navigate: (...args: unknown[]) => navigateMock(...args),
|
||||
useRoute: () => useRouteMock(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/components/EntryView', () => ({
|
||||
EntryView: ({ onOpenSettings }: { onOpenSettings: (section?: 'execution' | 'media') => void }) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onOpenSettings('media')}>
|
||||
Open media settings
|
||||
</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,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
initial: AppConfig;
|
||||
initialSection?: string;
|
||||
onSave: (next: AppConfig) => void;
|
||||
onClose: () => void;
|
||||
}) => (
|
||||
<div role="dialog" aria-label="Settings dialog">
|
||||
<div>Section: {initialSection}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSave({
|
||||
...initial,
|
||||
mediaProviders: {
|
||||
openai: {
|
||||
apiKey: 'media-key',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Save media provider
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>
|
||||
Close settings
|
||||
</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(),
|
||||
syncConfigToDaemon: vi.fn().mockResolvedValue(undefined),
|
||||
syncMediaProvidersToDaemon: vi.fn().mockResolvedValue(undefined),
|
||||
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 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 mockedSyncMediaProvidersToDaemon = vi.mocked(syncMediaProvidersToDaemon);
|
||||
|
||||
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: {},
|
||||
agentModels: {},
|
||||
agentCliEnv: {},
|
||||
};
|
||||
|
||||
describe('App media provider sync flows', () => {
|
||||
beforeEach(() => {
|
||||
mockedDaemonIsLive.mockResolvedValue(true);
|
||||
mockedFetchAgents.mockResolvedValue([]);
|
||||
mockedFetchSkills.mockResolvedValue([]);
|
||||
mockedFetchDesignSystems.mockResolvedValue([]);
|
||||
mockedFetchPromptTemplates.mockResolvedValue([]);
|
||||
mockedFetchAppVersionInfo.mockResolvedValue(null);
|
||||
mockedListProjects.mockResolvedValue([]);
|
||||
mockedListTemplates.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('syncs configured media providers to the daemon during bootstrap when the daemon is live', async () => {
|
||||
const configuredProviders = {
|
||||
openai: {
|
||||
apiKey: 'media-key',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: '',
|
||||
},
|
||||
};
|
||||
mockedLoadConfig.mockReturnValue({
|
||||
...baseConfig,
|
||||
mediaProviders: configuredProviders,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedSyncMediaProvidersToDaemon).toHaveBeenCalledWith(configuredProviders);
|
||||
});
|
||||
});
|
||||
|
||||
it('forces a media provider sync when settings are saved', async () => {
|
||||
mockedLoadConfig.mockReturnValue({
|
||||
...baseConfig,
|
||||
onboardingCompleted: false,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: 'Settings dialog' })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save media provider' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedSyncMediaProvidersToDaemon).toHaveBeenCalledWith(
|
||||
{
|
||||
openai: {
|
||||
apiKey: 'media-key',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: '',
|
||||
},
|
||||
},
|
||||
{ force: true },
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedSaveConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onboardingCompleted: true,
|
||||
mediaProviders: {
|
||||
openai: {
|
||||
apiKey: 'media-key',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: '',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mockedSyncConfigToDaemon).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onboardingCompleted: true,
|
||||
mediaProviders: {
|
||||
openai: {
|
||||
apiKey: 'media-key',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: '',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildDesignSystemCreateSelection,
|
||||
defaultDesignSystemSelection,
|
||||
NewProjectPanel,
|
||||
} from '../../src/components/NewProjectPanel';
|
||||
import type { DesignSystemSummary, SkillSummary } from '../../src/types';
|
||||
import type { DesignSystemSummary, ProjectTemplate, SkillSummary } from '../../src/types';
|
||||
|
||||
const skills: SkillSummary[] = [
|
||||
{
|
||||
|
|
@ -33,8 +36,45 @@ const designSystems: DesignSystemSummary[] = [
|
|||
category: 'Product',
|
||||
swatches: ['#f4efe7', '#25211d'],
|
||||
},
|
||||
{
|
||||
id: 'noir',
|
||||
title: 'Editorial Noir',
|
||||
summary: 'High-contrast editorial system.',
|
||||
category: 'Editorial',
|
||||
swatches: ['#111111', '#f7f0e8'],
|
||||
},
|
||||
];
|
||||
|
||||
const templates: ProjectTemplate[] = [
|
||||
{
|
||||
id: 'tmpl-landing',
|
||||
name: 'Landing Page',
|
||||
description: 'A saved landing page starter.',
|
||||
files: [{ name: 'prototype/App.jsx', path: 'prototype/App.jsx' }],
|
||||
createdAt: '2026-05-07T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
globalThis.ResizeObserver = originalResizeObserver;
|
||||
Element.prototype.scrollIntoView = originalScrollIntoView;
|
||||
});
|
||||
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
const originalScrollIntoView = Element.prototype.scrollIntoView;
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.ResizeObserver = ResizeObserverMock as typeof ResizeObserver;
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
describe('NewProjectPanel design system defaults', () => {
|
||||
it('uses the configured default design system when it exists in the catalog', () => {
|
||||
expect(defaultDesignSystemSelection('clay', designSystems)).toEqual(['clay']);
|
||||
|
|
@ -69,4 +109,339 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
inspirations: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves prototype fidelity across tab switches and saves it into the create payload', () => {
|
||||
const onCreate = vi.fn();
|
||||
render(
|
||||
<NewProjectPanel
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('new-project-name'), {
|
||||
target: { value: 'Wireframe fidelity payload' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Wireframe' }));
|
||||
expect(screen.getByRole('button', { name: 'Wireframe' }).getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Slide deck' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Prototype' }));
|
||||
expect(screen.getByRole('button', { name: 'Wireframe' }).getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByTestId('create-project'));
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Wireframe fidelity payload',
|
||||
designSystemId: 'clay',
|
||||
metadata: expect.objectContaining({
|
||||
kind: 'prototype',
|
||||
fidelity: 'wireframe',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears design system metadata when freeform is selected in multi mode', () => {
|
||||
const onCreate = vi.fn();
|
||||
render(
|
||||
<NewProjectPanel
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('new-project-name'), {
|
||||
target: { value: 'Freeform prototype' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('design-system-trigger'));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Multi' }));
|
||||
fireEvent.click(screen.getByRole('option', { name: /Editorial Noir/i }));
|
||||
expect(screen.getByTestId('design-system-trigger').textContent).toContain('Clay');
|
||||
expect(screen.getByTestId('design-system-trigger').textContent).toContain('+1');
|
||||
|
||||
fireEvent.click(screen.getByRole('option', { name: /None — freeform/i }));
|
||||
expect(screen.getByTestId('design-system-trigger').textContent).toContain('None — freeform');
|
||||
expect(screen.getByTestId('design-system-trigger').textContent ?? '').not.toContain('+');
|
||||
|
||||
fireEvent.click(screen.getByTestId('create-project'));
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Freeform prototype',
|
||||
designSystemId: null,
|
||||
metadata: expect.not.objectContaining({
|
||||
inspirationDesignSystemIds: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the generated default title when the prototype name is blank', () => {
|
||||
const onCreate = vi.fn();
|
||||
render(
|
||||
<NewProjectPanel
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId={null}
|
||||
templates={[]}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('new-project-name'), {
|
||||
target: { value: ' ' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('create-project'));
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: expect.stringMatching(/^Prototype\b/),
|
||||
metadata: expect.objectContaining({
|
||||
kind: 'prototype',
|
||||
fidelity: 'high-fidelity',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('saves live artifact creation with prototype kind, live-artifact intent, and fidelity metadata', () => {
|
||||
const onCreate = vi.fn();
|
||||
render(
|
||||
<NewProjectPanel
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
connectors={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Live artifact' }));
|
||||
fireEvent.change(screen.getByTestId('new-project-name'), {
|
||||
target: { value: 'Realtime artifact payload' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Wireframe' }));
|
||||
fireEvent.click(screen.getByTestId('create-project'));
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Realtime artifact payload',
|
||||
metadata: expect.objectContaining({
|
||||
kind: 'prototype',
|
||||
intent: 'live-artifact',
|
||||
fidelity: 'wireframe',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('saves deck creation with speaker notes metadata when the toggle is enabled', () => {
|
||||
const onCreate = vi.fn();
|
||||
render(
|
||||
<NewProjectPanel
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Slide deck' }));
|
||||
fireEvent.change(screen.getByTestId('new-project-name'), {
|
||||
target: { value: 'Deck speaker notes payload' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /use speaker notes/i }));
|
||||
fireEvent.click(screen.getByTestId('create-project'));
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Deck speaker notes payload',
|
||||
metadata: expect.objectContaining({
|
||||
kind: 'deck',
|
||||
speakerNotes: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('prevents template creation when there are no saved templates and enables creation once one exists', () => {
|
||||
const emptyOnCreate = vi.fn();
|
||||
const first = render(
|
||||
<NewProjectPanel
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
promptTemplates={[]}
|
||||
onCreate={emptyOnCreate}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'From template' }));
|
||||
const createFromTemplate = screen.getByTestId('create-project') as HTMLButtonElement;
|
||||
expect(createFromTemplate.disabled).toBe(true);
|
||||
fireEvent.click(createFromTemplate);
|
||||
expect(emptyOnCreate).not.toHaveBeenCalled();
|
||||
first.unmount();
|
||||
|
||||
const templateOnCreate = vi.fn();
|
||||
render(
|
||||
<NewProjectPanel
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={templates}
|
||||
promptTemplates={[]}
|
||||
onCreate={templateOnCreate}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'From template' }));
|
||||
fireEvent.change(screen.getByTestId('new-project-name'), {
|
||||
target: { value: 'Template creation payload' },
|
||||
});
|
||||
const createReady = screen.getByTestId('create-project') as HTMLButtonElement;
|
||||
expect(createReady.disabled).toBe(false);
|
||||
fireEvent.click(createReady);
|
||||
|
||||
expect(templateOnCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Template creation payload',
|
||||
metadata: expect.objectContaining({
|
||||
kind: 'template',
|
||||
templateId: 'tmpl-landing',
|
||||
templateLabel: 'Landing Page',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('saves image creation with the selected aspect and trimmed style notes metadata', () => {
|
||||
const onCreate = vi.fn();
|
||||
render(
|
||||
<NewProjectPanel
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Image' }));
|
||||
fireEvent.change(screen.getByTestId('new-project-name'), {
|
||||
target: { value: 'Image payload metadata' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tall3:4/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText('Editorial photo, soft daylight, muted palette'), {
|
||||
target: { value: ' cinematic still life ' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('create-project'));
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Image payload metadata',
|
||||
designSystemId: null,
|
||||
metadata: expect.objectContaining({
|
||||
kind: 'image',
|
||||
imageModel: 'gpt-image-2',
|
||||
imageAspect: '3:4',
|
||||
imageStyle: 'cinematic still life',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('saves video creation with the selected aspect and duration metadata', () => {
|
||||
const onCreate = vi.fn();
|
||||
render(
|
||||
<NewProjectPanel
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Video' }));
|
||||
fireEvent.change(screen.getByTestId('new-project-name'), {
|
||||
target: { value: 'Video payload metadata' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Portrait9:16/i }));
|
||||
fireEvent.change(screen.getByLabelText('Length'), {
|
||||
target: { value: '10' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('create-project'));
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Video payload metadata',
|
||||
designSystemId: null,
|
||||
metadata: expect.objectContaining({
|
||||
kind: 'video',
|
||||
videoModel: 'doubao-seedance-2-0-260128',
|
||||
videoAspect: '9:16',
|
||||
videoLength: 10,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('saves audio creation with the selected duration and trimmed voice metadata', () => {
|
||||
const onCreate = vi.fn();
|
||||
render(
|
||||
<NewProjectPanel
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Audio' }));
|
||||
fireEvent.change(screen.getByTestId('new-project-name'), {
|
||||
target: { value: 'Audio payload metadata' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Duration'), {
|
||||
target: { value: '30' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Provider voice id, optional'), {
|
||||
target: { value: ' soft contralto guide ' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('create-project'));
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Audio payload metadata',
|
||||
designSystemId: null,
|
||||
metadata: expect.objectContaining({
|
||||
kind: 'audio',
|
||||
audioKind: 'speech',
|
||||
audioModel: 'minimax-tts',
|
||||
audioDuration: 30,
|
||||
voice: 'soft contralto guide',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
1709
apps/web/tests/components/SettingsDialog.execution.test.tsx
Normal file
1709
apps/web/tests/components/SettingsDialog.execution.test.tsx
Normal file
File diff suppressed because it is too large
Load diff
53
docs/testing/e2e-coverage/README.md
Normal file
53
docs/testing/e2e-coverage/README.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# E2E 用例库
|
||||
|
||||
这个目录用于维护当前自动化测试覆盖的 QA 用例文档,主要索引 `e2e/` 套件,并在必要处补充与同一用户流直接相关的 `apps/web` 组件测试。
|
||||
|
||||
## 文档范围
|
||||
|
||||
- 优先记录已经存在于 `e2e/` 下的自动化覆盖;当某个用户流主要由 `apps/web` 组件测试保护时,也会一并注明。
|
||||
- 以用户视角描述场景,不展开实现细节。
|
||||
- 新增测试文件或新增重要场景时,同步更新对应模块文档。
|
||||
|
||||
## 模块索引
|
||||
|
||||
| 模块 | 覆盖重点 | 对应测试文件 |
|
||||
| --- | --- | --- |
|
||||
| [entry.md](./entry.md) | 入口页创建路径、连接器入口、提示词模板、资源驱动场景、顶部 chrome | `e2e/ui/app.test.ts`, `e2e/ui/entry-configuration-flows.test.ts`, `e2e/ui/entry-chrome-flows.test.ts` |
|
||||
| [project-management.md](./project-management.md) | 首页/项目管理、设计系统、项目重命名、删除流程、搜索与视图切换 | `e2e/ui/project-management-flows.test.ts` |
|
||||
| [workspace.md](./workspace.md) | 工作区标签、会话、文件流、快速切换器、手动编辑模式 | `e2e/ui/app.test.ts`, `e2e/ui/workspace-keyboard-flows.test.ts` |
|
||||
| [settings.md](./settings.md) | API protocol 回归、国际化内容完整性、关键设置表单行为 | `e2e/ui/settings-api-protocol.test.ts`, `e2e/tests/localized-content.test.ts`, `apps/web/tests/components/SettingsDialog.execution.test.tsx` |
|
||||
| [desktop.md](./desktop.md) | mac 桌面端 smoke 覆盖、打包产物运行时 smoke | `e2e/specs/mac.spec.ts` |
|
||||
|
||||
## 维护规则
|
||||
|
||||
1. 新增用例时,优先补到最接近的模块文档里,不再维护一个超大的总表。
|
||||
2. 每个场景尽量保持一行,方便 QA 在 PR review 里快速看差异。
|
||||
3. 如果某个场景依赖环境变量、默认跳过,必须在模块文档中明确标注。
|
||||
4. 如果测试被删除、重命名或迁移,文档需要在同一个 PR 里同步更新。
|
||||
|
||||
## 用例分类标准
|
||||
|
||||
### 已自动化
|
||||
|
||||
- 已经有稳定的自动化实现。
|
||||
- 需要写明对应测试文件。
|
||||
- 如果依赖特殊 gate,例如环境变量,也要一并标注。
|
||||
|
||||
### 自动化候选
|
||||
|
||||
- 业务价值明确,未来适合进入自动化。
|
||||
- 但当前可能受限于环境、成本、稳定性或外部依赖。
|
||||
- 建议补一行原因,方便后续判断何时转自动化。
|
||||
|
||||
### 手工保留
|
||||
|
||||
- 更适合人工验收,不建议短期纳入主自动化套件。
|
||||
- 常见于主观体验、视觉质感、复杂真实授权、多设备协作等场景。
|
||||
- 也建议补一行原因,避免以后重复讨论。
|
||||
|
||||
## 当前套件结构
|
||||
|
||||
- `e2e/ui/*.test.ts`:面向浏览器 UI 的 Playwright 回归测试。
|
||||
- `e2e/specs/*.spec.ts`:运行时与平台级 smoke 测试。
|
||||
- `e2e/tests/*.test.ts`:轻量 Vitest 完整性校验。
|
||||
- `e2e/lib/**`:仅放 helper,不放可执行用例入口。
|
||||
47
docs/testing/e2e-coverage/desktop.md
Normal file
47
docs/testing/e2e-coverage/desktop.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# 桌面端模块
|
||||
|
||||
## 覆盖范围
|
||||
|
||||
- 受环境变量控制的 mac 桌面端 smoke
|
||||
- mac 打包产物安装/启动/探活生命周期
|
||||
- 从 desktop shell 进入设置页的关键路径
|
||||
|
||||
## 对应测试文件
|
||||
|
||||
- `e2e/specs/mac.spec.ts`
|
||||
|
||||
## 已自动化
|
||||
|
||||
### Desktop shell smoke
|
||||
|
||||
| ID | 场景 | Gate | 来源 |
|
||||
| --- | --- | --- | --- |
|
||||
| DESK-001 | Desktop shell 可以打开当前 API 配置,并展示正确的 provider/model | `OD_DESKTOP_SMOKE=1` | `mac.spec.ts` |
|
||||
| DESK-002 | 在桌面端设置里切换 API protocol 时,legacy provider tracking 保持一致 | `OD_DESKTOP_SMOKE=1` | `mac.spec.ts` |
|
||||
| DESK-003 | 桌面端外观设置里预览 Dark 模式,并在保存后持久化 | `OD_DESKTOP_SMOKE=1` | `mac.spec.ts` |
|
||||
|
||||
### 打包运行时 smoke
|
||||
|
||||
| ID | 场景 | Gate | 来源 |
|
||||
| --- | --- | --- | --- |
|
||||
| DESK-101 | 构建出的 mac 安装包可以完成安装、启动、健康检查、停止和卸载 | `OD_PACKAGED_E2E_MAC=1` | `mac.spec.ts` |
|
||||
|
||||
## 自动化候选
|
||||
|
||||
| ID | 场景 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| DESK-C01 | Windows desktop smoke | 值得补,但要等对应平台 smoke 文件和执行基础设施准备好 |
|
||||
| DESK-C02 | 更多桌面端设置分区,例如 notifications、language、connectors | 有自动化价值,但当前先保留高 ROI 核心路径 |
|
||||
| DESK-C03 | 更深入的 packaged runtime 校验 | 成本较高,适合在发布链路更稳定后逐步扩展 |
|
||||
|
||||
## 手工保留
|
||||
|
||||
| ID | 场景 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| DESK-M01 | 真机安装体验、系统权限弹窗体验 | 强依赖真实机器环境和人工判断 |
|
||||
| DESK-M02 | 不同 macOS 版本下的界面细节与交互质感 | 自动化覆盖成本高,更适合人工回归 |
|
||||
|
||||
## 说明
|
||||
|
||||
- 桌面端 smoke 有意折叠进 `e2e/specs/mac.spec.ts`,这样可执行覆盖仍然留在现有平台 smoke 层里。
|
||||
- `e2e/lib/desktop/**` 只放 helper,不放独立可执行用例。
|
||||
64
docs/testing/e2e-coverage/entry.md
Normal file
64
docs/testing/e2e-coverage/entry.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# 入口模块
|
||||
|
||||
## 覆盖范围
|
||||
|
||||
- 新建项目入口面板
|
||||
- 入口标签切换与草稿保持
|
||||
- 提示词模板创建路径
|
||||
- 连接器入口与连接器 gate
|
||||
- 资源驱动的项目创建 happy path
|
||||
|
||||
## 对应测试文件
|
||||
|
||||
- `e2e/ui/entry-configuration-flows.test.ts`
|
||||
- `e2e/ui/entry-chrome-flows.test.ts`
|
||||
- `e2e/ui/app.test.ts`
|
||||
|
||||
## 已自动化
|
||||
|
||||
### 入口配置流
|
||||
|
||||
| ID | 场景 | 来源 |
|
||||
| --- | --- | --- |
|
||||
| ENTRY-001 | 提示词模板加载失败后重试,编辑后的模板正文会写入项目 metadata | `entry-configuration-flows.test.ts` |
|
||||
| ENTRY-002 | live artifact 的空状态连接器 CTA 会跳转到受保护的 connector setup 路径 | `entry-configuration-flows.test.ts` |
|
||||
| ENTRY-003 | connectors 入口支持搜索、空结果态,以及详情抽屉的键盘关闭 | `entry-configuration-flows.test.ts` |
|
||||
| ENTRY-004 | 在 Settings 里保存 Composio key 后,Entry 页 connectors gate 会立即解锁,搜索和卡片可直接使用 | `entry-configuration-flows.test.ts` |
|
||||
| ENTRY-005 | 创建原型时切换到 `Wireframe` 后,即使先切到其他项目类型再切回,`fidelity` 选择也会保留,并正确写入创建 payload | `NewProjectPanel.test.tsx` |
|
||||
| ENTRY-006 | 创建原型时在 design system 多选模式下切回 `不指定 — 自由发挥`,会清空主设计体系和 inspiration metadata | `NewProjectPanel.test.tsx` |
|
||||
| ENTRY-007 | 创建原型时若项目名为空白,会回退到自动生成的默认标题而不是提交空名 | `NewProjectPanel.test.tsx` |
|
||||
| ENTRY-008 | 创建实时制品时会把 `kind=prototype`、`intent=live-artifact` 和当前 `fidelity` 一并写入创建 payload | `NewProjectPanel.test.tsx` |
|
||||
| ENTRY-009 | 创建幻灯片时,开启 `Use speaker notes` 会把 `speakerNotes=true` 写入创建 metadata | `NewProjectPanel.test.tsx` |
|
||||
| ENTRY-010 | 从模板创建在没有用户模板时不会误触发创建;有模板时会带上 `templateId/templateLabel` 正常提交 | `NewProjectPanel.test.tsx` |
|
||||
| ENTRY-011 | 创建图片项目时,所选 `aspect` 与修剪后的 `style notes` 会正确写入创建 payload | `NewProjectPanel.test.tsx` |
|
||||
| ENTRY-012 | 创建视频项目时,所选 `aspect` 与 `duration` 会正确写入创建 payload | `NewProjectPanel.test.tsx` |
|
||||
| ENTRY-013 | 创建音频项目时,所选 `duration` 与修剪后的 `voice` 会正确写入创建 payload | `NewProjectPanel.test.tsx` |
|
||||
| ENTRY-014 | 顶部 settings menu 可以切换 pet rail 的显示/隐藏 | `entry-chrome-flows.test.ts` |
|
||||
| ENTRY-015 | 紧凑桌面宽度下,入口页 header 与整页不会出现明显横向溢出 | `entry-chrome-flows.test.ts` |
|
||||
|
||||
### 资源驱动创建场景
|
||||
|
||||
| ID | 场景 | 来源 |
|
||||
| --- | --- | --- |
|
||||
| ENTRY-101 | Prototype 项目可以创建并预览生成的 artifact | `app.test.ts` via `prototype-basic` |
|
||||
| ENTRY-102 | Deck 项目可以创建并预览生成的 deck artifact | `app.test.ts` via `deck-basic` |
|
||||
| ENTRY-103 | 选择 design system 后,创建项目时会正确带入配置 | `app.test.ts` via `design-system-selection` |
|
||||
| ENTRY-104 | 使用 example prompt 可以直接创建带有预填草稿提示词的项目 | `app.test.ts` via `example-use-prompt` |
|
||||
|
||||
## 自动化候选
|
||||
|
||||
| ID | 场景 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| ENTRY-C01 | 更多 image template / video template 的入口创建流 | 业务有价值,但当前入口覆盖仍以主路径为主,可在模板能力稳定后补进自动化 |
|
||||
|
||||
## 手工保留
|
||||
|
||||
| ID | 场景 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| ENTRY-M01 | 入口页视觉风格是否符合品牌预期 | 依赖主观视觉判断,不适合做稳定自动化断言 |
|
||||
| ENTRY-M02 | 入口页动效、过渡、微交互是否自然 | 更适合人工体验验收,自动化收益较低 |
|
||||
|
||||
## 说明
|
||||
|
||||
- `app.test.ts` 的部分场景来自 `e2e/resources/playwright.ts`。新增资源驱动用例时,需要同时更新资源文件和这份文档。
|
||||
- 依赖 mocked SSE 的入口流程应尽量保持稳定、可重复、执行快。
|
||||
46
docs/testing/e2e-coverage/project-management.md
Normal file
46
docs/testing/e2e-coverage/project-management.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# 项目管理模块
|
||||
|
||||
## 覆盖范围
|
||||
|
||||
- 首页项目卡片
|
||||
- 首页搜索与视图切换
|
||||
- 创建设计时的 design system 选择
|
||||
- 项目重命名持久化
|
||||
- 设计文件删除与首页删除流程
|
||||
- 首页入口的宠物自定义
|
||||
|
||||
## 对应测试文件
|
||||
|
||||
- `e2e/ui/project-management-flows.test.ts`
|
||||
|
||||
## 已自动化
|
||||
|
||||
| ID | 场景 | 来源 |
|
||||
| --- | --- | --- |
|
||||
| PM-001 | Prototype、live artifact、deck、image 标签切换正确,且草稿内容会保留 | `project-management-flows.test.ts` |
|
||||
| PM-002 | 多选 design system 时,会正确保存主系统和 inspiration metadata | `project-management-flows.test.ts` |
|
||||
| PM-003 | 单选 design system 时,搜索后可以切换目标系统 | `project-management-flows.test.ts` |
|
||||
| PM-004 | 项目标题重命名后刷新仍保留,空白标题不会覆盖原值 | `project-management-flows.test.ts` |
|
||||
| PM-005 | 取消删除 design file 时,文件行和已打开标签都会保留 | `project-management-flows.test.ts` |
|
||||
| PM-006 | 首页 design 卡片删除同时覆盖取消和确认两种路径 | `project-management-flows.test.ts` |
|
||||
| PM-007 | 首页 designs 视图支持 grid/kanban 切换,并在刷新后保持 | `project-management-flows.test.ts` |
|
||||
| PM-008 | 首页搜索会过滤项目卡片,并支持从无结果态恢复 | `project-management-flows.test.ts` |
|
||||
| PM-009 | Change pet 可以打开宠物设置,并保存自定义 companion | `project-management-flows.test.ts` |
|
||||
|
||||
## 自动化候选
|
||||
|
||||
| ID | 场景 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| PM-C02 | 更多 design system 筛选、排序或分类行为 | 价值明确,但要等产品侧交互稳定后再固化断言 |
|
||||
|
||||
## 手工保留
|
||||
|
||||
| ID | 场景 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| PM-M01 | 宠物形象、表情、交互是否“自然/有趣” | 强主观体验项,不适合自动化 |
|
||||
| PM-M02 | 首页卡片视觉密度、布局观感是否舒适 | 更适合人工视觉验收 |
|
||||
|
||||
## 说明
|
||||
|
||||
- 首页/项目管理相关场景集中在一个 Playwright 文件里,是因为它们共用相似的项目初始化生命周期。
|
||||
- design system 相关覆盖同时验证了 metadata 持久化和 picker 搜索行为。
|
||||
112
docs/testing/e2e-coverage/settings.md
Normal file
112
docs/testing/e2e-coverage/settings.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# 设置模块
|
||||
|
||||
## 覆盖范围
|
||||
|
||||
- Configure execution 页面
|
||||
- Language 页面
|
||||
- Pets 页面
|
||||
- API protocol 迁移与切换回归
|
||||
- 国际化内容注册完整性
|
||||
|
||||
## 对应测试文件
|
||||
|
||||
- `e2e/ui/settings-api-protocol.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/SettingsDialog.test.ts`
|
||||
- `apps/web/tests/components/SettingsDialog.execution.test.tsx`
|
||||
|
||||
## 已自动化
|
||||
|
||||
| ID | 场景 | 来源 |
|
||||
| --- | --- | --- |
|
||||
| SET-001 | BYOK 页面展示 protocol tabs,以及 `Quick fill provider / API key / Model / Base URL` 核心字段 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-002 | BYOK 的 `Show / Hide` 可以切换 API key 明文与密文显示 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-003 | 切换 `Quick fill provider` 后,`Model` 与 `Base URL` 会联动更新到对应 preset | `SettingsDialog.execution.test.tsx`, `settings-api-protocol.test.ts` |
|
||||
| SET-004 | 手动修改 `Base URL` 后,当前 provider 会回退为 custom 状态 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-005 | 不同 protocol 的 draft 相互隔离,`apiKey` 不会跨协议泄漏 | `SettingsDialog.execution.test.tsx`, `SettingsDialog.test.ts` |
|
||||
| SET-006 | 历史 OpenAI-compatible 已知 provider 切到 Anthropic 时,会命中对应 sibling preset | `settings-api-protocol.test.ts`, `SettingsDialog.test.ts` |
|
||||
| SET-007 | 历史 custom provider 切换 protocol 时,会保留自定义 `Base URL` 和 `Model` | `settings-api-protocol.test.ts`, `SettingsDialog.test.ts` |
|
||||
| SET-008 | BYOK 下只有必填字段合法时才允许保存,非法 `Base URL` 会阻止保存 | `SettingsDialog.execution.test.tsx`, `settings-api-protocol.test.ts` |
|
||||
| SET-009 | BYOK 保存后,配置会写入本地并在关闭后重新打开设置时正确回显 | `settings-api-protocol.test.ts` |
|
||||
| SET-010 | Azure 的 `apiVersion` 仅保留在 Azure draft 中,不污染其他协议 | `SettingsDialog.test.ts` |
|
||||
| SET-011 | BYOK 编辑后点击 `Cancel` 或点击遮罩关闭时,不会保存未提交修改 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-012 | Azure OpenAI 页面展示 `Deployment name / API version` 专属字段,并支持保存 Azure 配置 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-013 | BYOK 支持切换到 `Custom model id` 输入路径并保存自定义 model | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-014 | Local CLI 模式下只能选择已安装 agent,选择后可保存为当前执行 CLI | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-015 | Local CLI 在无 agent 时显示 empty state,且不可保存 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-016 | `Rescan` 会展示 loading 状态、阻止重复点击,并在成功后展示可用 agent 数 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-017 | `Rescan` 失败时会展示错误提示,但不破坏当前页面状态 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-018 | Configure execution 页面里的 `CLAUDE_CONFIG_DIR`、`CODEX_HOME` 可保存进配置 | `SettingsDialog.execution.test.tsx`, `SettingsDialog.test.ts` |
|
||||
| SET-019 | daemon offline 时 `Local CLI` 模式不可选,并展示 offline 文案 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-020 | Local CLI 保存后,首页左下角执行状态 pill 会联动展示当前 agent 与版本 | `settings-api-protocol.test.ts` |
|
||||
| SET-021 | Media providers 会按 `已配置优先 -> Integrated 优先 -> 名称排序` 稳定展示,已配置 provider 会显示 `Configured` badge | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-022 | Unsupported media providers 会以禁用行展示,不允许编辑当前不支持的 provider 配置 | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-023 | Media providers 支持保存 API key / Base URL / 自定义 model,并在 `Clear` 后从保存 payload 中移除对应 provider | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-024 | Media providers 编辑后点击 `Cancel` 或点击遮罩关闭时,不会保存未提交修改 | `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 页面编辑后点击 `Cancel` 或点击遮罩关闭时,不会保存未提交修改 | `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 页面不通过 `Save` 持久化;语言切换即时生效,点击 `Cancel` 也不会回滚已应用 locale | `SettingsDialog.execution.test.tsx` |
|
||||
| SET-043 | 每个 locale 都覆盖了所有 curated skill、design system、prompt template id | `localized-content.test.ts` |
|
||||
| SET-044 | 每个 locale 都覆盖了要求的展示分类和 prompt tag | `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 编辑后点击 `Cancel` 或点击遮罩关闭时,不会保存未提交修改 | `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 的实时主题预览在点击 `Cancel` 关闭后,会回滚到已保存主题 | `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 页面是只读信息页;点击 `Cancel` 或遮罩关闭不会产生保存动作或脏状态 | `SettingsDialog.execution.test.tsx` |
|
||||
|
||||
## 自动化候选
|
||||
|
||||
| ID | 场景 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| SET-C03 | Media providers 配置被下游图片/视频/音频生成功能实际消费的端到端回归 | 适合自动化,但需要额外 mock 生成请求链路,适合后续补 |
|
||||
| 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 的跨页面联动验证 |
|
||||
|
||||
## 手工保留
|
||||
|
||||
| ID | 场景 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| SET-M01 | 不同主题下的整体观感是否协调 | 视觉主观项,人工验收更合理 |
|
||||
| SET-M02 | 多语言翻译语气是否自然、本地化是否地道 | 语义质量判断仍需人工 review |
|
||||
|
||||
## 说明
|
||||
|
||||
- API protocol 用例的价值在于:历史配置迁移和协议切换很容易静默回归,单靠单元测试不够稳。
|
||||
- `localized-content.test.ts` 不是浏览器流,但它确实保护了设置页/入口页在多语言下的展示完整性,适合放在这个模块下维护。
|
||||
65
docs/testing/e2e-coverage/workspace.md
Normal file
65
docs/testing/e2e-coverage/workspace.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# 工作区模块
|
||||
|
||||
## 覆盖范围
|
||||
|
||||
- 项目工作区内的会话与文件流
|
||||
- Design Files 上传、删除、标签持久化
|
||||
- Quick switcher 键盘行为
|
||||
- 聊天面板宽度持久化
|
||||
- 手动编辑模式回归
|
||||
|
||||
## 对应测试文件
|
||||
|
||||
- `e2e/ui/app.test.ts`
|
||||
- `e2e/ui/workspace-keyboard-flows.test.ts`
|
||||
|
||||
## 已自动化
|
||||
|
||||
### 资源驱动工作区场景
|
||||
|
||||
| ID | 场景 | 来源 |
|
||||
| --- | --- | --- |
|
||||
| WS-001 | 会话历史在刷新和线程切换后仍能保留 | `app.test.ts` via `conversation-persistence` |
|
||||
| WS-002 | 上传文件后可以在聊天中通过 mention 再次引用发送给 agent | `app.test.ts` via `file-mention` |
|
||||
| WS-003 | 通过文件深链接进入项目时,可以恢复到正确的预览标签 | `app.test.ts` via `deep-link-preview` |
|
||||
| WS-004 | 通过 composer 文件选择器上传文件,并随 prompt 一起发送 | `app.test.ts` via `file-upload-send` |
|
||||
| WS-005 | Design Files 上传图片后,会在工作区打开并可预览 | `app.test.ts` via `design-files-upload` |
|
||||
| WS-006 | Design Files 删除上传文件后,列表和打开标签都会清理 | `app.test.ts` via `design-files-delete` |
|
||||
| WS-007 | 已打开的文件标签在刷新后仍会恢复,并保持正确激活项 | `app.test.ts` via `design-files-tab-persistence` |
|
||||
| WS-008 | 删除当前活跃会话后,界面会自动回退到剩余线程 | `app.test.ts` via `conversation-delete-recovery` |
|
||||
| WS-009 | Question form 的多选题会正确限制最大选择数量 | `app.test.ts` via `question-form-selection-limit` |
|
||||
| WS-010 | Question form 的回答会进入聊天历史,并在刷新后保持锁定态 | `app.test.ts` via `question-form-submit-persistence` |
|
||||
| WS-011 | 在没有新 prompt 的情况下,刷新或空闲不会额外生成新文件 | `app.test.ts` via `generation-does-not-create-extra-file` |
|
||||
| WS-012 | 预览评论可以附加到聊天中,并以结构化上下文发送 | `app.test.ts` via `comment-attachment-flow` |
|
||||
| WS-013 | daemon 发送失败后,错误详情仍然可见,便于重试和排查 | `app.test.ts` direct test |
|
||||
| WS-014 | 手动编辑模式支持内容、样式、源码 patch,以及 undo/redo | `app.test.ts` direct test |
|
||||
| WS-015 | deck 形态 HTML 在手动编辑模式下仍保留 deck 导航能力 | `app.test.ts` direct test |
|
||||
|
||||
### 键盘优先工作区流
|
||||
|
||||
| ID | 场景 | 来源 |
|
||||
| --- | --- | --- |
|
||||
| WS-101 | Quick switcher 可通过键盘打开,并激活目标文件 | `workspace-keyboard-flows.test.ts` |
|
||||
| WS-102 | Quick switcher 搜索无匹配时,不会改变当前文件 | `workspace-keyboard-flows.test.ts` |
|
||||
| WS-103 | Quick switcher 支持方向键移动选择后再打开文件 | `workspace-keyboard-flows.test.ts` |
|
||||
| WS-104 | 通过键盘调整聊天面板宽度后,刷新仍会保持 | `workspace-keyboard-flows.test.ts` |
|
||||
|
||||
## 自动化候选
|
||||
|
||||
| ID | 场景 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| WS-C01 | Python 等非 HTML 文件的源码预览 | 很适合回归自动化,但当前仍属于待补 viewer 能力覆盖 |
|
||||
| WS-C02 | 工作区侧栏的更完整纯键盘导航 | 自动化价值高,但需要先明确产品侧快捷键与焦点规则 |
|
||||
| WS-C03 | 多会话的重命名、归档或恢复流 | 值得自动化,但前提是这些能力在产品层正式稳定 |
|
||||
|
||||
## 手工保留
|
||||
|
||||
| ID | 场景 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| WS-M01 | 生成结果在预览里的“设计质量”是否达标 | 依赖主观内容质量判断,不适合用稳定断言衡量 |
|
||||
| WS-M02 | 手动编辑后的视觉细节是否足够精致 | 更适合设计/QA 人工验收 |
|
||||
|
||||
## 说明
|
||||
|
||||
- `app.test.ts` 同时包含资源驱动场景和少量集中式回归,这里按用户行为分组,而不是按 helper 或实现结构分组。
|
||||
- 资源驱动类场景来源于 `e2e/resources/playwright.ts`。
|
||||
81
e2e/ui/entry-chrome-flows.test.ts
Normal file
81
e2e/ui/entry-chrome-flows.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const STORAGE_KEY = 'open-design:config';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((key) => {
|
||||
window.localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
mode: 'daemon',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
agentId: 'mock',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
onboardingCompleted: true,
|
||||
agentModels: {},
|
||||
}),
|
||||
);
|
||||
}, STORAGE_KEY);
|
||||
|
||||
await page.route('**/api/agents', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
agents: [
|
||||
{
|
||||
id: 'mock',
|
||||
name: 'Mock Agent',
|
||||
bin: 'mock-agent',
|
||||
available: true,
|
||||
version: 'test',
|
||||
models: [{ id: 'default', label: 'Default' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('entry chrome settings menu toggles pet rail visibility', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
||||
await expect(page.locator('.app-chrome-header')).toBeVisible();
|
||||
await expect(page.locator('.app-chrome-brand[aria-label="Open Design"]')).toBeVisible();
|
||||
await expect(page.locator('.entry-brand')).toHaveCount(0);
|
||||
|
||||
const openSettings = page.getByRole('button', { name: /open settings/i });
|
||||
await openSettings.click();
|
||||
const settingsMenu = page.locator('.avatar-popover[role="menu"]');
|
||||
await expect(settingsMenu).toBeVisible();
|
||||
|
||||
await settingsMenu.getByRole('button', { name: /hide pet picker/i }).click();
|
||||
await expect(settingsMenu).toHaveCount(0);
|
||||
await expect(page.locator('.pet-rail')).toHaveCount(0);
|
||||
|
||||
await openSettings.click();
|
||||
await expect(page.getByRole('button', { name: /show pet picker/i })).toBeVisible();
|
||||
await page.getByRole('button', { name: /show pet picker/i }).click();
|
||||
await expect(page.locator('.pet-rail')).toBeVisible();
|
||||
});
|
||||
|
||||
test('entry chrome avoids horizontal overflow on compact desktop width', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 820, height: 900 });
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
||||
await expect(page.locator('.app-chrome-header')).toBeVisible();
|
||||
|
||||
const overflow = await page.evaluate(() => {
|
||||
const header = document.querySelector('.app-chrome-header');
|
||||
if (!(header instanceof HTMLElement)) return null;
|
||||
return Math.max(0, header.scrollWidth - header.clientWidth);
|
||||
});
|
||||
expect(overflow).not.toBeNull();
|
||||
expect(overflow!).toBeLessThanOrEqual(2);
|
||||
|
||||
const pageOverflow = await page.evaluate(() =>
|
||||
Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
|
||||
);
|
||||
expect(pageOverflow).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
|
@ -112,7 +112,7 @@ test('prompt template retry preserves the edited body in project metadata', asyn
|
|||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await gotoEntryHome(page);
|
||||
await page.getByTestId('new-project-tab-image').click();
|
||||
await page.getByTestId('new-project-name').fill('Prompt template retry metadata');
|
||||
|
||||
|
|
@ -144,7 +144,7 @@ test('prompt template retry preserves the edited body in project metadata', asyn
|
|||
test('live artifact empty connector CTA opens the gated connector setup path', async ({ page }) => {
|
||||
await routeConnectors(page, []);
|
||||
|
||||
await page.goto('/');
|
||||
await gotoEntryHome(page);
|
||||
await page.getByTestId('new-project-tab-live-artifact').click();
|
||||
await expect(page.getByTestId('new-project-connectors')).toBeVisible();
|
||||
|
||||
|
|
@ -162,7 +162,7 @@ test('live artifact empty connector CTA opens the gated connector setup path', a
|
|||
test('connectors search supports empty results and keyboard-closeable details', async ({ page }) => {
|
||||
await routeConnectors(page, CONNECTORS);
|
||||
|
||||
await page.goto('/');
|
||||
await gotoEntryHome(page);
|
||||
await page.getByTestId('entry-tab-connectors').click();
|
||||
await expect(page.getByTestId('connector-grid-wrap')).toBeVisible();
|
||||
|
||||
|
|
@ -185,6 +185,61 @@ test('connectors search supports empty results and keyboard-closeable details',
|
|||
await expect(page.getByTestId('connector-drawer')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('saving a Composio key from Settings unlocks the connectors gate immediately', async ({ page }) => {
|
||||
const { accountLabel: _unusedAccountLabel, ...slackConnector } = CONNECTORS[1]!;
|
||||
await routeConnectors(page, [
|
||||
{
|
||||
...CONNECTORS[0]!,
|
||||
status: 'available',
|
||||
auth: { provider: 'composio', configured: false },
|
||||
},
|
||||
{
|
||||
...slackConnector,
|
||||
status: 'available',
|
||||
auth: { provider: 'composio', configured: false },
|
||||
},
|
||||
]);
|
||||
|
||||
let savedComposioBody: unknown = null;
|
||||
await page.route('**/api/connectors/composio/config', async (route) => {
|
||||
savedComposioBody = route.request().postDataJSON();
|
||||
await route.fulfill({ status: 200, body: '{}' });
|
||||
});
|
||||
await page.route('**/api/app-config', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ status: 200, json: { config: null } });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 200, body: '{}' });
|
||||
});
|
||||
|
||||
await gotoEntryHome(page);
|
||||
await page.getByTestId('entry-tab-connectors').click();
|
||||
await expect(page.getByTestId('connector-gate')).toBeVisible();
|
||||
await expect(page.getByTestId('connectors-search-input')).toBeDisabled();
|
||||
|
||||
await page.getByTestId('connector-gate-action').click();
|
||||
const settingsDialog = page.getByRole('dialog');
|
||||
await expect(settingsDialog).toBeVisible();
|
||||
await settingsDialog.getByPlaceholder('Paste Composio API key').fill('cmp-secret-1234');
|
||||
await settingsDialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
|
||||
expect(savedComposioBody).toEqual({ apiKey: 'cmp-secret-1234' });
|
||||
await expect(page.getByTestId('connector-gate')).toHaveCount(0);
|
||||
await expect(page.getByTestId('connectors-search-input')).toBeEnabled();
|
||||
await expect(connectorCard(page, 'github')).toBeVisible();
|
||||
|
||||
const savedConfig = await page.evaluate((key) => {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
}, STORAGE_KEY);
|
||||
expect(savedConfig?.composio).toMatchObject({
|
||||
apiKey: '',
|
||||
apiKeyConfigured: true,
|
||||
apiKeyTail: '1234',
|
||||
});
|
||||
});
|
||||
|
||||
async function routeConnectors(page: Page, connectors: typeof CONNECTORS) {
|
||||
await page.route('**/api/connectors', async (route) => {
|
||||
await route.fulfill({ json: { connectors } });
|
||||
|
|
@ -211,6 +266,11 @@ async function routeConnectors(page: Page, connectors: typeof CONNECTORS) {
|
|||
});
|
||||
}
|
||||
|
||||
async function gotoEntryHome(page: Page) {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
||||
}
|
||||
|
||||
function connectorCard(page: Page, id: string) {
|
||||
return page.locator(`article.connector-card[data-connector-id="${id}"]`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Page } from '@playwright/test';
|
|||
|
||||
const STORAGE_KEY = 'open-design:config';
|
||||
|
||||
async function bootstrapWithLegacyConfig(
|
||||
async function openExecutionSettings(
|
||||
page: Page,
|
||||
config: Record<string, unknown>,
|
||||
) {
|
||||
|
|
@ -23,8 +23,39 @@ async function bootstrapWithLegacyConfig(
|
|||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
}
|
||||
|
||||
async function openExecutionSettingsWithAgents(
|
||||
page: Page,
|
||||
config: Record<string, unknown>,
|
||||
agents: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
bin: string;
|
||||
available: boolean;
|
||||
version?: string | null;
|
||||
models?: Array<{ id: string; label: string }>;
|
||||
}>,
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ key, value }) => {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
},
|
||||
{ key: STORAGE_KEY, value: config },
|
||||
);
|
||||
|
||||
await page.route('**/api/health', async (route) => {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
|
||||
});
|
||||
await page.route('**/api/agents', async (route) => {
|
||||
await route.fulfill({ json: { agents } });
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByTitle('Configure execution mode').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
}
|
||||
|
||||
test('legacy known OpenAI provider switches to the matching Anthropic preset', async ({ page }) => {
|
||||
await bootstrapWithLegacyConfig(page, {
|
||||
await openExecutionSettings(page, {
|
||||
mode: 'api',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
|
|
@ -57,7 +88,7 @@ test('legacy known OpenAI provider switches to the matching Anthropic preset', a
|
|||
});
|
||||
|
||||
test('legacy custom provider preserves custom baseUrl and model when switching protocols', async ({ page }) => {
|
||||
await bootstrapWithLegacyConfig(page, {
|
||||
await openExecutionSettings(page, {
|
||||
mode: 'api',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://my-proxy.example.com/v1',
|
||||
|
|
@ -88,3 +119,140 @@ test('legacy custom provider preserves custom baseUrl and model when switching p
|
|||
await expect(baseUrlInput).toHaveValue('https://my-proxy.example.com/v1');
|
||||
await expect(customModelInput).toHaveValue('my-custom-model');
|
||||
});
|
||||
|
||||
test('BYOK quick fill provider updates fields and saved settings persist after closing and reopening', async ({ page }) => {
|
||||
await openExecutionSettings(page, {
|
||||
mode: 'api',
|
||||
apiKey: '',
|
||||
apiProtocol: 'openai',
|
||||
apiVersion: '',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
apiProviderBaseUrl: 'https://api.openai.com/v1',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
onboardingCompleted: true,
|
||||
mediaProviders: {},
|
||||
agentModels: {},
|
||||
agentCliEnv: {},
|
||||
});
|
||||
|
||||
await page.getByRole('tab', { name: 'OpenAI', exact: true }).click();
|
||||
await page.getByLabel('Quick fill provider').selectOption('1');
|
||||
await expect(page.getByLabel('Model')).toHaveValue('deepseek-chat');
|
||||
await expect(page.getByLabel('Base URL')).toHaveValue('https://api.deepseek.com');
|
||||
|
||||
await page.getByRole('button', { name: 'Show' }).click();
|
||||
const apiKeyInput = page.getByLabel('API key');
|
||||
await expect(apiKeyInput).toHaveAttribute('type', 'text');
|
||||
await apiKeyInput.fill('sk-openai-test');
|
||||
|
||||
const saveButton = page.getByRole('button', { name: 'Save', exact: true });
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await saveButton.click();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
|
||||
const savedConfig = await page.evaluate((key) => {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
}, STORAGE_KEY);
|
||||
expect(savedConfig).toMatchObject({
|
||||
mode: 'api',
|
||||
apiProtocol: 'openai',
|
||||
apiKey: 'sk-openai-test',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
apiProviderBaseUrl: 'https://api.deepseek.com',
|
||||
});
|
||||
|
||||
await page.getByTitle('Configure execution mode').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'OpenAI', exact: true })).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.getByLabel('Quick fill provider')).toHaveValue('1');
|
||||
await expect(page.getByLabel('Model')).toHaveValue('deepseek-chat');
|
||||
await expect(page.getByLabel('Base URL')).toHaveValue('https://api.deepseek.com');
|
||||
await expect(page.getByLabel('API key')).toHaveValue('sk-openai-test');
|
||||
});
|
||||
|
||||
test('BYOK save stays disabled until required fields are valid', async ({ page }) => {
|
||||
await openExecutionSettings(page, {
|
||||
mode: 'api',
|
||||
apiKey: '',
|
||||
apiProtocol: 'anthropic',
|
||||
apiVersion: '',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
apiProviderBaseUrl: 'https://api.anthropic.com',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
onboardingCompleted: true,
|
||||
mediaProviders: {},
|
||||
agentModels: {},
|
||||
agentCliEnv: {},
|
||||
});
|
||||
|
||||
const saveButton = page.getByRole('button', { name: 'Save', exact: true });
|
||||
await expect(saveButton).toBeDisabled();
|
||||
|
||||
await page.getByLabel('API key').fill('sk-ant-test');
|
||||
await expect(saveButton).toBeEnabled();
|
||||
|
||||
await page.getByLabel('Base URL').fill('http://10.0.0.5:11434/v1');
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await expect(page.locator('#settings-base-url-error')).toContainText('valid public');
|
||||
|
||||
await page.getByLabel('Base URL').fill('http://localhost:11434/v1');
|
||||
await expect(saveButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('saving Local CLI updates the entry status pill with the selected agent', async ({ page }) => {
|
||||
await openExecutionSettingsWithAgents(
|
||||
page,
|
||||
{
|
||||
mode: 'api',
|
||||
apiKey: 'sk-openai-test',
|
||||
apiProtocol: 'openai',
|
||||
apiVersion: '',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
apiProviderBaseUrl: 'https://api.openai.com/v1',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
onboardingCompleted: true,
|
||||
mediaProviders: {},
|
||||
agentModels: {},
|
||||
agentCliEnv: {},
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex CLI',
|
||||
bin: 'codex',
|
||||
available: true,
|
||||
version: '0.80.0',
|
||||
models: [{ id: 'default', label: 'Default' }],
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Gemini CLI',
|
||||
bin: 'gemini',
|
||||
available: false,
|
||||
version: null,
|
||||
models: [],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await page.getByRole('tab', { name: /Local CLI.*1 installed/i }).click();
|
||||
await page.getByRole('button', { name: /Codex CLI/i }).click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
|
||||
const executionPill = page.getByTitle('Configure execution mode');
|
||||
await expect(executionPill).toContainText('Local CLI');
|
||||
await expect(executionPill).toContainText('Codex CLI');
|
||||
await expect(executionPill).toContainText('0.80.0');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue