Files
nexu-io--open-design/e2e/ui/entry-configuration-flows.test.ts
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

427 lines
14 KiB
TypeScript

import { expect, test } from '@/playwright/suite';
import { ensureRailOpen, openNewProjectModal } from '@/playwright/rail';
import { routeAgents } from '@/playwright/mock-factory';
import { T } from '@/timeouts';
import type { Locator, Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
test.describe.configure({ timeout: T.xlong });
const CONNECTORS = [
{
id: 'github',
name: 'GitHub',
provider: 'composio',
category: 'Developer tools',
description: 'Read repository issues and pull requests.',
status: 'available',
auth: { provider: 'composio', configured: true },
tools: [
{
name: 'list_issues',
title: 'List issues',
description: 'List recent issues from a repository.',
safety: {
sideEffect: 'read',
approval: 'auto',
reason: 'Read-only issue lookup.',
},
refreshEligible: true,
},
],
},
{
id: 'slack',
name: 'Slack',
provider: 'composio',
category: 'Communication',
description: 'Search channels and messages.',
status: 'connected',
accountLabel: 'design-team',
auth: { provider: 'composio', configured: true },
tools: [],
},
];
const IMAGE_TEMPLATE = {
id: 'editorial-poster',
surface: 'image',
title: 'Editorial Poster',
summary: 'A punchy launch poster for a product announcement.',
category: 'Marketing',
tags: ['poster', 'launch'],
model: 'gpt-image-1',
aspect: '4:5',
source: {
repo: 'open-design/test-prompts',
license: 'MIT',
author: 'Open Design QA',
},
};
async function readSavedConfig(page: Page) {
return page.evaluate((key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
}, STORAGE_KEY);
}
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 routeAgents(page, [
{
id: 'mock',
name: 'Mock Agent',
bin: 'mock-agent',
available: true,
version: 'test',
models: [{ id: 'default', label: 'Default' }],
},
]);
});
test('[P1] prompt template retry preserves the edited body in project metadata', async ({ page }) => {
let detailRequests = 0;
await page.route('**/api/prompt-templates', async (route) => {
await route.fulfill({ json: { promptTemplates: [IMAGE_TEMPLATE] } });
});
await page.route('**/api/prompt-templates/image/editorial-poster', async (route) => {
detailRequests += 1;
if (detailRequests === 1) {
await route.fulfill({ status: 500, body: 'template unavailable' });
return;
}
await route.fulfill({
json: {
promptTemplate: {
...IMAGE_TEMPLATE,
prompt: 'Original poster prompt with dramatic type and product photography.',
},
},
});
});
await gotoEntryHome(page);
await openNewProjectModal(page);
await page.getByTestId('new-project-tab-media').click();
await page.getByTestId('new-project-media-surface-image').click();
await page.getByTestId('new-project-name').fill('Prompt template retry metadata');
await page.getByTestId('prompt-template-trigger').click();
await page.getByTestId('prompt-template-search').fill('poster');
await page.getByRole('option', { name: /Editorial Poster/i }).click();
await expect(page.getByTestId('prompt-template-error')).toBeVisible();
await page.getByTestId('prompt-template-retry').click();
await expect(page.getByTestId('prompt-template-error')).toHaveCount(0);
await expect(page.getByTestId('prompt-template-body')).toContainText('Original poster prompt');
await page.getByTestId('prompt-template-body').fill('');
await expect(page.getByTestId('prompt-template-empty-hint')).toBeVisible();
await page.getByTestId('prompt-template-body').fill(
'Edited QA prompt: bold poster, one hero product, crisp headline.',
);
await expect(page.getByTestId('create-project')).toBeEnabled();
const createResponsePromise = page.waitForResponse((response) =>
response.url().endsWith('/api/projects') &&
response.request().method() === 'POST',
);
await page.getByTestId('create-project').click();
const createResponse = await createResponsePromise;
expect(createResponse.ok(), await createResponse.text()).toBeTruthy();
const project = await fetchCurrentProject(page);
expect(project.metadata?.promptTemplate).toMatchObject({
id: 'editorial-poster',
surface: 'image',
title: 'Editorial Poster',
prompt: 'Edited QA prompt: bold poster, one hero product, crisp headline.',
});
});
test('[P1] live artifact empty connector CTA opens the gated connector setup path', async ({ page }) => {
await routeConnectors(page, []);
await routeComposioConfig(page, { configured: false, apiKeyTail: '' });
await gotoEntryHome(page);
await openNewProjectModal(page);
await page.getByTestId('new-project-tab-live-artifact').click();
await expect(page.getByTestId('new-project-connectors')).toBeVisible();
// The empty CTA now opens Integrations → Connectors directly. The Composio
// API key field sits at the top of the section; the catalog (and its gate)
// sits below it.
await page.getByTestId('new-project-connectors-empty').click();
await expect(page.getByTestId('new-project-modal')).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Integrations' })).toBeVisible();
await expect(page.getByTestId('integrations-tab-connectors')).toHaveAttribute(
'aria-selected',
'true',
);
await expect(page.getByPlaceholder('Paste Composio API key')).toBeVisible();
await expect(page.getByTestId('connector-gate')).toBeVisible();
await expect(page.getByTestId('connectors-search-input')).toBeDisabled();
});
test('[P2] connectors search supports empty results and keyboard-closeable details', async ({ page }) => {
await routeConnectors(page, CONNECTORS);
await routeComposioConfig(page, { configured: true, apiKeyTail: '1234' });
await page.addInitScript((key) => {
const next = {
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
agentId: 'mock',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: '1234',
},
};
window.localStorage.setItem(key, JSON.stringify(next));
}, STORAGE_KEY);
await gotoEntryHome(page);
const settingsDialog = await openIntegrationsConnectors(page);
const search = settingsDialog.getByTestId('connectors-search-input');
await search.fill('git');
await expect(connectorCard(settingsDialog, 'github')).toBeVisible();
await expect(connectorCard(settingsDialog, 'slack')).toHaveCount(0);
await search.fill('missing connector');
await expect(settingsDialog.getByTestId('connectors-empty')).toBeVisible();
await settingsDialog.getByTestId('connectors-search-clear').click();
await expect(settingsDialog.getByTestId('connectors-empty')).toHaveCount(0);
await expect(connectorCard(settingsDialog, 'github')).toBeVisible();
await expect(connectorCard(settingsDialog, 'slack')).toBeVisible();
await connectorCard(settingsDialog, 'github').focus();
await connectorCard(settingsDialog, 'github').press('Enter');
await expect(page.getByTestId('connector-drawer')).toBeVisible();
await expect(page.getByTestId('connector-drawer')).toContainText('List issues');
await page.keyboard.press('Escape');
await expect(page.getByTestId('connector-drawer')).toHaveCount(0);
});
test('[P0] saving a Composio key from Integrations 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);
const settingsDialog = await openIntegrationsConnectors(page);
await expect(settingsDialog.getByTestId('connectors-search-input')).toBeDisabled();
await settingsDialog.getByPlaceholder('Paste Composio API key').fill('cmp-secret-1234');
await settingsDialog.getByRole('button', { name: 'Save key', exact: true }).click();
expect(savedComposioBody).toEqual({ apiKey: 'cmp-secret-1234' });
await expect(settingsDialog.getByTestId('connectors-search-input')).toBeEnabled();
await expect(connectorCard(settingsDialog, 'github')).toBeVisible();
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: '1234',
},
});
const savedConfig = await readSavedConfig(page);
expect(savedConfig?.composio).toMatchObject({
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: '1234',
});
expect(savedConfig?.composio?.apiKey).toBe('');
});
test('[P1] typing a draft replacement Composio key does not trigger global autosave', async ({ page }) => {
await routeConnectors(page, CONNECTORS);
await routeComposioConfig(page, { configured: true, apiKeyTail: '1234' });
await page.addInitScript((key) => {
const next = {
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
agentId: 'mock',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: '1234',
},
};
window.localStorage.setItem(key, JSON.stringify(next));
}, STORAGE_KEY);
const appConfigPersistBodies: unknown[] = [];
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: { config: null } });
return;
}
appConfigPersistBodies.push(route.request().postDataJSON());
await route.fulfill({ status: 200, body: '{}' });
});
await gotoEntryHome(page);
const settingsDialog = await openIntegrationsConnectors(page);
await expect(settingsDialog.getByTestId('connector-grid-wrap')).toBeVisible();
await expect(settingsDialog.getByText('Saved · ••••1234')).toBeVisible();
await page.waitForTimeout(1200);
const appConfigPersistCountBeforeDraftEdit = appConfigPersistBodies.length;
const replacementInput = settingsDialog.getByPlaceholder('Paste a new key to replace the saved one');
await replacementInput.fill('cmp-draft-secret-9999');
await expect(settingsDialog.getByRole('button', { name: 'Save key', exact: true })).toBeEnabled();
await page.waitForTimeout(900);
expect(appConfigPersistBodies).toHaveLength(appConfigPersistCountBeforeDraftEdit);
const savedConfig = await readSavedConfig(page);
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 } });
});
await page.route('**/api/connectors/status', async (route) => {
const statuses = Object.fromEntries(
connectors.map((connector) => [
connector.id,
{
status: connector.status,
accountLabel: connector.accountLabel,
},
]),
);
await route.fulfill({ json: { statuses } });
});
await page.route('**/api/connectors/discovery*', async (route) => {
await route.fulfill({
json: {
connectors,
meta: { provider: 'composio' },
},
});
});
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.getByText('Loading Open Design…').waitFor({ state: 'hidden', timeout: T.long });
await expect(page.getByTestId('home-hero')).toBeVisible({ timeout: T.long });
await expect(page.getByTestId('home-hero-input')).toBeVisible({ timeout: T.long });
}
async function openIntegrationsConnectors(page: Page): Promise<Locator> {
await ensureRailOpen(page);
await page.getByTestId('entry-nav-integrations').click();
await expect(page).toHaveURL(/\/integrations$/);
await expect(page.getByRole('heading', { name: 'Integrations' })).toBeVisible();
await page.getByTestId('integrations-tab-connectors').click();
await expect(page.getByTestId('integrations-tab-connectors')).toHaveAttribute(
'aria-selected',
'true',
);
const panel = page.locator('.integrations-view__panel');
await expect(panel.getByTestId('connector-grid-wrap')).toBeVisible();
return panel;
}
async function routeComposioConfig(
page: Page,
config: { configured: boolean; apiKeyTail?: string },
) {
await page.route('**/api/connectors/composio/config', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: config });
return;
}
await route.fulfill({ json: { ok: true } });
});
}
function connectorCard(scope: Page | Locator, id: string) {
return scope.locator(`article.connector-card[data-connector-id="${id}"]`);
}
async function fetchCurrentProject(page: Page) {
await expect(page).toHaveURL(/\/projects\/[^/]+/);
const url = new URL(page.url());
const [, projectId] = url.pathname.match(/\/projects\/([^/]+)/) ?? [];
expect(projectId).toBeTruthy();
const response = await page.request.get(`/api/projects/${projectId}`);
expect(response.ok()).toBeTruthy();
const body = (await response.json()) as {
project: {
metadata?: {
promptTemplate?: {
id: string;
surface: string;
title: string;
prompt: string;
};
};
};
};
return body.project;
}