/** * E2E tests for Bitable views — the standalone /bitable route hierarchy. * * Navigation: login via UI form → click TopNav "多维表格" icon (SPA navigate * to /bitable). This avoids the whoami cold-start bug that page.goto would * trigger on a full reload. * * ponytail: bitable backend may not be fully configured (no DATABASE_URL); * the view should still render its topbar and file list gracefully. */ import { test, expect, type Page } from '@playwright/test' import { TEST_USER, clearAuth, waitForServer } from './helpers' async function loginAndOpenBitable(page: Page): Promise { await page.goto('/login') await clearAuth(page) await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username) await page.getByPlaceholder('请输入密码').fill(TEST_USER.password) await page.getByRole('button', { name: /登\s*录/ }).click() await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 }) // SPA-navigate to /bitable via the TopNav "多维表格" button. await page.getByRole('button', { name: '多维表格' }).click() await expect(page).toHaveURL(/\/bitable/, { timeout: 15_000 }) await expect(page.locator('.bitable-file-list-view')).toBeVisible({ timeout: 15_000 }) } test.describe('Bitable View E2E', () => { test.beforeAll(async () => { try { await waitForServer(undefined, 5_000) } catch { test.skip(true, 'Backend not running — skipping bitable view E2E') } }) test('B1: bitable file list loads without white screen or 401 redirect', async ({ page }) => { await loginAndOpenBitable(page) // URL should be /bitable, not redirected to /login. await expect(page).toHaveURL(/\/bitable/, { timeout: 10_000 }) // File list view root element visible — no white screen. await expect(page.locator('.bitable-file-list-view')).toBeVisible() }) test('B2: bitable file list core elements are visible', async ({ page }) => { await loginAndOpenBitable(page) // The topbar title "多维表格" should be visible. await expect(page.locator('.bitable-file-list-view__title')).toContainText('多维表格', { timeout: 15_000, }) // Either the file grid (cards) or the empty state is rendered. await expect .poll( async () => { const grid = await page.locator('.bitable-file-list-view__grid').count() const empty = await page.locator('.bitable-file-list-view__empty').count() return grid + empty }, { timeout: 15_000, intervals: [1_000] }, ) .toBeGreaterThan(0) }) test('B3: new file button is visible and clickable', async ({ page }) => { await loginAndOpenBitable(page) const newButton = page.getByRole('button', { name: /新建文件/ }) await expect(newButton).toBeVisible({ timeout: 10_000 }) }) }) // --------------------------------------------------------------------------- // U4 (R3): View type switcher — "新建视图" exposes 5 view types; only `grid` // is enabled in v1, the rest are disabled with a "规划中" tooltip. // // These tests require a running backend (to create a file + table so the // ViewSwitcher renders). They skip gracefully if the backend is down, mirroring // the B1-B3 suite. The POST /views request is intercepted and mocked so the // view-creation assertion is deterministic and does not depend on backend // view persistence. // --------------------------------------------------------------------------- test.describe('Bitable View Type Switcher E2E (U4)', () => { test.beforeAll(async () => { try { await waitForServer(undefined, 5_000) } catch { test.skip(true, 'Backend not running — skipping view type switcher E2E') } }) /** * Log in, create a bitable file + table via the UI, and wait for the * ViewSwitcher ("新建视图" button) to render. Returns once the grid header * is visible so callers can interact with the view switcher. * * Each test uses a unique file name to avoid collisions with parallel runs. */ async function openFileDetailWithTable(page: Page, label: string): Promise { await loginAndOpenBitable(page) // Create a file — opens the file detail view. await page.getByRole('button', { name: /新建文件/ }).click() await page.getByPlaceholder('请输入文件名').fill(`U4-${label}`) await page.getByRole('button', { name: /确\s*定/ }).click() await expect(page).toHaveURL(/\/bitable\/[^/]+/, { timeout: 10_000 }) // Create a table — required for the ViewSwitcher to render. await page.locator('.table-view-list__header .ant-btn').click() await expect(page.getByText('新建数据表')).toBeVisible({ timeout: 5_000 }) await page.getByPlaceholder('请输入表名').fill(`U4表-${label}`) await page.getByRole('button', { name: /确\s*定/ }).click() // Wait for the grid header (and thus the ViewSwitcher) to render. await expect(page.locator('.bitable-file-detail-view__table-name')).toContainText( `U4表-${label}`, { timeout: 10_000 }, ) } /** Open the "新建视图" dropdown and return the menu item locator list. */ async function openViewTypeDropdown(page: Page): Promise { await page.getByRole('button', { name: /新建视图/ }).click() // The dropdown overlay menu items. return page.locator('.ant-dropdown-menu-item') } test('E1: "新建视图" dropdown exposes all 5 view types', async ({ page }) => { await openFileDetailWithTable(page, 'E1-types') const items = await openViewTypeDropdown(page) await expect(items).toHaveCount(5, { timeout: 5_000 }) // Labels in spec order: 表格 / 看板 / 画廊 / 甘特 / 表单 const labels = await items.allTextContents() expect(labels.map((t) => t.trim())).toEqual(['表格', '看板', '画廊', '甘特', '表单']) }) test('E2: kanban/gallery/gantt/form are disabled with "规划中" tooltip', async ({ page }) => { await openFileDetailWithTable(page, 'E2-disabled') const items = await openViewTypeDropdown(page) // The 4 unimplemented types are disabled and carry title="规划中". for (const label of ['看板', '画廊', '甘特', '表单']) { const item = items.filter({ hasText: label }) await expect(item).toHaveClass(/ant-dropdown-menu-item-disabled/, { timeout: 5_000 }) await expect(item).toHaveAttribute('title', '规划中') } // grid is enabled and has no "规划中" title. const gridItem = items.filter({ hasText: '表格' }) await expect(gridItem).not.toHaveClass(/ant-dropdown-menu-item-disabled/) await expect(gridItem).not.toHaveAttribute('title', '规划中') }) test('E3: selecting grid sends POST /views with view_type=grid', async ({ page }) => { await openFileDetailWithTable(page, 'E3-create-grid') let capturedBody: { name?: string; view_type?: string } | null = null await page.route('**/api/v1/bitable/tables/*/views', (route) => { if (route.request().method() !== 'POST') return route.continue() capturedBody = route.request().postDataJSON() const name = capturedBody?.name ?? '测试视图' return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ success: true, view: { id: 'view-e3-mock', table_id: 'tbl-e3', name, view_type: 'grid', config: {}, created_at: new Date().toISOString(), }, }), }) }) const items = await openViewTypeDropdown(page) await items.filter({ hasText: '表格' }).click() // Name modal (AModal.confirm) appears — fill + confirm. const nameInput = page.getByPlaceholder('请输入视图名称') await expect(nameInput).toBeVisible({ timeout: 5_000 }) await nameInput.fill('网格视图E3') await page.getByRole('button', { name: /确\s*定/ }).click() // Assert the POST body carried view_type=grid (no longer hardcoded elsewhere). await expect .poll(async () => capturedBody, { timeout: 5_000 }) .toMatchObject({ name: '网格视图E3', view_type: 'grid' }) }) test('E4: clicking a disabled type does not open the name modal or fire POST', async ({ page }) => { await openFileDetailWithTable(page, 'E4-disabled-click') let postFired = false await page.route('**/api/v1/bitable/tables/*/views', (route) => { if (route.request().method() === 'POST') { postFired = true return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ success: true, view: { id: 'x', table_id: 'x', name: 'x', view_type: 'kanban', config: {}, created_at: '' } }), }) } return route.continue() }) const items = await openViewTypeDropdown(page) // Click the disabled "看板" item — antd will not emit a click event for it. await items.filter({ hasText: '看板' }).click({ force: true }) // The name modal must NOT appear, and no POST /views must have fired. await expect(page.getByPlaceholder('请输入视图名称')).not.toBeVisible({ timeout: 1_500 }) expect(postFired).toBe(false) }) test('E5: created grid view is added to the view tab list (round-trip contract)', async ({ page }) => { await openFileDetailWithTable(page, 'E5-roundtrip') await page.route('**/api/v1/bitable/tables/*/views', (route) => { if (route.request().method() !== 'POST') return route.continue() const body = route.request().postDataJSON() ?? {} return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ success: true, view: { id: 'view-e5-mock', table_id: 'tbl-e5', name: body.name ?? 'E5视图', view_type: body.view_type ?? 'grid', config: {}, created_at: new Date().toISOString(), }, }), }) }) const items = await openViewTypeDropdown(page) await items.filter({ hasText: '表格' }).click() const nameInput = page.getByPlaceholder('请输入视图名称') await expect(nameInput).toBeVisible({ timeout: 5_000 }) await nameInput.fill('E5网格视图') await page.getByRole('button', { name: /确\s*定/ }).click() // The mock response is pushed into the store; a new tab with the view // name appears in the ViewSwitcher tabs. await expect(page.locator('.ant-tabs-tab', { hasText: 'E5网格视图' })).toBeVisible({ timeout: 5_000, }) }) })