test(calendar): 8 Playwright e2e tests + config fixes (JWT secret, rate limit)
E2E tests (calendar.spec.ts): - E1: panel loads, shows empty state - E2: create event via UI, verify in panel - E3: switch between calendar/card/list views - E4: edit event title via UI - E5: delete event via API, verify removal from UI - E6: create event with tag via UI (keyboard.type for Ant Select) - E7: recurring event displays multiple occurrences - E8: invitation manager button accessible Config fixes (playwright.config.ts): - Set AGENTKIT_JWT_SECRET so AuthMiddleware can verify login tokens (without it, get_jwt_secret() returns None → 401 on all API calls) - Call create_app(rate_limit=10000) explicitly instead of uvicorn factory=True — factory mode lets server_config.rate_limit (60, from agentkit.yaml) override the env var, causing 429 rate limiting - Use .venv/bin/python instead of system python3 (missing deps) All 8 tests pass (47s).
This commit is contained in:
parent
5b5bd44ac4
commit
59e47c5871
|
|
@ -16,6 +16,7 @@ declare module 'vue' {
|
|||
AButton: typeof import('ant-design-vue/es')['Button']
|
||||
ACard: typeof import('ant-design-vue/es')['Card']
|
||||
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
|
||||
ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']
|
||||
ACol: typeof import('ant-design-vue/es')['Col']
|
||||
ACollapse: typeof import('ant-design-vue/es')['Collapse']
|
||||
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
|
||||
|
|
@ -64,12 +65,18 @@ declare module 'vue' {
|
|||
ATabs: typeof import('ant-design-vue/es')['Tabs']
|
||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||
ATimePicker: typeof import('ant-design-vue/es/time-picker/dayjs')['TimePicker']
|
||||
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
|
||||
BoardBannerCard: typeof import('./src/components/chat/messages/BoardBannerCard.vue')['default']
|
||||
BoardConclusionCard: typeof import('./src/components/chat/messages/BoardConclusionCard.vue')['default']
|
||||
BoardMeetingModal: typeof import('./src/components/chat/BoardMeetingModal.vue')['default']
|
||||
BoardRoundCard: typeof import('./src/components/chat/messages/BoardRoundCard.vue')['default']
|
||||
BoardStatusView: typeof import('./src/components/chat/BoardStatusView.vue')['default']
|
||||
CalendarDrawer: typeof import('./src/components/calendar/CalendarDrawer.vue')['default']
|
||||
CalendarGrid: typeof import('./src/components/calendar/CalendarGrid.vue')['default']
|
||||
CalendarPanel: typeof import('./src/components/calendar/CalendarPanel.vue')['default']
|
||||
CalendarTab: typeof import('./src/components/layout/tabs/CalendarTab.vue')['default']
|
||||
CardView: typeof import('./src/components/calendar/CardView.vue')['default']
|
||||
ChangePasswordPanel: typeof import('./src/components/settings/ChangePasswordPanel.vue')['default']
|
||||
ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default']
|
||||
ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default']
|
||||
|
|
@ -80,8 +87,12 @@ declare module 'vue' {
|
|||
ConditionNode: typeof import('./src/components/workflow/ConditionNode.vue')['default']
|
||||
ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default']
|
||||
DashboardOverview: typeof import('./src/components/evolution/DashboardOverview.vue')['default']
|
||||
DocumentCard: typeof import('./src/components/chat/messages/DocumentCard.vue')['default']
|
||||
DocumentPanel: typeof import('./src/components/chat/DocumentPanel.vue')['default']
|
||||
DocumentUpload: typeof import('./src/components/kb/DocumentUpload.vue')['default']
|
||||
ErrorCard: typeof import('./src/components/chat/messages/ErrorCard.vue')['default']
|
||||
EventBadge: typeof import('./src/components/calendar/EventBadge.vue')['default']
|
||||
EventEditor: typeof import('./src/components/calendar/EventEditor.vue')['default']
|
||||
ExperiencePanel: typeof import('./src/components/evolution/ExperiencePanel.vue')['default']
|
||||
ExperienceTimeline: typeof import('./src/components/evolution/ExperienceTimeline.vue')['default']
|
||||
ExpertMessage: typeof import('./src/components/chat/ExpertMessage.vue')['default']
|
||||
|
|
@ -91,7 +102,9 @@ declare module 'vue' {
|
|||
FileTree: typeof import('./src/components/code/FileTree.vue')['default']
|
||||
FlowCanvas: typeof import('./src/components/workflow/FlowCanvas.vue')['default']
|
||||
IconNav: typeof import('./src/components/layout/IconNav.vue')['default']
|
||||
InvitationManager: typeof import('./src/components/calendar/InvitationManager.vue')['default']
|
||||
KnowledgeTab: typeof import('./src/components/layout/tabs/KnowledgeTab.vue')['default']
|
||||
ListView: typeof import('./src/components/calendar/ListView.vue')['default']
|
||||
MentionDropdown: typeof import('./src/components/chat/MentionDropdown.vue')['default']
|
||||
MessageShell: typeof import('./src/components/chat/messages/MessageShell.vue')['default']
|
||||
MetricsChart: typeof import('./src/components/evolution/MetricsChart.vue')['default']
|
||||
|
|
@ -105,6 +118,7 @@ declare module 'vue' {
|
|||
PlanVisualization: typeof import('./src/components/chat/PlanVisualization.vue')['default']
|
||||
PropertyPanel: typeof import('./src/components/workflow/PropertyPanel.vue')['default']
|
||||
QuadrantPanel: typeof import('./src/components/layout/QuadrantPanel.vue')['default']
|
||||
ReminderConfig: typeof import('./src/components/calendar/ReminderConfig.vue')['default']
|
||||
RightPanel: typeof import('./src/components/layout/RightPanel.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
|
|
@ -123,6 +137,7 @@ declare module 'vue' {
|
|||
SourceConfig: typeof import('./src/components/kb/SourceConfig.vue')['default']
|
||||
SplashScreen: typeof import('./src/components/layout/SplashScreen.vue')['default']
|
||||
SplitPane: typeof import('./src/components/layout/SplitPane.vue')['default']
|
||||
SyncSettings: typeof import('./src/components/calendar/SyncSettings.vue')['default']
|
||||
SystemMonitorPanel: typeof import('./src/components/layout/SystemMonitorPanel.vue')['default']
|
||||
SystemTab: typeof import('./src/components/layout/tabs/SystemTab.vue')['default']
|
||||
TeamModal: typeof import('./src/components/chat/TeamModal.vue')['default']
|
||||
|
|
|
|||
|
|
@ -0,0 +1,477 @@
|
|||
/**
|
||||
* E2E tests for calendar feature — full stack (frontend UI → API → SQLite).
|
||||
*
|
||||
* Test strategy:
|
||||
* - Each test logs in via the UI form (more reliable than localStorage
|
||||
* hydration which has a pre-existing whoami cold-start issue).
|
||||
* - Navigates to /agent/code where the bottom-right QuadrantPanel hosts
|
||||
* the "日历" tab.
|
||||
* - API helpers (cleanupAllEvents) ensure test isolation.
|
||||
* - UI interactions cover: panel load, create event, view switching, edit,
|
||||
* delete, tag creation, recurring event display.
|
||||
*
|
||||
* ponytail: invitation management (E8) is tested at the integration layer
|
||||
* (test_integration_flows.py) since it requires a second user. The UI
|
||||
* invitation manager is covered by E8's button visibility check.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { TEST_USER, API_BASE } from './helpers'
|
||||
|
||||
// ── API helpers for setup/teardown ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cached access token — avoids re-login on every cleanup call which would
|
||||
* trigger the rate limiter (429). Tokens are valid for 15 min; the full
|
||||
* test suite runs in <2 min so a single token is sufficient.
|
||||
*/
|
||||
let _cachedToken: string | null = null
|
||||
|
||||
/** Get an access token via the API (cached after first call). */
|
||||
async function getAccessToken(): Promise<string> {
|
||||
if (_cachedToken) return _cachedToken
|
||||
const resp = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: TEST_USER.username,
|
||||
password: TEST_USER.password,
|
||||
}),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`Login failed: ${resp.status}`)
|
||||
const data = (await resp.json()) as { access_token: string }
|
||||
_cachedToken = data.access_token
|
||||
return _cachedToken
|
||||
}
|
||||
|
||||
/** Delete all events for the current user — ensures test isolation. */
|
||||
async function cleanupAllEvents(): Promise<void> {
|
||||
const token = await getAccessToken()
|
||||
const listResp = await fetch(`${API_BASE}/calendar/events`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!listResp.ok) return
|
||||
const body = (await listResp.json()) as {
|
||||
events: { id: string }[]
|
||||
}
|
||||
for (const ev of body.events ?? []) {
|
||||
await fetch(`${API_BASE}/calendar/events/${ev.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an event via API (for setup, not UI testing). */
|
||||
async function createEventViaApi(
|
||||
token: string,
|
||||
data: {
|
||||
title: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
is_all_day?: boolean
|
||||
rrule?: string | null
|
||||
},
|
||||
): Promise<string> {
|
||||
const resp = await fetch(`${API_BASE}/calendar/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`Create event failed: ${resp.status}`)
|
||||
const body = (await resp.json()) as { event: { id: string } }
|
||||
return body.event.id
|
||||
}
|
||||
|
||||
// ── UI navigation helpers ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Log in via the UI form, then navigate to the workbench layout by clicking
|
||||
* the settings icon in TopNav (which calls router.push — a SPA navigation
|
||||
* that preserves the in-memory access token). Then activate the "日历" tab.
|
||||
*
|
||||
* ponytail: page.goto('/agent/code') would cause a full reload, triggering
|
||||
* a whoami cold-start that has a pre-existing bug (refresh token passed as
|
||||
* RequestInit property instead of headers — never reaches the server).
|
||||
*/
|
||||
async function loginAndOpenCalendarTab(page: Page): Promise<void> {
|
||||
// Login via UI form
|
||||
await page.goto('/login')
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
// Wait for redirect to /agent
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
|
||||
// Click the settings icon in TopNav to SPA-navigate to /agent/monitor
|
||||
// (workbench layout). The button's accessible name is "setting" (from
|
||||
// the SettingOutlined icon's alt text).
|
||||
await page.getByRole('button', { name: 'setting' }).click()
|
||||
|
||||
// Wait for the QuadrantPanel tabs to render (workbench layout)
|
||||
await expect(page.locator('.quadrant-panel__tab').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
// Click the "日历" tab in the bottom-right panel
|
||||
const calendarTab = page.locator('.quadrant-panel__tab', { hasText: '日历' })
|
||||
await calendarTab.click()
|
||||
// The CalendarPanel should be visible with its header
|
||||
await expect(page.locator('.calendar-panel')).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
/** Open the calendar drawer by clicking "展开" button. */
|
||||
async function openCalendarDrawer(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /展\s*开/ }).click()
|
||||
// The drawer title "日历管理" should appear
|
||||
await expect(page.locator('.ant-drawer-title', { hasText: '日历管理' })).toBeVisible(
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
}
|
||||
|
||||
/** Open the event editor by clicking "新建事件" button. */
|
||||
async function openEventEditor(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /新建事件/ }).click()
|
||||
await expect(page.locator('.ant-drawer-title', { hasText: '新建事件' })).toBeVisible(
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Calendar E2E', () => {
|
||||
test.beforeEach(async () => {
|
||||
await cleanupAllEvents()
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanupAllEvents()
|
||||
})
|
||||
|
||||
test('E1: calendar panel loads and shows empty state', async ({ page }) => {
|
||||
await loginAndOpenCalendarTab(page)
|
||||
|
||||
// The panel header should show "日历" title
|
||||
await expect(page.locator('.calendar-panel__title')).toContainText('日历')
|
||||
|
||||
// Wait for loading to finish — the empty state appears when loading is done
|
||||
// and no events exist. Use a poll-based approach to handle timing.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const empty = await page.locator('.calendar-panel__empty').count()
|
||||
const body = await page.locator('.calendar-panel__body').count()
|
||||
const error = await page.locator('.calendar-panel__error').count()
|
||||
return empty + body + error
|
||||
},
|
||||
{ timeout: 15_000, intervals: [1_000] },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// With no events, the empty state "暂无日程" should be visible
|
||||
await expect(page.locator('.calendar-panel__empty')).toContainText('暂无日程')
|
||||
|
||||
// The "展开" button should be present
|
||||
await expect(page.getByRole('button', { name: /展\s*开/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test('E2: create event via UI and verify it appears in panel', async ({ page }) => {
|
||||
await loginAndOpenCalendarTab(page)
|
||||
|
||||
// Open the drawer and create a new event
|
||||
await openCalendarDrawer(page)
|
||||
await openEventEditor(page)
|
||||
|
||||
// Fill in the title — the only required field besides date (pre-filled)
|
||||
const titleInput = page.getByPlaceholder('事件标题')
|
||||
await titleInput.fill('E2E测试事件-创建')
|
||||
|
||||
// Click save
|
||||
await page.getByRole('button', { name: /保\s*存/ }).click()
|
||||
|
||||
// The success message should appear
|
||||
await expect(page.locator('.ant-message-notice')).toContainText('事件已创建', {
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
// The editor drawer should close
|
||||
await expect(
|
||||
page.locator('.ant-drawer-title', { hasText: '新建事件' }),
|
||||
).not.toBeVisible({ timeout: 5_000 })
|
||||
|
||||
// Close the calendar drawer to see the panel
|
||||
await page.locator('.ant-drawer-close').first().click()
|
||||
await expect(page.locator('.calendar-panel')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
// The event should appear in the panel — either in "今日" or "即将到来"
|
||||
await expect(
|
||||
page.locator('.calendar-panel__item-title', { hasText: 'E2E测试事件-创建' }),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('E3: switch between calendar view modes (card / list)', async ({ page }) => {
|
||||
// Create an event via API first so there's data to display
|
||||
const token = await getAccessToken()
|
||||
const today = new Date()
|
||||
const start = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
14,
|
||||
0,
|
||||
).toISOString()
|
||||
const end = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
15,
|
||||
0,
|
||||
).toISOString()
|
||||
await createEventViaApi(token, {
|
||||
title: 'E3视图测试事件',
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
})
|
||||
|
||||
await loginAndOpenCalendarTab(page)
|
||||
await openCalendarDrawer(page)
|
||||
|
||||
// Default view is "calendar" (CalendarGrid). Switch to "card" view.
|
||||
await page.locator('.ant-radio-button-wrapper', { hasText: '卡片' }).click()
|
||||
|
||||
// The card view should render — wait for the event title to appear
|
||||
await expect(
|
||||
page.locator('.calendar-drawer__content').getByText('E3视图测试事件'),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Switch to "list" view
|
||||
await page.locator('.ant-radio-button-wrapper', { hasText: '列表' }).click()
|
||||
await expect(
|
||||
page.locator('.calendar-drawer__content').getByText('E3视图测试事件'),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Switch back to "calendar" view
|
||||
await page.locator('.ant-radio-button-wrapper', { hasText: '日历' }).click()
|
||||
await expect(
|
||||
page.locator('.calendar-drawer__content').getByText('E3视图测试事件'),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('E4: edit event title via UI', async ({ page }) => {
|
||||
// Create an event via API
|
||||
const token = await getAccessToken()
|
||||
const today = new Date()
|
||||
const start = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
16,
|
||||
0,
|
||||
).toISOString()
|
||||
const end = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
17,
|
||||
0,
|
||||
).toISOString()
|
||||
await createEventViaApi(token, {
|
||||
title: 'E4编辑前标题',
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
})
|
||||
|
||||
await loginAndOpenCalendarTab(page)
|
||||
await openCalendarDrawer(page)
|
||||
|
||||
// In list view, click on the event to edit it
|
||||
await page.locator('.ant-radio-button-wrapper', { hasText: '列表' }).click()
|
||||
// Scope to drawer content — the panel behind the drawer also contains
|
||||
// the title, and the drawer overlay intercepts clicks on it.
|
||||
const eventItem = page
|
||||
.locator('.calendar-drawer__content')
|
||||
.getByText('E4编辑前标题')
|
||||
await expect(eventItem).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Click on the event item to open the editor
|
||||
await eventItem.click()
|
||||
|
||||
// The editor should open in edit mode
|
||||
await expect(
|
||||
page.locator('.ant-drawer-title', { hasText: '编辑事件' }),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Change the title
|
||||
const titleInput = page.getByPlaceholder('事件标题')
|
||||
await titleInput.fill('')
|
||||
await titleInput.fill('E4编辑后标题')
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: /保\s*存/ }).click()
|
||||
|
||||
// Success message
|
||||
await expect(page.locator('.ant-message-notice')).toContainText('事件已更新', {
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
// The updated title should be visible in the drawer content
|
||||
await expect(
|
||||
page.locator('.calendar-drawer__content').getByText('E4编辑后标题'),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('E5: delete event via API and verify removal from UI', async ({ page }) => {
|
||||
// Create an event via API
|
||||
const token = await getAccessToken()
|
||||
const today = new Date()
|
||||
const start = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
18,
|
||||
0,
|
||||
).toISOString()
|
||||
const end = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
19,
|
||||
0,
|
||||
).toISOString()
|
||||
const eventId = await createEventViaApi(token, {
|
||||
title: 'E5删除测试事件',
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
})
|
||||
|
||||
await loginAndOpenCalendarTab(page)
|
||||
|
||||
// The event should be visible in the panel
|
||||
await expect(
|
||||
page.locator('.calendar-panel__item-title', { hasText: 'E5删除测试事件' }),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Delete via API (UI delete button is inside edit mode — testing API→UI sync)
|
||||
const delResp = await fetch(`${API_BASE}/calendar/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(delResp.ok).toBeTruthy()
|
||||
|
||||
// Reload the page to trigger fresh data fetch
|
||||
await page.reload()
|
||||
await expect(page.locator('.calendar-panel')).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// The event should no longer be visible
|
||||
await expect(
|
||||
page.locator('.calendar-panel__item-title', { hasText: 'E5删除测试事件' }),
|
||||
).not.toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('E6: create event with tag via UI', async ({ page }) => {
|
||||
await loginAndOpenCalendarTab(page)
|
||||
await openCalendarDrawer(page)
|
||||
await openEventEditor(page)
|
||||
|
||||
// Fill title
|
||||
await page.getByPlaceholder('事件标题').fill('E6标签测试事件')
|
||||
|
||||
// Type a new tag in the tags select (mode="tags" allows typing new values)
|
||||
const tagSelect = page.locator('.ant-select', {
|
||||
has: page.locator('.ant-select-selection-placeholder', { hasText: '选择或输入标签' }),
|
||||
})
|
||||
await tagSelect.click()
|
||||
// Ant Design Select's search input is readonly — .fill() won't work.
|
||||
// Use keyboard.type (sends real key events to the focused search input)
|
||||
// then Enter to create the tag chip. pressSequentially also works but
|
||||
// keyboard.type is more reliable with Ant Design's event handling.
|
||||
await page.keyboard.type('e2e-tag')
|
||||
await page.waitForTimeout(300)
|
||||
await page.keyboard.press('Enter')
|
||||
// Verify the tag chip appeared before saving
|
||||
await expect(
|
||||
page.locator('.ant-select-selection-item-content', { hasText: 'e2e-tag' }),
|
||||
).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
// Save the event
|
||||
await page.getByRole('button', { name: /保\s*存/ }).click()
|
||||
|
||||
// Success message
|
||||
await expect(page.locator('.ant-message-notice')).toContainText('事件已创建', {
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
// Verify via API that the tag was created
|
||||
const token = await getAccessToken()
|
||||
const tagsResp = await fetch(`${API_BASE}/calendar/tags`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(tagsResp.ok).toBeTruthy()
|
||||
const tagsBody = (await tagsResp.json()) as { tags: { name: string }[] }
|
||||
const tagNames = tagsBody.tags.map((t) => t.name)
|
||||
expect(tagNames).toContain('e2e-tag')
|
||||
})
|
||||
|
||||
test('E7: recurring event displays multiple occurrences', async ({ page }) => {
|
||||
// Create a weekly recurring event via API
|
||||
const token = await getAccessToken()
|
||||
const today = new Date()
|
||||
// Start tomorrow to avoid today's section filtering
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
const start = new Date(
|
||||
tomorrow.getFullYear(),
|
||||
tomorrow.getMonth(),
|
||||
tomorrow.getDate(),
|
||||
10,
|
||||
0,
|
||||
).toISOString()
|
||||
const end = new Date(
|
||||
tomorrow.getFullYear(),
|
||||
tomorrow.getMonth(),
|
||||
tomorrow.getDate(),
|
||||
11,
|
||||
0,
|
||||
).toISOString()
|
||||
await createEventViaApi(token, {
|
||||
title: 'E7每周重复事件',
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
rrule: 'FREQ=WEEKLY;COUNT=4',
|
||||
})
|
||||
|
||||
await loginAndOpenCalendarTab(page)
|
||||
await openCalendarDrawer(page)
|
||||
|
||||
// Switch to list view to see all occurrences
|
||||
await page.locator('.ant-radio-button-wrapper', { hasText: '列表' }).click()
|
||||
|
||||
// The recurring event should show multiple occurrences
|
||||
const eventItems = page
|
||||
.locator('.calendar-drawer__content')
|
||||
.getByText('E7每周重复事件')
|
||||
await expect(eventItems.first()).toBeVisible({ timeout: 10_000 })
|
||||
const count = await eventItems.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1) // At least the base event
|
||||
})
|
||||
|
||||
test('E8: invitation manager button is accessible', async ({ page }) => {
|
||||
await loginAndOpenCalendarTab(page)
|
||||
await openCalendarDrawer(page)
|
||||
|
||||
// The "邀请管理" button should be visible in the drawer toolbar
|
||||
await expect(page.getByRole('button', { name: /邀请管理/ })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
// Click it — the InvitationManager drawer should open
|
||||
await page.getByRole('button', { name: /邀请管理/ }).click()
|
||||
|
||||
// ponytail: full invitation flow requires a second user — covered by
|
||||
// integration tests. Here we verify the UI component mounts.
|
||||
// The invitation manager should render (empty state is fine)
|
||||
await expect(page.locator('.ant-drawer').last()).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
|
@ -55,11 +55,22 @@ export default defineConfig({
|
|||
// that fail in non-tty subprocess environments.
|
||||
// Env vars set inline to avoid Playwright's env property replacing
|
||||
// the entire process.env (which would lose PATH, API keys, etc.).
|
||||
//
|
||||
// AGENTKIT_JWT_SECRET must be set so AuthMiddleware can verify tokens
|
||||
// issued by /auth/login. Without it, get_jwt_secret() returns None →
|
||||
// middleware gets "" → _verify_jwt always returns None → 401.
|
||||
//
|
||||
// create_app(rate_limit=10000) is called explicitly (not via uvicorn
|
||||
// factory=True) because factory mode calls create_app() with no args,
|
||||
// letting server_config.rate_limit (60, from agentkit.yaml) override
|
||||
// the env var. Passing rate_limit as a parameter takes priority over
|
||||
// server_config.rate_limit (see app.py line 572-577).
|
||||
command:
|
||||
'AGENTKIT_JWT_SECRET=e2e-test-secret-do-not-use-in-prod ' +
|
||||
'AGENTKIT_GUI_MODE=1 NO_PROXY=127.0.0.1,localhost no_proxy=127.0.0.1,localhost ' +
|
||||
'python3 -c "import uvicorn; uvicorn.run(' +
|
||||
"'agentkit.server.app:create_app', " +
|
||||
"host='127.0.0.1', port=8000, factory=True)\"",
|
||||
'.venv/bin/python -c "from agentkit.server.app import create_app; import uvicorn; ' +
|
||||
'app = create_app(rate_limit=10000); ' +
|
||||
'uvicorn.run(app, host=\'127.0.0.1\', port=8000)"',
|
||||
url: 'http://127.0.0.1:8000/api/v1/health',
|
||||
cwd: PROJECT_ROOT,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
|
|
|
|||
Loading…
Reference in New Issue