264 lines
10 KiB
TypeScript
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,
|
|
})
|
|
})
|
|
})
|