import { expect, type Locator, type Page } from '@playwright/test'; import { invokeBridge } from './bridge'; import { TEAM_SUPPORTED_BACKENDS } from './teamConfig'; type TeamAgent = { role: string; name: string }; type TeamRecord = { id: string; name: string; agents: TeamAgent[] }; /** UI label patterns for each backend leader type. */ const BACKEND_UI_PATTERN: Record = { claude: /Claude Code/i, codex: /Codex/i, gemini: /Gemini/i, }; /** * Create a team through the sidebar UI (TeamCreateModal). * * Uses the real user flow so the TeamCreateModal.onCreated -> refreshTeams() * callback runs and the sidebar SWR cache stays in sync. Plain HTTP POST of * /api/teams would bypass this, leaving the sidebar empty under Playwright * Electron (see mnemo #269). * * Throws if no supported backend is available — callers should skip the test. */ export async function createTeam(page: Page, name: string, leaderType?: string): Promise { if (TEAM_SUPPORTED_BACKENDS.size === 0) { throw new Error('No supported team backends available — skip this test'); } await page.evaluate(() => { window.location.hash = '#/team'; }); await page.waitForFunction(() => window.location.hash === '#/team', { timeout: 10_000 }).catch(() => {}); const createBtn = page.locator('[data-testid="team-create-btn"]').first(); await createBtn.waitFor({ state: 'visible', timeout: 10_000 }); await createBtn.click(); const modal = page.locator('.arco-modal').last(); await modal.waitFor({ state: 'visible', timeout: 5_000 }); const nameInput = modal.getByRole('textbox').first(); await nameInput.fill(name); const option = await pickLeaderOption(page, leaderType); if (!option) { await closeModal(page, modal); throw new Error(`No assistant option matched leader type "${leaderType ?? 'any'}" — skip this test`); } await option.click(); const confirmBtn = modal.locator('.arco-btn-primary'); await expect(confirmBtn).toBeEnabled({ timeout: 5_000 }); await confirmBtn.click(); await page.waitForURL(/\/team\/[^/?#]+/, { timeout: 15_000 }); const hash = await page.evaluate(() => window.location.hash); const match = hash.match(/#\/team\/([^/?#]+)/); if (!match) { throw new Error(`Could not extract teamId from URL hash: ${hash}`); } return match[1]; } async function pickLeaderOption(page: Page, leaderType?: string): Promise { const options = page.locator('.team-create-modal [data-testid^="team-create-agent-option-"]'); await options .first() .waitFor({ state: 'visible', timeout: 5_000 }) .catch(() => {}); if (!leaderType) { const count = await options.count().catch(() => 0); for (let i = 0; i < count; i++) { const option = options.nth(i); const classes = (await option.getAttribute('class').catch(() => '')) ?? ''; if (!classes.includes('cursor-not-allowed')) return option; } return null; } const pattern = BACKEND_UI_PATTERN[leaderType] ?? new RegExp(leaderType, 'i'); const count = await options.count().catch(() => 0); for (let i = 0; i < count; i++) { const option = options.nth(i); const classes = (await option.getAttribute('class').catch(() => '')) ?? ''; if (classes.includes('cursor-not-allowed')) continue; const text = await option.textContent().catch(() => ''); if (pattern.test(text ?? '')) return option; } return null; } async function closeModal(page: Page, modal: Locator): Promise { const cancel = modal .locator('.arco-btn') .filter({ hasText: /Cancel|取消/i }) .first(); if ((await cancel.count().catch(() => 0)) > 0) { await cancel.click({ force: true }).catch(() => {}); } await page .locator('.arco-modal') .last() .waitFor({ state: 'hidden', timeout: 5_000 }) .catch(() => {}); } /** * Find-or-create a team by name. Returns teamId. */ export async function ensureTeam(page: Page, name: string, leaderType?: string): Promise { const teams = await invokeBridge(page, 'team.list', { user_id: 'system_default_user', }).catch(() => [] as TeamRecord[]); const existing = teams.find((t) => t.name === name); if (existing) return existing.id; return createTeam(page, name, leaderType); } /** * Delete a team by id via IPC. No-op if team doesn't exist. */ export async function deleteTeam(page: Page, id: string): Promise { await invokeBridge(page, 'team.remove', { id }).catch(() => {}); } /** * Remove all teams whose name matches `name`. Used for pre-test cleanup. * * Cleanup is done via IPC — faster and doesn't require the sidebar row to * render. After deleting we reload the page so SWR refetches the team list * and the sidebar reflects current backend state. */ export async function cleanupTeamsByName(page: Page, name: string): Promise { const teams = await invokeBridge(page, 'team.list', { user_id: 'system_default_user', }).catch(() => [] as TeamRecord[]); const matches = teams.filter((t) => t.name === name); for (const t of matches) { await invokeBridge(page, 'team.remove', { id: t.id }).catch(() => {}); } if (matches.length > 0) { const url = page.url(); await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(2_000); } }