From 803464b492c6e8ef84264a454d331ecf4692e44b Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 7 Apr 2026 16:06:47 +0800 Subject: [PATCH] =?UTF-8?q?test(admin-v2):=20Phase=202=20frontend=20tests?= =?UTF-8?q?=20=E2=80=94=2061=20tests=20for=205=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Billing (13 tests): plan cards, prices, limits, usage bars, payment flow - ScheduledTasks (16 tests): CRUD table, schedule/target types, color tags - Knowledge (12 tests): 4 tabs, items/categories/search/analytics panels - Roles (12 tests): roles + permission templates tabs - ConfigSync (8 tests): sync log viewer with action labels Fix: Knowledge.tsx missing and closing tags (JSX parse error) Fix: tests/setup.ts added ResizeObserver mock for ProTable compatibility --- admin-v2/src/pages/Knowledge.tsx | 2 + admin-v2/tests/pages/Billing.test.tsx | 244 +++++++++++++++ admin-v2/tests/pages/ConfigSync.test.tsx | 153 ++++++++++ admin-v2/tests/pages/Knowledge.test.tsx | 299 +++++++++++++++++++ admin-v2/tests/pages/Roles.test.tsx | 227 ++++++++++++++ admin-v2/tests/pages/ScheduledTasks.test.tsx | 268 +++++++++++++++++ admin-v2/tests/setup.ts | 7 + 7 files changed, 1200 insertions(+) create mode 100644 admin-v2/tests/pages/Billing.test.tsx create mode 100644 admin-v2/tests/pages/ConfigSync.test.tsx create mode 100644 admin-v2/tests/pages/Knowledge.test.tsx create mode 100644 admin-v2/tests/pages/Roles.test.tsx create mode 100644 admin-v2/tests/pages/ScheduledTasks.test.tsx diff --git a/admin-v2/src/pages/Knowledge.tsx b/admin-v2/src/pages/Knowledge.tsx index 6a95a06..07a8496 100644 --- a/admin-v2/src/pages/Knowledge.tsx +++ b/admin-v2/src/pages/Knowledge.tsx @@ -183,6 +183,7 @@ function CategoriesPanel() { .map((c) => ( {c.name} ))} + @@ -454,6 +455,7 @@ function ItemsPanel() { }, ]} /> + ) } diff --git a/admin-v2/tests/pages/Billing.test.tsx b/admin-v2/tests/pages/Billing.test.tsx new file mode 100644 index 0000000..e86268c --- /dev/null +++ b/admin-v2/tests/pages/Billing.test.tsx @@ -0,0 +1,244 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +import Billing from '@/pages/Billing' + +// โ”€โ”€ Mock data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const mockPlans = [ + { + id: 'plan-free', name: 'free', display_name: 'ๅ…่ดน็‰ˆ', + description: 'ๅŸบ็ก€ๅŠŸ่ƒฝ', price_cents: 0, currency: 'CNY', + interval: 'month', + features: {}, limits: { max_relay_requests_monthly: 100, max_hand_executions_monthly: 10, max_pipeline_runs_monthly: 5 }, + is_default: true, sort_order: 0, status: 'active', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + }, + { + id: 'plan-pro', name: 'pro', display_name: 'ไธ“ไธš็‰ˆ', + description: '้ซ˜็บงๅŠŸ่ƒฝ', price_cents: 9900, currency: 'CNY', + interval: 'month', + features: {}, limits: { max_relay_requests_monthly: 1000, max_hand_executions_monthly: 100, max_pipeline_runs_monthly: 50 }, + is_default: false, sort_order: 1, status: 'active', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + }, + { + id: 'plan-team', name: 'team', display_name: 'ๅ›ข้˜Ÿ็‰ˆ', + description: 'ๅ›ข้˜Ÿๅไฝœ', price_cents: 29900, currency: 'CNY', + interval: 'month', + features: {}, limits: { max_relay_requests_monthly: 10000, max_hand_executions_monthly: 500, max_pipeline_runs_monthly: 200 }, + is_default: false, sort_order: 2, status: 'active', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + }, +] + +const mockSubscription = { + plan: mockPlans[0], + subscription: null, + usage: { + id: 'usage-001', account_id: 'acc-001', + period_start: '2026-04-01T00:00:00Z', period_end: '2026-04-30T23:59:59Z', + input_tokens: 5000, output_tokens: 12000, + relay_requests: 42, hand_executions: 3, pipeline_runs: 1, + max_input_tokens: null, max_output_tokens: null, + max_relay_requests: 100, max_hand_executions: 10, max_pipeline_runs: 5, + created_at: '2026-04-01T00:00:00Z', updated_at: '2026-04-07T12:00:00Z', + }, +} + +const server = setupServer() + +beforeEach(() => { + server.listen({ onUnhandledRequest: 'bypass' }) +}) + +afterEach(() => { + server.close() +}) + +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return render( + + {ui} + , + ) +} + +function setupBillingHandlers(overrides: Record = {}) { + server.use( + http.get('*/api/v1/billing/plans', () => { + return HttpResponse.json(overrides.plans ?? mockPlans) + }), + http.get('*/api/v1/billing/subscription', () => { + return HttpResponse.json(overrides.subscription ?? mockSubscription) + }), + ) +} + +describe('Billing', () => { + it('renders page title', () => { + setupBillingHandlers() + renderWithProviders() + expect(screen.getByText('่ฎก่ดน็ฎก็†')).toBeInTheDocument() + }) + + it('shows loading state', async () => { + server.use( + http.get('*/api/v1/billing/plans', async () => { + await new Promise(resolve => setTimeout(resolve, 500)) + return HttpResponse.json(mockPlans) + }), + http.get('*/api/v1/billing/subscription', async () => { + await new Promise(resolve => setTimeout(resolve, 500)) + return HttpResponse.json(mockSubscription) + }), + ) + renderWithProviders() + expect(document.querySelector('.ant-spin')).toBeTruthy() + }) + + it('displays all three plan cards', async () => { + setupBillingHandlers() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('ๅ…่ดน็‰ˆ')).toBeInTheDocument() + }) + expect(screen.getByText('ไธ“ไธš็‰ˆ')).toBeInTheDocument() + expect(screen.getByText('ๅ›ข้˜Ÿ็‰ˆ')).toBeInTheDocument() + }) + + it('displays plan prices', async () => { + setupBillingHandlers() + renderWithProviders() + + await waitFor(() => { + // Free plan: ยฅ0 + expect(screen.getByText('ยฅ0')).toBeInTheDocument() + }) + // Pro plan: ยฅ99, Team plan: ยฅ299 + expect(screen.getByText('ยฅ99')).toBeInTheDocument() + expect(screen.getByText('ยฅ299')).toBeInTheDocument() + }) + + it('displays per-month interval', async () => { + setupBillingHandlers() + renderWithProviders() + + await waitFor(() => { + // All plans are monthly, so "/ๆœˆ" should appear multiple times + const monthLabels = screen.getAllByText('/ๆœˆ') + expect(monthLabels.length).toBeGreaterThanOrEqual(3) + }) + }) + + it('displays plan limits', async () => { + setupBillingHandlers() + renderWithProviders() + + await waitFor(() => { + // Free plan limits + expect(screen.getByText('ไธญ่ฝฌ่ฏทๆฑ‚: 100 ๆฌก/ๆœˆ')).toBeInTheDocument() + }) + expect(screen.getByText('Hand ๆ‰ง่กŒ: 10 ๆฌก/ๆœˆ')).toBeInTheDocument() + expect(screen.getByText('Pipeline ่ฟ่กŒ: 5 ๆฌก/ๆœˆ')).toBeInTheDocument() + }) + + it('shows ๅฝ“ๅ‰่ฎกๅˆ’ badge on free plan', async () => { + setupBillingHandlers() + renderWithProviders() + + await waitFor(() => { + // "ๅฝ“ๅ‰่ฎกๅˆ’" appears on the badge AND the disabled button for free plan + const allCurrentPlan = screen.getAllByText('ๅฝ“ๅ‰่ฎกๅˆ’') + expect(allCurrentPlan.length).toBeGreaterThanOrEqual(1) + }) + }) + + it('renders pro and team plan cards with buttons', async () => { + setupBillingHandlers() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('ไธ“ไธš็‰ˆ')).toBeInTheDocument() + }) + // Non-current plans should have clickable buttons (not disabled "ๅฝ“ๅ‰่ฎกๅˆ’") + expect(screen.getByText('ๅ›ข้˜Ÿ็‰ˆ')).toBeInTheDocument() + // Free plan is current โ†’ its button shows "ๅฝ“ๅ‰่ฎกๅˆ’" and is disabled + const allButtons = screen.getAllByRole('button') + const disabledButtons = allButtons.filter(btn => btn.hasAttribute('disabled')) + expect(disabledButtons.length).toBeGreaterThanOrEqual(1) // at least free plan button + }) + + it('shows ๅฝ“ๅ‰็”จ้‡ section', async () => { + setupBillingHandlers() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('ๅฝ“ๅ‰็”จ้‡')).toBeInTheDocument() + }) + }) + + it('displays usage bars with correct values', async () => { + setupBillingHandlers() + renderWithProviders() + + await waitFor(() => { + // relay_requests: 42 / 100 + expect(screen.getByText('ไธญ่ฝฌ่ฏทๆฑ‚')).toBeInTheDocument() + }) + expect(screen.getByText('Hand ๆ‰ง่กŒ')).toBeInTheDocument() + expect(screen.getByText('Pipeline ่ฟ่กŒ')).toBeInTheDocument() + }) + + it('shows error state on plans API failure', async () => { + server.use( + http.get('*/api/v1/billing/plans', () => { + return HttpResponse.json( + { error: 'internal_error', message: 'ๆ•ฐๆฎๅบ“้”™่ฏฏ' }, + { status: 500 }, + ) + }), + http.get('*/api/v1/billing/subscription', () => { + return HttpResponse.json(mockSubscription) + }), + ) + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('ๅŠ ่ฝฝๅคฑ่ดฅ')).toBeInTheDocument() + }) + }) + + it('renders without subscription data', async () => { + setupBillingHandlers({ + subscription: { + plan: null, + subscription: null, + usage: null, + }, + }) + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('ๅ…่ดน็‰ˆ')).toBeInTheDocument() + }) + // No usage section when usage is null + expect(screen.queryByText('ๅฝ“ๅ‰็”จ้‡')).not.toBeInTheDocument() + }) + + it('shows ้€‰ๆ‹ฉ่ฎกๅˆ’ heading', async () => { + setupBillingHandlers() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('้€‰ๆ‹ฉ่ฎกๅˆ’')).toBeInTheDocument() + }) + }) +}) diff --git a/admin-v2/tests/pages/ConfigSync.test.tsx b/admin-v2/tests/pages/ConfigSync.test.tsx new file mode 100644 index 0000000..5617791 --- /dev/null +++ b/admin-v2/tests/pages/ConfigSync.test.tsx @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +import ConfigSync from '@/pages/ConfigSync' + +const mockSyncLogs = { + items: [ + { + id: 1, + account_id: 'acc-001', + client_fingerprint: 'fp-abc123def456', + action: 'push', + config_keys: 'model_config,prompt_config', + client_values: '{"model":"gpt-4"}', + saas_values: '{"model":"gpt-3.5"}', + resolution: 'client_wins', + created_at: '2026-04-07T10:30:00Z', + }, + { + id: 2, + account_id: 'acc-002', + client_fingerprint: 'fp-xyz789', + action: 'pull', + config_keys: 'privacy_settings', + client_values: null, + saas_values: '{"analytics":true}', + resolution: null, + created_at: '2026-04-06T08:00:00Z', + }, + ], + total: 2, + page: 1, + page_size: 20, +} + +const server = setupServer() + +beforeEach(() => { + server.listen({ onUnhandledRequest: 'bypass' }) +}) + +afterEach(() => { + server.close() +}) + +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return render( + + {ui} + , + ) +} + +describe('ConfigSync', () => { + it('renders page title', () => { + server.use( + http.get('*/api/v1/config/sync-logs', () => { + return HttpResponse.json({ items: [], total: 0, page: 1, page_size: 20 }) + }), + ) + renderWithProviders() + expect(screen.getByText('้…็ฝฎๅŒๆญฅๆ—ฅๅฟ—')).toBeInTheDocument() + }) + + it('shows loading state', async () => { + server.use( + http.get('*/api/v1/config/sync-logs', async () => { + await new Promise(resolve => setTimeout(resolve, 500)) + return HttpResponse.json(mockSyncLogs) + }), + ) + renderWithProviders() + expect(document.querySelector('.ant-spin')).toBeTruthy() + }) + + it('displays sync logs with Chinese action labels', async () => { + server.use( + http.get('*/api/v1/config/sync-logs', () => { + return HttpResponse.json(mockSyncLogs) + }), + ) + renderWithProviders() + + // Action labels are mapped to Chinese: push โ†’ ๆŽจ้€, pull โ†’ ๆ‹‰ๅ– + await waitFor(() => { + expect(screen.getByText('ๆŽจ้€')).toBeInTheDocument() + }) + expect(screen.getByText('ๆ‹‰ๅ–')).toBeInTheDocument() + }) + + it('displays config keys for each log', async () => { + server.use( + http.get('*/api/v1/config/sync-logs', () => { + return HttpResponse.json(mockSyncLogs) + }), + ) + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('model_config,prompt_config')).toBeInTheDocument() + }) + }) + + it('displays resolution column', async () => { + server.use( + http.get('*/api/v1/config/sync-logs', () => { + return HttpResponse.json(mockSyncLogs) + }), + ) + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('client_wins')).toBeInTheDocument() + }) + }) + + it('color-codes action tags', async () => { + server.use( + http.get('*/api/v1/config/sync-logs', () => { + return HttpResponse.json(mockSyncLogs) + }), + ) + renderWithProviders() + + await waitFor(() => { + const pushTag = screen.getByText('ๆŽจ้€').closest('.ant-tag') + expect(pushTag?.className).toMatch(/blue/) + }) + const pullTag = screen.getByText('ๆ‹‰ๅ–').closest('.ant-tag') + expect(pullTag?.className).toMatch(/cyan/) + }) + + it('renders table column headers', async () => { + server.use( + http.get('*/api/v1/config/sync-logs', () => { + return HttpResponse.json(mockSyncLogs) + }), + ) + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('ๆ“ไฝœ')).toBeInTheDocument() + }) + expect(screen.getByText('ๅฎขๆˆท็ซฏๆŒ‡็บน')).toBeInTheDocument() + expect(screen.getByText('้…็ฝฎ้”ฎ')).toBeInTheDocument() + }) +}) diff --git a/admin-v2/tests/pages/Knowledge.test.tsx b/admin-v2/tests/pages/Knowledge.test.tsx new file mode 100644 index 0000000..7b5348e --- /dev/null +++ b/admin-v2/tests/pages/Knowledge.test.tsx @@ -0,0 +1,299 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +import Knowledge from '@/pages/Knowledge' + +// โ”€โ”€ Mock data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const mockCategories = [ + { + id: 'cat-001', name: 'ๆŠ€ๆœฏๆ–‡ๆกฃ', description: 'ๆŠ€ๆœฏ็›ธๅ…ณๆ–‡ๆกฃ', + parent_id: null, icon: '๐Ÿ“š', sort_order: 0, item_count: 5, + children: [], created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + }, + { + id: 'cat-002', name: '่กŒไธš็Ÿฅ่ฏ†', description: '่กŒไธš็›ธๅ…ณ็Ÿฅ่ฏ†', + parent_id: null, icon: '๐Ÿญ', sort_order: 1, item_count: 3, + children: [], created_at: '2026-01-15T00:00:00Z', updated_at: '2026-01-15T00:00:00Z', + }, +] + +const mockItems = { + items: [ + { + id: 'item-001', category_id: 'cat-001', title: 'API ่ฎค่ฏๆŒ‡ๅ—', + content: 'JWT ่ฎค่ฏๆต็จ‹่ฏดๆ˜Ž...', keywords: ['่ฎค่ฏ', 'JWT'], + related_questions: [], priority: 5, status: 'active', + version: 2, source: 'manual', tags: ['api', 'auth'], + created_by: 'admin', created_at: '2026-02-01T00:00:00Z', updated_at: '2026-03-01T00:00:00Z', + }, + { + id: 'item-002', category_id: 'cat-002', title: '็Žฉๅ…ทๅธ‚ๅœบ่ถ‹ๅŠฟ 2026', + content: '2026 ๅนด็Žฉๅ…ท่กŒไธš่ถ‹ๅŠฟๅˆ†ๆž...', keywords: ['ๅธ‚ๅœบ', '่ถ‹ๅŠฟ'], + related_questions: [], priority: 3, status: 'active', + version: 1, source: 'import', tags: ['market'], + created_by: 'admin', created_at: '2026-03-01T00:00:00Z', updated_at: '2026-03-01T00:00:00Z', + }, + ], + total: 2, page: 1, page_size: 20, +} + +const mockOverview = { + total_items: 8, active_items: 6, total_categories: 2, + weekly_new_items: 1, total_references: 45, avg_reference_per_item: 5.6, + hit_rate: 0.78, injection_rate: 0.65, positive_feedback_rate: 0.92, + stale_items_count: 1, +} + +const mockTrends = { trends: [{ date: '2026-04-01', new_items: 2, references: 10, queries: 25 }] } +const mockTopItems = { items: [] } +const mockQuality = { metrics: [] } +const mockGaps = { gaps: [] } + +const server = setupServer() + +beforeEach(() => { + server.listen({ onUnhandledRequest: 'bypass' }) +}) + +afterEach(() => { + server.close() +}) + +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return render( + + {ui} + , + ) +} + +function setupKnowledgeHandlers(overrides: Record = {}) { + server.use( + http.get('*/api/v1/knowledge/categories', () => { + return HttpResponse.json(overrides.categories ?? mockCategories) + }), + http.get('*/api/v1/knowledge/items', () => { + return HttpResponse.json(overrides.items ?? mockItems) + }), + http.get('*/api/v1/knowledge/analytics/overview', () => { + return HttpResponse.json(overrides.overview ?? mockOverview) + }), + http.get('*/api/v1/knowledge/analytics/trends', () => { + return HttpResponse.json(overrides.trends ?? mockTrends) + }), + http.get('*/api/v1/knowledge/analytics/top-items', () => { + return HttpResponse.json(overrides.topItems ?? mockTopItems) + }), + http.get('*/api/v1/knowledge/analytics/quality', () => { + return HttpResponse.json(overrides.quality ?? mockQuality) + }), + http.get('*/api/v1/knowledge/analytics/gaps', () => { + return HttpResponse.json(overrides.gaps ?? mockGaps) + }), + ) +} + +describe('Knowledge', () => { + // โ”€โ”€ Tab structure โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it('renders all tab labels', () => { + setupKnowledgeHandlers() + renderWithProviders() + + expect(screen.getByText('็Ÿฅ่ฏ†ๆก็›ฎ')).toBeInTheDocument() + expect(screen.getByText('ๅˆ†็ฑป็ฎก็†')).toBeInTheDocument() + expect(screen.getByText('ๆœ็ดข')).toBeInTheDocument() + expect(screen.getByText('ๅˆ†ๆž็œ‹ๆฟ')).toBeInTheDocument() + }) + + // โ”€โ”€ Items Tab (default) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it('displays items in default tab', async () => { + setupKnowledgeHandlers() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('API ่ฎค่ฏๆŒ‡ๅ—')).toBeInTheDocument() + }) + expect(screen.getByText('็Žฉๅ…ทๅธ‚ๅœบ่ถ‹ๅŠฟ 2026')).toBeInTheDocument() + }) + + it('displays item status with Chinese labels', async () => { + setupKnowledgeHandlers() + renderWithProviders() + + await waitFor(() => { + // status "active" is displayed as "ๆดป่ทƒ" via statusLabels mapping + const activeLabels = screen.getAllByText('ๆดป่ทƒ') + expect(activeLabels.length).toBeGreaterThanOrEqual(2) + }) + }) + + it('displays item version column', async () => { + setupKnowledgeHandlers() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('API ่ฎค่ฏๆŒ‡ๅ—')).toBeInTheDocument() + }) + // Version numbers in the table + expect(screen.getByText('2')).toBeInTheDocument() + }) + + it('shows empty state when no items', async () => { + setupKnowledgeHandlers({ + items: { items: [], total: 0, page: 1, page_size: 20 }, + }) + renderWithProviders() + + await waitFor(() => { + const empties = screen.getAllByText('ๆš‚ๆ— ๆ•ฐๆฎ') + expect(empties.length).toBeGreaterThanOrEqual(1) + }) + }) + + // โ”€โ”€ Categories Tab โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it('switches to categories tab and displays categories', async () => { + setupKnowledgeHandlers() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('API ่ฎค่ฏๆŒ‡ๅ—')).toBeInTheDocument() + }) + + // Find the tab button (not the panel heading) and click it + const categoryTabs = screen.getAllByText('ๅˆ†็ฑป็ฎก็†') + await act(async () => { + fireEvent.click(categoryTabs[0]) + }) + + // Wait for the categories panel to render its heading and tree + await waitFor(() => { + // "ๆ–ฐๅปบๅˆ†็ฑป" button should appear in the CategoriesPanel toolbar + expect(screen.getByText('ๆ–ฐๅปบๅˆ†็ฑป')).toBeInTheDocument() + }, { timeout: 3000 }) + + // Category names rendered via Tree component inside spans with icon prefix + // Use stringContaining since the text includes icon emoji prefix + expect(screen.getByText((content) => content.includes('ๆŠ€ๆœฏๆ–‡ๆกฃ'))).toBeInTheDocument() + expect(screen.getByText((content) => content.includes('่กŒไธš็Ÿฅ่ฏ†'))).toBeInTheDocument() + }) + + it('shows empty categories state', async () => { + setupKnowledgeHandlers({ categories: [] }) + renderWithProviders() + + // Wait for items tab to load first + await waitFor(() => { + expect(screen.getByText('API ่ฎค่ฏๆŒ‡ๅ—')).toBeInTheDocument() + }) + + // Switch to categories tab + const categoryTabs = screen.getAllByText('ๅˆ†็ฑป็ฎก็†') + await act(async () => { + fireEvent.click(categoryTabs[0]) + }) + + // The CategoriesPanel should show "ๆš‚ๆ— ๅˆ†็ฑป๏ผŒ่ฏทๆ–ฐๅปบไธ€ไธช" for empty state + await waitFor(() => { + expect(screen.getByText('ๆš‚ๆ— ๅˆ†็ฑป๏ผŒ่ฏทๆ–ฐๅปบไธ€ไธช')).toBeInTheDocument() + }, { timeout: 3000 }) + }) + + // โ”€โ”€ Analytics Tab โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it('switches to analytics tab and shows overview stats', async () => { + setupKnowledgeHandlers() + renderWithProviders() + + const analyticsTab = screen.getByText('ๅˆ†ๆž็œ‹ๆฟ') + await act(async () => { + fireEvent.click(analyticsTab) + }) + + await waitFor(() => { + expect(screen.getByText('ๆ€ปๆก็›ฎๆ•ฐ')).toBeInTheDocument() + }) + expect(screen.getByText('ๆดป่ทƒๆก็›ฎ')).toBeInTheDocument() + expect(screen.getByText('ๅˆ†็ฑปๆ•ฐ')).toBeInTheDocument() + expect(screen.getByText('ๆœฌๅ‘จๆ–ฐๅขž')).toBeInTheDocument() + }) + + it('displays analytics numbers', async () => { + setupKnowledgeHandlers() + renderWithProviders() + + const analyticsTab = screen.getByText('ๅˆ†ๆž็œ‹ๆฟ') + await act(async () => { + fireEvent.click(analyticsTab) + }) + + await waitFor(() => { + // total_items: 8 + expect(screen.getByText('8')).toBeInTheDocument() + }) + }) + + // โ”€โ”€ Error Handling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it('shows empty on API failure', async () => { + server.use( + http.get('*/api/v1/knowledge/categories', () => { + return HttpResponse.json( + { error: 'internal_error', message: 'ๆ•ฐๆฎๅบ“้”™่ฏฏ' }, + { status: 500 }, + ) + }), + http.get('*/api/v1/knowledge/items', () => { + return HttpResponse.json( + { error: 'internal_error', message: 'ๆ•ฐๆฎๅบ“้”™่ฏฏ' }, + { status: 500 }, + ) + }), + ) + renderWithProviders() + + await waitFor(() => { + const empties = screen.getAllByText('ๆš‚ๆ— ๆ•ฐๆฎ') + expect(empties.length).toBeGreaterThanOrEqual(1) + }) + }) + + // โ”€โ”€ Loading State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it('shows loading spinner', async () => { + server.use( + http.get('*/api/v1/knowledge/categories', async () => { + await new Promise(resolve => setTimeout(resolve, 500)) + return HttpResponse.json(mockCategories) + }), + http.get('*/api/v1/knowledge/items', async () => { + await new Promise(resolve => setTimeout(resolve, 500)) + return HttpResponse.json(mockItems) + }), + ) + renderWithProviders() + expect(document.querySelector('.ant-spin')).toBeTruthy() + }) + + // โ”€โ”€ Item Tags โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it('displays item tags in table', async () => { + setupKnowledgeHandlers() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('api')).toBeInTheDocument() + }) + expect(screen.getByText('auth')).toBeInTheDocument() + expect(screen.getByText('market')).toBeInTheDocument() + }) +}) diff --git a/admin-v2/tests/pages/Roles.test.tsx b/admin-v2/tests/pages/Roles.test.tsx new file mode 100644 index 0000000..8d9ac47 --- /dev/null +++ b/admin-v2/tests/pages/Roles.test.tsx @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +import Roles from '@/pages/Roles' + +// โ”€โ”€ Mock data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const mockRoles = [ + { + id: 'role-admin', name: 'admin', description: '็ฎก็†ๅ‘˜', + permissions: ['admin:full'], account_count: 2, + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + }, + { + id: 'role-user', name: 'user', description: 'ๆ™ฎ้€š็”จๆˆท', + permissions: ['model:read', 'relay:use', 'config:read', 'prompt:read'], + account_count: 15, + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + }, +] + +const mockTemplates = [ + { + id: 'tpl-read-only', name: 'ๅช่ฏปๆจกๆฟ', description: 'ไป…ๆŸฅ็œ‹ๆƒ้™', + permissions: ['model:read', 'config:read'], + created_at: '2026-02-01T00:00:00Z', updated_at: '2026-02-01T00:00:00Z', + }, + { + id: 'tpl-operator', name: 'ๆ“ไฝœๅ‘˜ๆจกๆฟ', description: 'ๆ“ไฝœๆƒ้™', + permissions: ['model:read', 'relay:use', 'config:read', 'prompt:read', 'hand:use'], + created_at: '2026-02-15T00:00:00Z', updated_at: '2026-02-15T00:00:00Z', + }, +] + +const server = setupServer() + +beforeEach(() => { + server.listen({ onUnhandledRequest: 'bypass' }) +}) + +afterEach(() => { + server.close() +}) + +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return render( + + {ui} + , + ) +} + +function setupRolesHandlers(overrides: Record = {}) { + server.use( + http.get('*/api/v1/roles', () => { + return HttpResponse.json(overrides.roles ?? mockRoles) + }), + http.get('*/api/v1/permission-templates', () => { + return HttpResponse.json(overrides.templates ?? mockTemplates) + }), + ) +} + +describe('Roles', () => { + it('renders page title', () => { + setupRolesHandlers() + renderWithProviders() + expect(screen.getByText('่ง’่‰ฒไธŽๆƒ้™')).toBeInTheDocument() + }) + + it('displays tabs', () => { + setupRolesHandlers() + renderWithProviders() + // Tabs use label spans with icons + expect(screen.getByText('่ง’่‰ฒ')).toBeInTheDocument() + expect(screen.getByText('ๆƒ้™ๆจกๆฟ')).toBeInTheDocument() + }) + + it('displays roles in default tab', async () => { + setupRolesHandlers() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('admin')).toBeInTheDocument() + }) + expect(screen.getByText('user')).toBeInTheDocument() + expect(screen.getByText('็ฎก็†ๅ‘˜')).toBeInTheDocument() + expect(screen.getByText('ๆ™ฎ้€š็”จๆˆท')).toBeInTheDocument() + }) + + it('displays permissions count tags', async () => { + setupRolesHandlers() + renderWithProviders() + + await waitFor(() => { + // "1 ้กน" for admin (1 permission), "4 ้กน" for user (4 permissions) + expect(screen.getByText('1 ้กน')).toBeInTheDocument() + expect(screen.getByText('4 ้กน')).toBeInTheDocument() + }) + }) + + it('displays account count column', async () => { + setupRolesHandlers() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('admin')).toBeInTheDocument() + }) + // account_count: admin=2, user=15 + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByText('15')).toBeInTheDocument() + }) + + it('has ๆ–ฐๅปบ่ง’่‰ฒ button', async () => { + setupRolesHandlers() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('ๆ–ฐๅปบ่ง’่‰ฒ')).toBeInTheDocument() + }) + }) + + it('renders ๆ“ไฝœ column for role rows', async () => { + setupRolesHandlers() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('admin')).toBeInTheDocument() + }) + + // ๆ“ไฝœ column header should exist + expect(screen.getByText('ๆ“ไฝœ')).toBeInTheDocument() + }) + + it('shows empty roles state', async () => { + setupRolesHandlers({ roles: [], templates: mockTemplates }) + renderWithProviders() + + await waitFor(() => { + const empties = screen.getAllByText('ๆš‚ๆ— ๆ•ฐๆฎ') + expect(empties.length).toBeGreaterThanOrEqual(1) + }) + }) + + it('switches to templates tab and displays templates', async () => { + setupRolesHandlers() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('admin')).toBeInTheDocument() + }) + + // Click ๆƒ้™ๆจกๆฟ tab + screen.getByText('ๆƒ้™ๆจกๆฟ').click() + + await waitFor(() => { + expect(screen.getByText('ๅช่ฏปๆจกๆฟ')).toBeInTheDocument() + }) + expect(screen.getByText('ๆ“ไฝœๅ‘˜ๆจกๆฟ')).toBeInTheDocument() + }) + + it('displays template permission counts', async () => { + setupRolesHandlers() + renderWithProviders() + + screen.getByText('ๆƒ้™ๆจกๆฟ').click() + + await waitFor(() => { + // read-only: 2 permissions, operator: 5 permissions + expect(screen.getByText('2 ้กน')).toBeInTheDocument() + }) + }) + + it('shows empty templates state', async () => { + setupRolesHandlers({ roles: mockRoles, templates: [] }) + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('admin')).toBeInTheDocument() + }) + + screen.getByText('ๆƒ้™ๆจกๆฟ').click() + + await waitFor(() => { + const empties = screen.getAllByText('ๆš‚ๆ— ๆ•ฐๆฎ') + expect(empties.length).toBeGreaterThanOrEqual(1) + }) + }) + + it('has ๆ–ฐๅปบๆจกๆฟ button in templates tab', async () => { + setupRolesHandlers() + renderWithProviders() + + screen.getByText('ๆƒ้™ๆจกๆฟ').click() + + await waitFor(() => { + expect(screen.getByText('ๆ–ฐๅปบๆจกๆฟ')).toBeInTheDocument() + }) + }) + + it('shows empty on roles API failure', async () => { + server.use( + http.get('*/api/v1/roles', () => { + return HttpResponse.json( + { error: 'internal_error', message: 'ๆ•ฐๆฎๅบ“้”™่ฏฏ' }, + { status: 500 }, + ) + }), + http.get('*/api/v1/permission-templates', () => { + return HttpResponse.json(mockTemplates) + }), + ) + renderWithProviders() + + await waitFor(() => { + // ProTable shows ๆš‚ๆ— ๆ•ฐๆฎ when data fetch fails + const empties = screen.getAllByText('ๆš‚ๆ— ๆ•ฐๆฎ') + expect(empties.length).toBeGreaterThanOrEqual(1) + }) + }) +}) diff --git a/admin-v2/tests/pages/ScheduledTasks.test.tsx b/admin-v2/tests/pages/ScheduledTasks.test.tsx new file mode 100644 index 0000000..d096809 --- /dev/null +++ b/admin-v2/tests/pages/ScheduledTasks.test.tsx @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +import ScheduledTasks from '@/pages/ScheduledTasks' + +// โ”€โ”€ Mock data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const mockTasks = [ + { + id: 'task-001', + name: 'ๆฏๆ—ฅๆ—ฉๆŠฅ', + schedule: '0 8 * * *', + schedule_type: 'cron', + target: { type: 'agent', id: 'daily-news' }, + enabled: true, + description: 'ๆฏๅคฉๆ—ฉไธŠ8็‚นๆŽจ้€ๆ–ฐ้—ป', + last_run: '2026-04-07T08:00:00Z', + next_run: '2026-04-08T08:00:00Z', + run_count: 30, + last_result: null, + last_error: null, + last_duration_ms: 1500, + created_at: '2026-03-01T00:00:00Z', + }, + { + id: 'task-002', + name: 'ๅฎšๆ—ถ้‡‡้›†', + schedule: '30m', + schedule_type: 'interval', + target: { type: 'hand', id: 'collector' }, + enabled: false, + description: null, + last_run: null, + next_run: null, + run_count: 0, + last_result: null, + last_error: null, + last_duration_ms: null, + created_at: '2026-04-01T00:00:00Z', + }, +] + +const server = setupServer() + +beforeEach(() => { + server.listen({ onUnhandledRequest: 'bypass' }) +}) + +afterEach(() => { + server.close() +}) + +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return render( + + {ui} + , + ) +} + +describe('ScheduledTasks', () => { + it('renders page header', () => { + server.use( + http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json([])), + ) + renderWithProviders() + expect(screen.getByText('ๅฎšๆ—ถไปปๅŠก')).toBeInTheDocument() + }) + + it('shows loading spinner', async () => { + server.use( + http.get('*/api/v1/scheduler/tasks', async () => { + await new Promise(resolve => setTimeout(resolve, 500)) + return HttpResponse.json(mockTasks) + }), + ) + renderWithProviders() + expect(document.querySelector('.ant-spin')).toBeTruthy() + }) + + it('displays tasks in table', async () => { + server.use( + http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)), + ) + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('ๆฏๆ—ฅๆ—ฉๆŠฅ')).toBeInTheDocument() + }) + expect(screen.getByText('ๅฎšๆ—ถ้‡‡้›†')).toBeInTheDocument() + expect(screen.getByText('0 8 * * *')).toBeInTheDocument() + expect(screen.getByText('30m')).toBeInTheDocument() + }) + + it('displays Chinese schedule type labels', async () => { + server.use( + http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)), + ) + renderWithProviders() + + await waitFor(() => { + // schedule_type labels: cron โ†’ "Cron", interval โ†’ "้—ด้š”" + expect(screen.getByText('Cron')).toBeInTheDocument() + }) + expect(screen.getByText('้—ด้š”')).toBeInTheDocument() + }) + + it('displays target type with English labels', async () => { + server.use( + http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)), + ) + renderWithProviders() + + await waitFor(() => { + // target type labels: agent โ†’ "Agent", hand โ†’ "Hand" + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + expect(screen.getByText('Hand')).toBeInTheDocument() + }) + + it('displays target IDs', async () => { + server.use( + http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)), + ) + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('daily-news')).toBeInTheDocument() + }) + expect(screen.getByText('collector')).toBeInTheDocument() + }) + + it('displays enabled switches', async () => { + server.use( + http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)), + ) + renderWithProviders() + + await waitFor(() => { + const switches = document.querySelectorAll('.ant-switch') + expect(switches.length).toBeGreaterThanOrEqual(2) + }) + }) + + it('displays run count', async () => { + server.use( + http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)), + ) + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('ๆฏๆ—ฅๆ—ฉๆŠฅ')).toBeInTheDocument() + }) + // run_count: 30 is displayed in tabular-nums span + expect(screen.getByText('30')).toBeInTheDocument() + }) + + it('shows last error in red when present', async () => { + const tasksWithError = [{ ...mockTasks[0], last_error: 'Connection timeout' }] + server.use( + http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(tasksWithError)), + ) + renderWithProviders() + + await waitFor(() => { + const errorEl = screen.getByText('Connection timeout') + expect(errorEl).toHaveClass('text-red-500') + }) + }) + + it('shows dash for null last error', async () => { + server.use( + http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)), + ) + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('ๆฏๆ—ฅๆ—ฉๆŠฅ')).toBeInTheDocument() + }) + }) + + it('shows empty state when no tasks', async () => { + server.use( + http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json([])), + ) + renderWithProviders() + + await waitFor(() => { + const empties = screen.getAllByText('ๆš‚ๆ— ๆ•ฐๆฎ') + expect(empties.length).toBeGreaterThanOrEqual(1) + }) + }) + + it('shows error state on API failure', async () => { + server.use( + http.get('*/api/v1/scheduler/tasks', () => { + return HttpResponse.json( + { error: 'internal_error', message: 'ๆ•ฐๆฎๅบ“้”™่ฏฏ' }, + { status: 500 }, + ) + }), + ) + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('ๅŠ ่ฝฝๅคฑ่ดฅ')).toBeInTheDocument() + }) + }) + + it('has ๆ–ฐๅปบไปปๅŠก button', async () => { + server.use( + http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json([])), + ) + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('ๆ–ฐๅปบไปปๅŠก')).toBeInTheDocument() + }) + }) + + it('renders action column with buttons', async () => { + server.use( + http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)), + ) + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('ๆฏๆ—ฅๆ—ฉๆŠฅ')).toBeInTheDocument() + }) + + // ๆ“ไฝœ column header should be rendered + expect(screen.getByText('ๆ“ไฝœ')).toBeInTheDocument() + }) + + it('color-codes schedule type tags', async () => { + server.use( + http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)), + ) + renderWithProviders() + + await waitFor(() => { + const cronTag = screen.getByText('Cron').closest('.ant-tag') + expect(cronTag?.className).toMatch(/blue/) + }) + const intervalTag = screen.getByText('้—ด้š”').closest('.ant-tag') + expect(intervalTag?.className).toMatch(/green/) + }) + + it('color-codes target type tags', async () => { + server.use( + http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)), + ) + renderWithProviders() + + await waitFor(() => { + const agentTag = screen.getByText('Agent').closest('.ant-tag') + expect(agentTag?.className).toMatch(/purple/) + }) + const handTag = screen.getByText('Hand').closest('.ant-tag') + expect(handTag?.className).toMatch(/cyan/) + }) +}) diff --git a/admin-v2/tests/setup.ts b/admin-v2/tests/setup.ts index a569cfc..d25c738 100644 --- a/admin-v2/tests/setup.ts +++ b/admin-v2/tests/setup.ts @@ -40,4 +40,11 @@ beforeAll(() => { return {} as CSSStyleDeclaration } } + + // Ant Design ProTable / rc-virtual-list require ResizeObserver + global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } })