open-design/e2e/ui/settings-connectors-auth-recovery.test.ts
lefarcen c14baf07d3 Merge origin/main into release/v0.8.0
PR #2461 sync prep — resolves 14 conflicts merging 84 main-side commits
on top of 58 release-side commits accumulated during the 0.8.0 cycle.

Resolution summary:

Take main (theirs) where main carried deliberate forward progress:
- apps/web/src/components/PluginCard.tsx — 7 hunks, i18n migration:
  hardcoded English aria-labels/titles replaced with t() calls keyed
  on pluginCard.* (all 8 keys verified present in en.ts).
- apps/web/src/components/TasksView.tsx — 1 hunk, source-ingestion
  feature: sortedRoutines (newest-first), sourceIngestionTemplates,
  patchSourceForm, submitSourceIngestion. activeCount/pausedCount
  semantics preserved (now keyed on sortedRoutines, count unchanged).
- e2e/ui/app.test.ts — new node:fs/promises + tmpdir + path + @/timeouts
  imports needed by main-side test helpers.
- e2e/ui/settings-local-cli-codex-fallback.test.ts — menu-dismissal
  helper block added by main.

Keep both sides where each added a different field to the same object
literal:
- apps/web/src/components/ProjectView.tsx (locale + analyticsHints
  spread).
- apps/web/src/components/DesignSystemFlow.tsx (locale + analyticsHints).

Take release (ours) where release carried deliberate work that ships
0.8.0:
- CHANGELOG.md — release-side 0.8.0 entry + PR link refs; main's
  Unreleased section was the same body of work, now finalized.
- apps/landing-page/public/{apple-touch-icon,favicon}.png +
  apps/web/public/app-icon.svg — release-side visual refresh assets
  consistent with 0.8.0 stable ship.
- tools/pack/src/linux.ts — packageVersion const required by line 466;
  taking main's empty line would build-error.
- e2e/ui/project-management-flows.test.ts +
  e2e/ui/settings-api-protocol.test.ts +
  e2e/ui/settings-memory-routines.test.ts — release-side release-smoke
  hardening (shangxinyu1 + PerishFire) takes precedence on overlap.

Closes-issue / unblocks: PR #2461 sync release/v0.8.0 → main.
2026-05-23 12:17:18 +08:00

329 lines
10 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
const OPEN_SETTINGS_LABEL = /Open settings|打开设置|開啟設定/i;
test.describe.configure({ timeout: 30_000 });
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: [],
},
{
id: 'slack',
name: 'Slack',
provider: 'composio',
category: 'Communication',
description: 'Search channels and messages.',
status: 'connected',
accountLabel: 'design-team',
auth: { provider: 'composio', configured: true },
tools: [],
},
] as const;
function baseConfig(): Record<string, unknown> {
return {
mode: 'daemon',
apiKey: '',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
};
}
function pendingAuthorizationStorage() {
return {
github: {
expiresAt: '2099-01-01T00:00:00.000Z',
},
};
}
function connectorCard(scope: Page | Locator, id: string) {
return scope.locator(`article.connector-card[data-connector-id="${id}"]`);
}
async function waitForLoadingToClear(page: Page) {
await expect(page.getByText('Loading Open Design…')).toHaveCount(0, { timeout: 15_000 });
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible()) {
await privacyDialog.getByRole('button', { name: /not now|don't share/i }).click();
}
await expect(page.getByTestId('home-hero')).toBeVisible();
}
async function openSettingsDialogFromEntry(page: Page) {
await waitForLoadingToClear(page);
await page.getByRole('button', { name: OPEN_SETTINGS_LABEL }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
return dialog;
}
async function openConnectorsSettings(
page: Page,
{
connectors = CONNECTORS,
onPrepare = () => ({
results: { github: { status: 'ready', authConfigId: 'cfg_123' } },
}),
onConnect = () => ({
status: 200,
body: {
connector: {
...CONNECTORS[0],
status: 'connected',
accountLabel: 'octo-user',
},
auth: { kind: 'connected' },
},
}),
onCancel = () => ({
status: 200,
body: {
connector: {
...CONNECTORS[0],
status: 'available',
},
},
}),
pendingAuthorization = null,
blockPopup = false,
}: {
connectors?: typeof CONNECTORS;
onPrepare?: () => Record<string, unknown>;
onConnect?: () => { status: number; body: Record<string, unknown> };
onCancel?: () => { status: number; body: Record<string, unknown> };
pendingAuthorization?: Record<string, unknown> | null;
blockPopup?: boolean;
} = {},
) {
let cancelRequestCount = 0;
await page.addInitScript(
({ key, value, pendingAuthorization, blockPopup }) => {
window.localStorage.setItem(key, JSON.stringify(value));
if (pendingAuthorization) {
window.sessionStorage.setItem(
'od-connectors-authorization-pending',
JSON.stringify(pendingAuthorization),
);
}
window.open = (blockPopup
? (() => null)
: (() => ({
document: { title: '', body: { innerHTML: '' } },
location: { replace() {} },
close() {},
}))) as unknown as typeof window.open;
},
{ key: STORAGE_KEY, value: baseConfig(), pendingAuthorization, blockPopup },
);
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: [
{
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
available: true,
version: '0.130.0',
models: [{ id: 'default', label: 'Default' }],
},
],
},
});
});
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { config: baseConfig() } });
return;
}
await route.fulfill({ json: { ok: true } });
});
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: 'accountLabel' in connector ? connector.accountLabel : undefined,
},
]),
);
await route.fulfill({ json: { statuses } });
});
await page.route('**/api/connectors/discovery*', async (route) => {
await route.fulfill({
json: {
connectors,
meta: { provider: 'composio' },
},
});
});
await page.route('**/api/connectors/composio/config', async (route) => {
await route.fulfill({ json: { configured: true, apiKeyTail: '1234' } });
});
await page.route('**/api/connectors/auth-configs/prepare', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onPrepare()),
});
});
await page.route('**/api/connectors/github/connect', async (route) => {
const response = onConnect();
await route.fulfill({
status: response.status,
contentType: 'application/json',
body: JSON.stringify(response.body),
});
});
await page.route('**/api/connectors/github/authorization/cancel*', async (route) => {
cancelRequestCount += 1;
const response = onCancel();
await route.fulfill({
status: response.status,
contentType: 'application/json',
body: JSON.stringify(response.body),
});
});
await gotoEntryHome(page);
const dialog = await openSettingsDialogFromEntry(page);
await dialog.getByRole('button', { name: /Connectors|连接器/i }).click();
await expect(dialog.getByTestId('connector-grid-wrap')).toBeVisible();
await expect(connectorCard(dialog, 'github')).toBeVisible();
return { dialog, getCancelRequestCount: () => cancelRequestCount };
}
test.describe('Settings connectors auth recovery', () => {
test('keeps a pending authorization visible when the connector enters authorization-pending state', async ({ page }) => {
const { dialog } = await openConnectorsSettings(page, {
pendingAuthorization: pendingAuthorizationStorage(),
});
const githubCard = connectorCard(dialog, 'github');
await expect(githubCard.getByRole('button', { name: 'Cancel' })).toBeVisible();
await expect
.poll(async () =>
page.evaluate(() => {
const raw = window.sessionStorage.getItem('od-connectors-authorization-pending');
if (!raw) return false;
const parsed = JSON.parse(raw) as Record<string, { expiresAt?: string }>;
return typeof parsed.github?.expiresAt === 'string' && parsed.github.expiresAt.length > 0;
}),
)
.toBe(true);
});
test('settles a pending authorization into Disconnect when status polling reports the connector as connected', async ({ page }) => {
let statusRequests = 0;
const { dialog } = await openConnectorsSettings(page, {
pendingAuthorization: pendingAuthorizationStorage(),
});
await page.unroute('**/api/connectors/status');
await page.route('**/api/connectors/status', async (route) => {
statusRequests += 1;
const githubStatus =
statusRequests >= 2
? { status: 'connected', accountLabel: 'octo-user' }
: { status: 'available', accountLabel: undefined };
await route.fulfill({
json: {
statuses: {
github: githubStatus,
slack: { status: 'connected', accountLabel: 'design-team' },
},
},
});
});
const githubCard = connectorCard(dialog, 'github');
await expect(githubCard.getByRole('button', { name: 'Cancel' })).toBeVisible();
await expect
.poll(async () => statusRequests, { timeout: 5000 })
.toBeGreaterThanOrEqual(2);
await expect(githubCard.getByRole('button', { name: 'Disconnect' })).toBeVisible();
await expect(githubCard.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
await expect
.poll(async () =>
page.evaluate(() => window.sessionStorage.getItem('od-connectors-authorization-pending')),
)
.toBe(null);
});
test('returns a pending authorization to Connect and clears session storage after a successful cancel', async ({ page }) => {
const { dialog } = await openConnectorsSettings(page, {
pendingAuthorization: pendingAuthorizationStorage(),
onCancel: () => ({
status: 200,
body: {
connector: {
...CONNECTORS[0],
status: 'available',
},
},
}),
});
const githubCard = connectorCard(dialog, 'github');
await expect(githubCard.getByRole('button', { name: 'Cancel' })).toBeVisible();
await githubCard.getByRole('button', { name: 'Cancel' }).click();
await expect(githubCard.getByRole('button', { name: 'Connect' })).toBeVisible();
await expect(githubCard.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
await expect
.poll(async () =>
page.evaluate(() => window.sessionStorage.getItem('od-connectors-authorization-pending')),
)
.toBe(null);
});
});