feat(web): complete fleet and instance management slices

This commit is contained in:
chick
2026-07-17 17:12:35 +08:00
parent 4093634e13
commit 6e817f380a
15 changed files with 1737 additions and 203 deletions
@@ -0,0 +1,116 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { InstanceEditor, type InstanceDataSource, type ManagedInstance } from './instance-crud.js';
const owner: ManagedInstance = {
id: 'owner',
name: 'Owner modem',
origin: 'https://owner.example',
tags: ['west', 'lab'],
revision: 3,
credentialConfigured: true,
};
function dataSource(overrides: Partial<InstanceDataSource> = {}): InstanceDataSource {
return {
get: vi.fn(async () => owner),
create: vi.fn(async () => owner),
update: vi.fn(async () => ({ ...owner, revision: 4 })),
testConnection: vi.fn(async () => ({ reachable: true, authenticated: true })),
delete: vi.fn(async () => undefined),
...overrides,
};
}
afterEach(cleanup);
describe('Instance CRUD form', () => {
it('creates with tags and an explicit set-password action without rendering the secret', async () => {
const user = userEvent.setup();
const source = dataSource();
render(<InstanceEditor mode="create" dataSource={source} />);
await user.type(screen.getByLabelText('Name'), 'Lab modem');
await user.type(screen.getByLabelText('Origin'), 'https://lab.example/admin');
await user.type(screen.getByLabelText('Tags'), 'lab, west, lab');
await user.selectOptions(screen.getByLabelText('Authentication method'), 'password');
await user.type(screen.getByLabelText('Password'), 'do-not-render');
expect(document.body.textContent).not.toContain('do-not-render');
await user.click(screen.getByRole('button', { name: 'Add instance' }));
expect(source.create).toHaveBeenCalledWith({
name: 'Lab modem',
origin: 'https://lab.example/admin',
tags: ['lab', 'west'],
password: { action: 'set', password: 'do-not-render' },
});
});
it('loads only the route owner and preserves, sets, or clears its credential explicitly', async () => {
const user = userEvent.setup();
const source = dataSource();
const { rerender } = render(
<InstanceEditor mode="edit" instanceId="owner" dataSource={source} />,
);
expect(await screen.findByDisplayValue('Owner modem')).toBeTruthy();
expect(source.get).toHaveBeenCalledWith('owner');
await user.clear(screen.getByLabelText('Name'));
await user.type(screen.getByLabelText('Name'), 'Renamed');
await user.click(screen.getByRole('button', { name: 'Save changes' }));
expect(source.update).toHaveBeenLastCalledWith(
'owner',
3,
expect.objectContaining({ password: { action: 'preserve' } }),
);
await user.selectOptions(screen.getByLabelText('Password action'), 'clear');
await user.click(screen.getByRole('button', { name: 'Save changes' }));
expect(source.update).toHaveBeenLastCalledWith(
'owner',
4,
expect.objectContaining({ password: { action: 'clear' } }),
);
rerender(<InstanceEditor mode="edit" instanceId="intruder" dataSource={source} />);
expect((await screen.findByRole('alert')).textContent).toMatch(/does not match this route/i);
expect(screen.queryByDisplayValue('Owner modem')).toBeNull();
});
it('tests the persisted owner connection and keeps safe errors visible while editing', async () => {
const user = userEvent.setup();
const source = dataSource({
update: vi.fn(async () => {
throw new Error('Could not save this instance.');
}),
});
render(<InstanceEditor mode="edit" instanceId="owner" dataSource={source} />);
await screen.findByDisplayValue('Owner modem');
await user.click(screen.getByRole('button', { name: 'Test connection' }));
expect((await screen.findByRole('status')).textContent).toMatch(/reachable and authenticated/i);
await user.click(screen.getByRole('button', { name: 'Save changes' }));
expect((await screen.findByRole('alert')).textContent).toContain(
'Could not save this instance.',
);
await user.type(screen.getByLabelText('Name'), ' still here');
expect(screen.getByRole('alert')).toBeTruthy();
});
it('requires an owner-bound typed confirmation before deletion', async () => {
const user = userEvent.setup();
const source = dataSource();
render(<InstanceEditor mode="edit" instanceId="owner" dataSource={source} />);
await screen.findByDisplayValue('Owner modem');
await user.click(screen.getByRole('button', { name: 'Delete instance' }));
expect(
(screen.getByRole('button', { name: 'Confirm deletion' }) as HTMLButtonElement).disabled,
).toBe(true);
await user.type(screen.getByLabelText('Type owner to confirm'), 'owner');
await user.click(screen.getByRole('button', { name: 'Confirm deletion' }));
expect(source.delete).toHaveBeenCalledWith('owner', 3);
});
});