e9fae32b43
Maintainer pre-merge review follow-up:
F1: test the orphaned-stack breadcrumb branch (orphaned=true, no regular) —
href carries stackId/orphaned, not external.
F2: extract STACK_CONTAINER_STATE_NAME so code + test share one literal.
F4: type buildStackLinkParams' return as StackLinkParams (documents the real
shape; external stays boolean, serialized by ui-router — no runtime change).
F3 (legacy ?id= deep links) answered wontfix in the PR thread.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
216 lines
7.1 KiB
TypeScript
216 lines
7.1 KiB
TypeScript
import { render, screen } from '@testing-library/react';
|
|
import { http, HttpResponse } from 'msw';
|
|
|
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
|
import { createMockContainer, createMockUser } from '@/react-tools/test-mocks';
|
|
import { server } from '@/setup-tests/server';
|
|
import { User } from '@/portainer/users/types';
|
|
import { ContainerDetailsViewModel } from '@/docker/models/containerDetails';
|
|
|
|
import { ItemView, STACK_CONTAINER_STATE_NAME } from './ItemView';
|
|
|
|
const { useCurrentStateAndParamsMock } = vi.hoisted(() => ({
|
|
useCurrentStateAndParamsMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
|
...(await importOriginal()),
|
|
useCurrentStateAndParams: useCurrentStateAndParamsMock,
|
|
}));
|
|
|
|
describe('ItemView', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Default: container opened from the global containers list.
|
|
useCurrentStateAndParamsMock.mockReturnValue({
|
|
state: { name: 'docker.containers.container' },
|
|
params: { id: 'container-id-123', endpointId: '1', nodeName: undefined },
|
|
});
|
|
});
|
|
|
|
it('renders page header with container details title', async () => {
|
|
renderComponent();
|
|
|
|
expect(
|
|
await screen.findByRole('heading', {
|
|
name: 'Container details',
|
|
level: 1,
|
|
})
|
|
).toBeVisible();
|
|
});
|
|
|
|
it('displays container name in breadcrumbs with leading slash trimmed', async () => {
|
|
renderComponent();
|
|
|
|
expect(await screen.findByText('test-container')).toBeVisible();
|
|
expect(screen.queryByText('/test-container')).not.toBeInTheDocument();
|
|
// The global Containers crumb is shown in the default (non-stack) context.
|
|
expect(screen.getByRole('link', { name: 'Containers' })).toBeVisible();
|
|
});
|
|
|
|
it('keeps the stack trail when the container is opened from a stack', async () => {
|
|
useCurrentStateAndParamsMock.mockReturnValue({
|
|
state: { name: STACK_CONTAINER_STATE_NAME },
|
|
params: {
|
|
id: 'container-id-123',
|
|
endpointId: '1',
|
|
nodeName: undefined,
|
|
name: 'my-stack',
|
|
stackId: '7',
|
|
type: '2',
|
|
regular: 'true',
|
|
},
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
// Stack trail is shown instead of the global Containers crumb.
|
|
const stacksCrumb = await screen.findByRole('link', { name: 'Stacks' });
|
|
expect(stacksCrumb).toBeVisible();
|
|
|
|
const stackCrumb = screen.getByRole('link', { name: 'my-stack' });
|
|
expect(stackCrumb).toBeVisible();
|
|
// Back-link carries the numeric stack id (stackId) so the stack can reload.
|
|
expect(stackCrumb.getAttribute('href')).toContain('stackId=7');
|
|
|
|
expect(
|
|
screen.queryByRole('link', { name: 'Containers' })
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps the stack trail without a stack id for an external stack', async () => {
|
|
useCurrentStateAndParamsMock.mockReturnValue({
|
|
state: { name: STACK_CONTAINER_STATE_NAME },
|
|
params: {
|
|
id: 'container-id-123',
|
|
endpointId: '1',
|
|
nodeName: undefined,
|
|
name: 'ext-stack',
|
|
type: '2',
|
|
external: 'true',
|
|
// stackId/regular are present in the inherited route params but the
|
|
// external branch must NOT propagate them. They are set here to
|
|
// non-empty values so the `not.toContain` assertions below actually
|
|
// catch a fall-through-to-regular regression instead of passing vacuously.
|
|
stackId: '7',
|
|
regular: 'true',
|
|
tab: 'logs',
|
|
},
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
// Stack trail is shown instead of the global Containers crumb.
|
|
const stacksCrumb = await screen.findByRole('link', { name: 'Stacks' });
|
|
expect(stacksCrumb).toBeVisible();
|
|
|
|
const stackCrumb = screen.getByRole('link', { name: 'ext-stack' });
|
|
expect(stackCrumb).toBeVisible();
|
|
// External stacks have no DB id, so the back-link is built from
|
|
// name/type/external only and must omit stackId and regular.
|
|
const href = stackCrumb.getAttribute('href');
|
|
expect(href).toContain('external=true');
|
|
expect(href).toContain('type=2');
|
|
expect(href).not.toContain('stackId=');
|
|
expect(href).not.toContain('regular=');
|
|
|
|
expect(
|
|
screen.queryByRole('link', { name: 'Containers' })
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps the stack trail with the stack id for an orphaned stack', async () => {
|
|
useCurrentStateAndParamsMock.mockReturnValue({
|
|
state: { name: STACK_CONTAINER_STATE_NAME },
|
|
params: {
|
|
id: 'container-id-123',
|
|
endpointId: '1',
|
|
nodeName: undefined,
|
|
name: 'orphan-stack',
|
|
stackId: '7',
|
|
type: '2',
|
|
// Orphaned stacks are identified by orphaned=true and, unlike regular
|
|
// stacks, carry no `regular` flag. They still have a DB id (stackId),
|
|
// so the back-link must keep stackId and must NOT take the external
|
|
// (id-less) branch.
|
|
orphaned: 'true',
|
|
tab: 'logs',
|
|
},
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
// Stack trail is shown instead of the global Containers crumb.
|
|
const stacksCrumb = await screen.findByRole('link', { name: 'Stacks' });
|
|
expect(stacksCrumb).toBeVisible();
|
|
|
|
const stackCrumb = screen.getByRole('link', { name: 'orphan-stack' });
|
|
expect(stackCrumb).toBeVisible();
|
|
const href = stackCrumb.getAttribute('href');
|
|
// Orphaned stacks keep their numeric stack id so the stack can reload,
|
|
// flag the orphaned state, and must not be treated as external.
|
|
expect(href).toContain('stackId=7');
|
|
expect(href).toContain('orphaned=true');
|
|
expect(href).not.toContain('external=');
|
|
|
|
expect(
|
|
screen.queryByRole('link', { name: 'Containers' })
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('renders health status section when container has health data', async () => {
|
|
renderComponent({
|
|
container: {
|
|
State: {
|
|
Health: {
|
|
Status: 'healthy',
|
|
FailingStreak: 0,
|
|
Log: [
|
|
{
|
|
Start: '2024-01-01T00:00:00Z',
|
|
End: '2024-01-01T00:00:01Z',
|
|
ExitCode: 0,
|
|
Output: 'Health check passed',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(await screen.findByText('Container health')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
function renderComponent({
|
|
user = createMockUser({ Role: 1 }),
|
|
container,
|
|
}: {
|
|
user?: User;
|
|
container?: Partial<ContainerDetailsViewModel>;
|
|
} = {}) {
|
|
server.use(
|
|
http.get('/api/endpoints/:endpointId/docker/containers/:id/json', () =>
|
|
HttpResponse.json(createMockContainer(container))
|
|
)
|
|
);
|
|
|
|
const Wrapped = withTestQueryProvider(
|
|
withTestRouter(withUserProvider(ItemView, user), {
|
|
stateConfig: [
|
|
{ name: 'docker', url: '/docker' },
|
|
{ name: 'docker.stacks', url: '/stacks' },
|
|
{
|
|
name: 'docker.stacks.stack',
|
|
url: '/:name?stackId&type®ular&external&orphaned&orphanedRunning&tab',
|
|
},
|
|
{ name: 'docker.containers', url: '/containers' },
|
|
],
|
|
})
|
|
);
|
|
|
|
return render(<Wrapped />);
|
|
}
|