fischer-agentkit/src/agentkit/server/frontend/e2e/bitable-view.spec.ts

264 lines
10 KiB
TypeScript

/**
* 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<void> {
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<void> {
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<import('@playwright/test').Locator> {
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,
})
})
})