439 lines
17 KiB
TypeScript
439 lines
17 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { AppShell, type InstanceContext } from './app-shell.js';
|
|
import type { AuditDataSource } from './audit/audit-page.js';
|
|
import type { EventStreamClient } from './events/event-stream-client.js';
|
|
import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
|
import type { FleetMessagesDataSource } from './fleet/fleet-messages-api-data-source.js';
|
|
import type { InstanceDataSource } from './instances/instance-api-data-source.js';
|
|
import type { JobsDataSource } from './jobs/jobs-page.js';
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
const snapshot: FleetSnapshot = {
|
|
instances: [
|
|
{
|
|
id: 'bravo',
|
|
name: 'Bravo',
|
|
url: 'http://user:***@bravo.example:8080/private',
|
|
tags: ['west'],
|
|
},
|
|
{ id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin' },
|
|
],
|
|
statuses: new Map([
|
|
[
|
|
'bravo',
|
|
{
|
|
reachable: true,
|
|
authenticated: false,
|
|
latencyMs: 40,
|
|
summary: {
|
|
version: '2.0',
|
|
capabilities: ['sms'],
|
|
freshness: 'stale',
|
|
anomalies: ['clock drift'],
|
|
resources: {
|
|
cpuPercent: 18.4,
|
|
memoryPercent: 63.2,
|
|
maxTemperatureCelsius: 46.7,
|
|
phoneNumbers: ['13800138000'],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
'alpha',
|
|
{
|
|
reachable: true,
|
|
authenticated: true,
|
|
latencyMs: 10,
|
|
summary: { version: '1.0', capabilities: ['ussd'], freshness: 'fresh' },
|
|
},
|
|
],
|
|
]),
|
|
};
|
|
|
|
function source(load: FleetDataSource['load']): FleetDataSource {
|
|
return { load };
|
|
}
|
|
|
|
const quietEventStreamClient: EventStreamClient = {
|
|
subscribe: () => () => undefined,
|
|
};
|
|
|
|
const emptyPage = { items: [], page: { page: 1, pageSize: 25, total: 0 } };
|
|
|
|
describe('React AppShell and Fleet vertical slice', () => {
|
|
it('loads real injected data and renders canonical origins and owner routes without React key warnings', async () => {
|
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
let resolve!: (value: FleetSnapshot) => void;
|
|
const dataSource = source(() => new Promise((done) => (resolve = done)));
|
|
render(<AppShell pathname="/fleet" version="0.1.0" fleetDataSource={dataSource} />);
|
|
|
|
expect(screen.getByRole('status', { name: '实例加载状态' }).textContent).toContain(
|
|
'正在加载实例',
|
|
);
|
|
resolve(snapshot);
|
|
|
|
const alpha = await screen.findByRole('row', { name: /Alpha/ });
|
|
expect(within(alpha).getByRole('link', { name: 'Alpha' }).getAttribute('href')).toBe(
|
|
'/instances/alpha/overview',
|
|
);
|
|
expect(
|
|
within(screen.getByRole('row', { name: /Bravo/ }))
|
|
.getByRole('link', { name: '打开 Bravo 的源站' })
|
|
.getAttribute('href'),
|
|
).toBe('http://bravo.example:8080');
|
|
expect(screen.getByText('版本 0.1.0')).toBeTruthy();
|
|
expect(
|
|
consoleError.mock.calls.some((call) =>
|
|
call.some((argument) => String(argument).includes('unique "key" prop')),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('makes cards, add, detail, edit, and safe deletion available from the overview', async () => {
|
|
const user = userEvent.setup();
|
|
const remove = vi.fn<InstanceDataSource['delete']>().mockResolvedValue(undefined);
|
|
const instanceDataSource: InstanceDataSource = {
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'bravo',
|
|
name: 'Bravo',
|
|
origin: 'http://bravo.example:8080',
|
|
tags: ['west'],
|
|
revision: 7,
|
|
credentialConfigured: true,
|
|
}),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
testConnection: vi.fn(),
|
|
delete: remove,
|
|
};
|
|
|
|
const fleetMessagesDataSource: FleetMessagesDataSource = {
|
|
load: vi.fn(async (instanceId) =>
|
|
instanceId === 'bravo'
|
|
? {
|
|
latest: {
|
|
id: 'sms-1',
|
|
direction: 'incoming',
|
|
phoneNumber: '13900139000',
|
|
content: '这是一条用于聚合页展示的最新短信内容',
|
|
timestamp: '2026-07-19T08:30:00.000Z',
|
|
},
|
|
}
|
|
: {},
|
|
),
|
|
};
|
|
|
|
render(
|
|
<AppShell
|
|
pathname="/fleet"
|
|
fleetDataSource={source(async () => snapshot)}
|
|
fleetMessagesDataSource={fleetMessagesDataSource}
|
|
instanceDataSource={instanceDataSource}
|
|
/>,
|
|
);
|
|
|
|
expect(await screen.findByRole('region', { name: '实例卡片' })).toBeTruthy();
|
|
expect(screen.getByRole('link', { name: '添加实例' }).getAttribute('href')).toBe(
|
|
'/instances/new',
|
|
);
|
|
const card = screen.getByRole('article', { name: 'Bravo 实例概览' });
|
|
expect(within(card).queryByText('延迟')).toBeNull();
|
|
expect(within(card).queryByText('版本')).toBeNull();
|
|
expect(within(card).queryByText('新鲜度')).toBeNull();
|
|
expect(within(card).queryByText('40 ms')).toBeNull();
|
|
expect(within(card).queryByText('2.0')).toBeNull();
|
|
expect(within(card).queryByText('可能过期')).toBeNull();
|
|
expect(within(card).getByText('18.4%')).toBeTruthy();
|
|
expect(within(card).getByText('63.2%')).toBeTruthy();
|
|
expect(within(card).getByText('46.7 °C')).toBeTruthy();
|
|
expect(within(card).getByText('13800138000')).toBeTruthy();
|
|
expect(await within(card).findByText('收到')).toBeTruthy();
|
|
expect(within(card).getByText('13900139000')).toBeTruthy();
|
|
expect(within(card).getByText(/这是一条用于聚合页展示/)).toBeTruthy();
|
|
expect(within(card).getByText(/2026/)).toBeTruthy();
|
|
expect(within(card).queryByText('能力未知')).toBeNull();
|
|
expect(
|
|
within(card).getByRole('link', { name: '打开 Bravo 实例仪表盘' }).getAttribute('href'),
|
|
).toBe('/instances/bravo/overview');
|
|
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
|
|
expect(within(card).getByRole('link', { name: '编辑 Bravo' }).getAttribute('href')).toBe(
|
|
'/settings/instances/bravo',
|
|
);
|
|
|
|
await user.click(within(card).getByRole('button', { name: '删除 Bravo' }));
|
|
expect(within(card).getByText('删除后无法撤销。')).toBeTruthy();
|
|
await user.type(within(card).getByRole('textbox', { name: '输入 bravo 以确认删除' }), 'bravo');
|
|
await user.click(within(card).getByRole('button', { name: '确认删除 Bravo' }));
|
|
expect(remove).toHaveBeenCalledWith('bravo', 7);
|
|
expect(within(card).getByRole('status').textContent).toContain('删除请求已提交');
|
|
});
|
|
|
|
it('keeps message read failures separate from instance reachability and reports each once', async () => {
|
|
const messages: FleetMessagesDataSource = { load: vi.fn().mockRejectedValue(new Error('no')) };
|
|
render(
|
|
<AppShell
|
|
pathname="/fleet"
|
|
fleetDataSource={source(async () => snapshot)}
|
|
fleetMessagesDataSource={messages}
|
|
/>,
|
|
);
|
|
const bravo = await screen.findByRole('article', { name: 'Bravo 实例概览' });
|
|
expect(await within(bravo).findAllByText('短信暂不可用')).toHaveLength(1);
|
|
expect(within(bravo).getByText('需要认证')).toBeTruthy();
|
|
});
|
|
|
|
it('keeps Jobs and Audit routes compatible without advertising them in global navigation', async () => {
|
|
const jobsDataSource: JobsDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
|
|
const { rerender } = render(
|
|
<AppShell
|
|
pathname="/jobs"
|
|
jobsDataSource={jobsDataSource}
|
|
eventStreamClient={quietEventStreamClient}
|
|
/>,
|
|
);
|
|
|
|
expect(await screen.findByText(/没有任务符合当前查询/i)).toBeTruthy();
|
|
const navigation = screen.getByRole('navigation', { name: '全局导航' });
|
|
expect(within(navigation).queryByRole('link', { name: '任务' })).toBeNull();
|
|
expect(within(navigation).queryByRole('link', { name: '审计' })).toBeNull();
|
|
expect(within(navigation).getByRole('link', { name: '实例总览' })).toBeTruthy();
|
|
expect(within(navigation).getByRole('link', { name: '设置' })).toBeTruthy();
|
|
|
|
const auditDataSource: AuditDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
|
|
rerender(
|
|
<AppShell
|
|
pathname="/audit"
|
|
auditDataSource={auditDataSource}
|
|
eventStreamClient={quietEventStreamClient}
|
|
/>,
|
|
);
|
|
expect(await screen.findByText(/没有审计事件符合当前查询/i)).toBeTruthy();
|
|
});
|
|
|
|
it('loads route-owned instance context so overview-card navigation opens detail management', async () => {
|
|
const instanceDataSource: InstanceDataSource = {
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'bravo',
|
|
name: 'Bravo',
|
|
origin: 'http://bravo.example:8080',
|
|
tags: ['west'],
|
|
revision: 7,
|
|
credentialConfigured: true,
|
|
}),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
testConnection: vi.fn(),
|
|
delete: vi.fn(),
|
|
};
|
|
|
|
render(
|
|
<AppShell pathname="/instances/bravo/overview" instanceDataSource={instanceDataSource} />,
|
|
);
|
|
|
|
expect(screen.getByRole('status').textContent).toContain('正在加载实例');
|
|
expect(await screen.findByText('Bravo')).toBeTruthy();
|
|
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
|
|
expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe(
|
|
'/settings/instances/bravo',
|
|
);
|
|
});
|
|
|
|
it('supports accessible search, status filtering, sorting, and visible selection', async () => {
|
|
const user = userEvent.setup();
|
|
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
|
|
await screen.findByRole('row', { name: /Alpha/ });
|
|
|
|
await user.click(screen.getByRole('button', { name: /按延迟排序/i }));
|
|
let rows = screen.getAllByRole('row').slice(1);
|
|
expect(rows[0]?.textContent).toContain('Alpha');
|
|
await user.click(screen.getByRole('button', { name: /按延迟排序/i }));
|
|
rows = screen.getAllByRole('row').slice(1);
|
|
expect(rows[0]?.textContent).toContain('Bravo');
|
|
|
|
await user.selectOptions(screen.getByRole('combobox', { name: '状态' }), 'auth');
|
|
expect(screen.queryByRole('row', { name: /Alpha/ })).toBeNull();
|
|
await user.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' }));
|
|
expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe(
|
|
true,
|
|
);
|
|
expect(screen.getByText('已选择 1 项')).toBeTruthy();
|
|
|
|
await user.type(screen.getByRole('searchbox', { name: '搜索实例' }), 'missing');
|
|
expect(screen.getByText(/没有实例符合搜索与筛选条件/i)).toBeTruthy();
|
|
});
|
|
|
|
it('exposes error, retry, configured-empty, and unsafe-origin states without claiming a backend', async () => {
|
|
const user = userEvent.setup();
|
|
const load = vi
|
|
.fn<FleetDataSource['load']>()
|
|
.mockRejectedValueOnce(new Error('collector unavailable'))
|
|
.mockResolvedValueOnce({
|
|
instances: [{ id: 'unsafe', name: 'Unsafe', url: 'javascript:alert(1)' }],
|
|
statuses: new Map(),
|
|
});
|
|
const first = render(<AppShell pathname="/fleet" fleetDataSource={source(load)} />);
|
|
|
|
expect((await screen.findByRole('alert')).textContent).toContain('实例加载失败');
|
|
await user.click(screen.getByRole('button', { name: '重试加载实例' }));
|
|
expect(await screen.findByRole('row', { name: /Unsafe/ })).toBeTruthy();
|
|
expect(screen.queryByRole('link', { name: '打开 Unsafe 的源站' })).toBeNull();
|
|
first.unmount();
|
|
|
|
const { unmount } = render(
|
|
<AppShell
|
|
pathname="/fleet"
|
|
fleetDataSource={source(async () => ({ instances: [], statuses: new Map() }))}
|
|
/>,
|
|
);
|
|
expect(await screen.findByText(/尚未配置实例/i)).toBeTruthy();
|
|
unmount();
|
|
});
|
|
|
|
it('supports metadata filters, column visibility, pagination, and a batch-action entry', async () => {
|
|
const user = userEvent.setup();
|
|
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
|
|
await screen.findByRole('row', { name: /Alpha/ });
|
|
|
|
await user.selectOptions(screen.getByRole('combobox', { name: '能力' }), 'sms');
|
|
expect(screen.queryByRole('row', { name: /Alpha/ })).toBeNull();
|
|
expect(screen.getByRole('row', { name: /Bravo/ }).textContent).toContain('clock drift');
|
|
|
|
await user.click(screen.getByRole('button', { name: '选择列' }));
|
|
await user.click(screen.getByRole('checkbox', { name: '显示异常列' }));
|
|
expect(screen.queryByRole('columnheader', { name: '异常' })).toBeNull();
|
|
|
|
await user.click(screen.getByRole('checkbox', { name: '选择 Bravo' }));
|
|
expect((screen.getByRole('button', { name: '批量操作' }) as HTMLButtonElement).disabled).toBe(
|
|
false,
|
|
);
|
|
await user.click(screen.getByRole('button', { name: '批量操作' }));
|
|
expect(screen.getByText('请为已选择的 1 项选择操作。')).toBeTruthy();
|
|
});
|
|
|
|
it('paginates fleet rows and selects only the current page', async () => {
|
|
const many: FleetSnapshot = {
|
|
instances: Array.from({ length: 11 }, (_, index) => ({
|
|
id: `instance-${String(index + 1).padStart(2, '0')}`,
|
|
name: `Instance ${String(index + 1).padStart(2, '0')}`,
|
|
url: `https://instance-${index + 1}.example`,
|
|
})),
|
|
statuses: new Map(),
|
|
};
|
|
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => many)} />);
|
|
await screen.findByRole('row', { name: /Instance 01/ });
|
|
|
|
expect(screen.queryByRole('row', { name: /Instance 11/ })).toBeNull();
|
|
expect(screen.getByText('第 1 页,共 2 页')).toBeTruthy();
|
|
fireEvent.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' }));
|
|
expect(screen.getByText('已选择 10 项')).toBeTruthy();
|
|
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
|
expect(await screen.findByRole('row', { name: /Instance 11/ })).toBeTruthy();
|
|
expect(screen.getByText('第 2 页,共 2 页')).toBeTruthy();
|
|
});
|
|
|
|
it('binds instance context to the route owner and never leaks mismatched context', () => {
|
|
const instance: InstanceContext = {
|
|
id: 'owner',
|
|
name: 'Owner modem',
|
|
origin: 'https://owner.example/path',
|
|
status: 'online',
|
|
authentication: 'authenticated',
|
|
freshness: 'fresh',
|
|
};
|
|
const { rerender } = render(
|
|
<AppShell pathname="/instances/owner/messages" instance={instance} />,
|
|
);
|
|
expect(screen.getByRole('heading', { name: '短信管理' })).toBeTruthy();
|
|
expect(screen.getByText('Owner modem')).toBeTruthy();
|
|
expect(screen.getByRole('link', { name: '打开源站' }).getAttribute('href')).toBe(
|
|
'https://owner.example',
|
|
);
|
|
|
|
rerender(<AppShell pathname="/instances/someone-else/messages" instance={instance} />);
|
|
expect(screen.queryByText('Owner modem')).toBeNull();
|
|
expect(screen.getByRole('status').textContent).toContain('正在加载实例');
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
pathname: '/jobs',
|
|
endpoint: '/api/v1/jobs?page=1&pageSize=25&sort=createdAt&direction=desc',
|
|
emptyMessage: /没有任务符合当前查询/i,
|
|
},
|
|
{
|
|
pathname: '/audit',
|
|
endpoint: '/api/v1/audit?sort=occurredAt&direction=desc&page=1&pageSize=25',
|
|
emptyMessage: /没有审计事件符合当前查询/i,
|
|
},
|
|
])(
|
|
'uses the default HTTP data source for $pathname',
|
|
async ({ pathname, endpoint, emptyMessage }) => {
|
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response(JSON.stringify(emptyPage), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetcher);
|
|
|
|
render(<AppShell pathname={pathname} eventStreamClient={quietEventStreamClient} />);
|
|
|
|
expect(await screen.findByText(emptyMessage)).toBeTruthy();
|
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
expect(fetcher).toHaveBeenCalledWith(endpoint, {
|
|
method: 'GET',
|
|
credentials: 'same-origin',
|
|
headers: { accept: 'application/json' },
|
|
signal: expect.any(AbortSignal),
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
pathname: '/jobs',
|
|
sourceProp: 'jobsDataSource' as const,
|
|
emptyMessage: /没有任务符合当前查询/i,
|
|
},
|
|
{
|
|
pathname: '/audit',
|
|
sourceProp: 'auditDataSource' as const,
|
|
emptyMessage: /没有审计事件符合当前查询/i,
|
|
},
|
|
])(
|
|
'uses an explicitly injected data source instead of global fetch for $pathname',
|
|
async ({ pathname, sourceProp, emptyMessage }) => {
|
|
const fetcher = vi.fn<typeof fetch>();
|
|
vi.stubGlobal('fetch', fetcher);
|
|
const load = vi
|
|
.fn<JobsDataSource['load'] | AuditDataSource['load']>()
|
|
.mockResolvedValue(emptyPage);
|
|
const injectedSource = { load };
|
|
|
|
render(
|
|
<AppShell
|
|
pathname={pathname}
|
|
eventStreamClient={quietEventStreamClient}
|
|
{...(sourceProp === 'jobsDataSource'
|
|
? { jobsDataSource: injectedSource as JobsDataSource }
|
|
: { auditDataSource: injectedSource as AuditDataSource })}
|
|
/>,
|
|
);
|
|
|
|
expect(await screen.findByText(emptyMessage)).toBeTruthy();
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
expect(fetcher).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
});
|