mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
f23b7e1bac
* feat: ordered multi-file Compose for Git sources
Extend Git sources to deploy an ordered list of compose files merged with
docker compose -f base.yaml -f override.yaml ..., plus an optional project
directory.
- Pick and reorder compose files from the repository tree (drag to reorder on
desktop, up/down arrows on phones); manual path entry is also supported.
- The ordered set drives every stack-scoped compose command (deploy, update,
start/stop/restart/down, image scans, Compose Doctor) and the container
lookup, so a service or image declared only in an override is handled too.
- Runtime keys off the materialized set, not the saved configuration: saving a
source does not change deploy args until the pull is applied, and apply
materializes from the pending snapshot rather than live config.
- The project directory is passed as --project-directory, with -p <stack>
pinning the Compose project so container labels stay stable.
- The Mesh override is layered last; single-file sources are byte-identical to
before, and existing rows keep working via the single-path fallback.
Docs cover the picker, ordering, project directory, and the new troubleshooting
and limitations (referenced files are not materialized; the dependency graph,
drift, and networking views read the primary file).
* fix: harden multi-file Git source (hash, unlink, collisions, node id)
- hashContent folds ordered file CONTENTS (not paths) so a clean multi-file
stack is not flagged as locally edited: create/apply hash the fetched files
(repo paths) while pull hashes the on-disk files (materialized paths), which
previously disagreed and showed a false "local edits detected".
- Block unlinking a multi-file or project-directory Git source (409): the deploy
spec lives on the source row, so removing it would silently revert deploys to
root compose.yaml. Single-file sources still unlink.
- Reject materialized-path collisions in the selection validator: an additional
file equal to or nested under compose.yaml, an ancestor/descendant overlap
between selected files, and a project directory nested under a compose file
(previously a 500 at materialization).
- DockerController.getContainersByStack uses the controller's node compose dir
and passes its node id to the authored prefix, instead of the process default.
* fix: CI failures on multi-file Git source (test crash, aria query, path barrier)
- GitSourceFields no longer crashes when repoUrl/branch are falsy: the canBrowse
trim() is optional-chained, so a reusable field component tolerates partial
props. Fixes the apply-binding panel test, which feeds a minimal source object.
- GitSourcePanel tests query the footer Remove button by its exact name, so the
picker's per-file "Remove <path>" buttons no longer collide with the broad
/remove/i match (the test intent, footer Remove present/absent, is unchanged).
- validateCompose uses an inline resolve + startsWith barrier at the context-dir
mkdir sink (CodeQL does not credit the wrapped isPathWithinBase helper),
clearing the js/path-injection alert. The containment check is equivalent and
contextDir is also validated upstream.
* test: update Git source E2E spec for the multi-file compose picker
The compose-file picker replaced the single #git-source-path input and added
per-file Remove buttons, so the E2E spec drove selectors that no longer exist:
- Drop the redundant compose.yaml fills (the picker defaults to compose.yaml).
- Select the footer Remove button by exact name so the picker's per-file
"Remove <path>" buttons no longer make the locator ambiguous.
- Set a custom compose path through the picker (add via the manual input, press
Enter, then remove the default compose.yaml).
* test: match the footer Remove button with an exact Playwright name
Playwright's getByRole name option is a substring match by default, so
{ name: 'Remove' } also matched the picker's "Remove <path>" buttons. Require an
exact match so only the footer Remove button is selected.
585 lines
20 KiB
TypeScript
585 lines
20 KiB
TypeScript
/**
|
|
* Unit tests for DockerController.getTopologyData() - network topology
|
|
* data assembly, filtering, container-to-network mapping, and edge cases.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
|
|
|
const { mockDocker } = vi.hoisted(() => {
|
|
const mockDocker = {
|
|
df: vi.fn(),
|
|
listImages: vi.fn().mockResolvedValue([]),
|
|
listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }),
|
|
listNetworks: vi.fn().mockResolvedValue([]),
|
|
listContainers: vi.fn().mockResolvedValue([]),
|
|
getContainer: vi.fn(),
|
|
getImage: vi.fn(),
|
|
getVolume: vi.fn(),
|
|
getNetwork: vi.fn(),
|
|
pruneContainers: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
|
|
pruneImages: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
|
|
pruneNetworks: vi.fn().mockResolvedValue({}),
|
|
pruneVolumes: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
|
|
createNetwork: vi.fn(),
|
|
};
|
|
return { mockDocker };
|
|
});
|
|
|
|
vi.mock('../services/NodeRegistry', () => ({
|
|
NodeRegistry: {
|
|
getInstance: () => ({
|
|
getDocker: () => mockDocker,
|
|
getDefaultNodeId: () => 1,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
// Prevent COMPOSE_DIR filesystem reads (resolveProjectNameMap reads compose files).
|
|
// With these mocked, fs.readFile returns ENOENT and the map falls back to
|
|
// { stackName: stackName } for each stack passed in.
|
|
vi.mock('child_process', () => ({ exec: vi.fn(), execFile: vi.fn() }));
|
|
vi.mock('util', () => ({ promisify: () => vi.fn() }));
|
|
|
|
import DockerController from '../services/DockerController';
|
|
import { CacheService } from '../services/CacheService';
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// The project-name-map is cached for 60 seconds. Invalidate between tests so
|
|
// that different stack-name inputs do not leak across tests.
|
|
CacheService.getInstance().invalidate('project-name-map');
|
|
});
|
|
|
|
// ── Basic happy path ──────────────────────────────────────────────────
|
|
|
|
describe('DockerController.getTopologyData - basic', () => {
|
|
it('returns networks with their containers', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{
|
|
Id: 'net-a',
|
|
Name: 'app_default',
|
|
Driver: 'bridge',
|
|
Scope: 'local',
|
|
Labels: { 'com.docker.compose.project': 'app' },
|
|
},
|
|
{
|
|
Id: 'net-b',
|
|
Name: 'monitoring_default',
|
|
Driver: 'bridge',
|
|
Scope: 'local',
|
|
Labels: { 'com.docker.compose.project': 'monitoring' },
|
|
},
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([
|
|
{
|
|
Id: 'c1',
|
|
Names: ['/app-web'],
|
|
State: 'running',
|
|
Image: 'nginx:latest',
|
|
Labels: { 'com.docker.compose.project': 'app' },
|
|
NetworkSettings: {
|
|
Networks: { app_default: { NetworkID: 'net-a', IPAddress: '172.20.0.2' } },
|
|
},
|
|
},
|
|
{
|
|
Id: 'c2',
|
|
Names: ['/monitoring-prom'],
|
|
State: 'running',
|
|
Image: 'prom/prometheus',
|
|
Labels: { 'com.docker.compose.project': 'monitoring' },
|
|
NetworkSettings: {
|
|
Networks: { monitoring_default: { NetworkID: 'net-b', IPAddress: '172.21.0.5' } },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData(['app', 'monitoring'], false);
|
|
|
|
expect(result).toHaveLength(2);
|
|
|
|
const appNet = result.find(n => n.Id === 'net-a')!;
|
|
expect(appNet.Name).toBe('app_default');
|
|
expect(appNet.managedStatus).toBe('managed');
|
|
expect(appNet.managedBy).toBe('app');
|
|
expect(appNet.containers).toHaveLength(1);
|
|
expect(appNet.containers[0]).toMatchObject({
|
|
id: 'c1',
|
|
name: 'app-web',
|
|
ip: '172.20.0.2',
|
|
state: 'running',
|
|
image: 'nginx:latest',
|
|
stack: 'app',
|
|
});
|
|
|
|
const monNet = result.find(n => n.Id === 'net-b')!;
|
|
expect(monNet.containers).toHaveLength(1);
|
|
expect(monNet.containers[0].name).toBe('monitoring-prom');
|
|
});
|
|
|
|
it('returns empty array when no networks exist', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([]);
|
|
mockDocker.listContainers.mockResolvedValue([]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData([], false);
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('returns networks with empty containers array when no containers are connected', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{ Id: 'net-x', Name: 'isolated', Driver: 'bridge', Scope: 'local', Labels: {} },
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData([], false);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].containers).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── System network filtering ──────────────────────────────────────────
|
|
|
|
describe('DockerController.getTopologyData - system network filtering', () => {
|
|
const systemNetworks = [
|
|
{ Id: 'sys-bridge', Name: 'bridge', Driver: 'bridge', Scope: 'local' },
|
|
{ Id: 'sys-host', Name: 'host', Driver: 'host', Scope: 'local' },
|
|
{ Id: 'sys-none', Name: 'none', Driver: 'null', Scope: 'local' },
|
|
];
|
|
const userNetwork = {
|
|
Id: 'net-user',
|
|
Name: 'app_default',
|
|
Driver: 'bridge',
|
|
Scope: 'local',
|
|
Labels: { 'com.docker.compose.project': 'app' },
|
|
};
|
|
const bridgeContainer = {
|
|
Id: 'c-bridge',
|
|
Names: ['/host-container'],
|
|
State: 'running',
|
|
Image: 'busybox',
|
|
Labels: {},
|
|
NetworkSettings: {
|
|
Networks: { bridge: { NetworkID: 'sys-bridge', IPAddress: '172.17.0.2' } },
|
|
},
|
|
};
|
|
const userContainer = {
|
|
Id: 'c-user',
|
|
Names: ['/app-svc'],
|
|
State: 'running',
|
|
Image: 'nginx',
|
|
Labels: { 'com.docker.compose.project': 'app' },
|
|
NetworkSettings: {
|
|
Networks: { app_default: { NetworkID: 'net-user', IPAddress: '172.20.0.2' } },
|
|
},
|
|
};
|
|
|
|
it('excludes system networks when includeSystem is false', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([...systemNetworks, userNetwork]);
|
|
mockDocker.listContainers.mockResolvedValue([bridgeContainer, userContainer]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData(['app'], false);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].Name).toBe('app_default');
|
|
// Bridge container is not visible because its only network was filtered out
|
|
expect(result[0].containers.find(c => c.id === 'c-bridge')).toBeUndefined();
|
|
});
|
|
|
|
it('includes system networks when includeSystem is true', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([...systemNetworks, userNetwork]);
|
|
mockDocker.listContainers.mockResolvedValue([bridgeContainer, userContainer]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData(['app'], true);
|
|
|
|
expect(result).toHaveLength(4);
|
|
const bridge = result.find(n => n.Name === 'bridge')!;
|
|
expect(bridge.managedStatus).toBe('system');
|
|
expect(bridge.containers).toHaveLength(1);
|
|
expect(bridge.containers[0].id).toBe('c-bridge');
|
|
});
|
|
|
|
it('system networks always have managedStatus system and managedBy null', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue(systemNetworks);
|
|
mockDocker.listContainers.mockResolvedValue([]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData([], true);
|
|
|
|
expect(result).toHaveLength(3);
|
|
for (const net of result) {
|
|
expect(net.managedStatus).toBe('system');
|
|
expect(net.managedBy).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Stack resolution ──────────────────────────────────────────────────
|
|
|
|
describe('DockerController.getTopologyData - stack resolution', () => {
|
|
it('classifies networks with matching compose project label as managed', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{
|
|
Id: 'n1',
|
|
Name: 'my-stack_default',
|
|
Driver: 'bridge',
|
|
Scope: 'local',
|
|
Labels: { 'com.docker.compose.project': 'my-stack' },
|
|
},
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData(['my-stack'], false);
|
|
|
|
expect(result[0].managedStatus).toBe('managed');
|
|
expect(result[0].managedBy).toBe('my-stack');
|
|
});
|
|
|
|
it('classifies unlabeled networks as unmanaged', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{ Id: 'n1', Name: 'custom-net', Driver: 'bridge', Scope: 'local' },
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData(['my-stack'], false);
|
|
|
|
expect(result[0].managedStatus).toBe('unmanaged');
|
|
expect(result[0].managedBy).toBeNull();
|
|
});
|
|
|
|
it('classifies networks with unrecognized project label as unmanaged', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{
|
|
Id: 'n1',
|
|
Name: 'other_default',
|
|
Driver: 'bridge',
|
|
Scope: 'local',
|
|
Labels: { 'com.docker.compose.project': 'other-stack' },
|
|
},
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData(['my-stack'], false);
|
|
|
|
expect(result[0].managedStatus).toBe('unmanaged');
|
|
expect(result[0].managedBy).toBeNull();
|
|
});
|
|
|
|
it('resolves container stack from compose project label', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{
|
|
Id: 'n1',
|
|
Name: 'my-stack_default',
|
|
Driver: 'bridge',
|
|
Scope: 'local',
|
|
Labels: { 'com.docker.compose.project': 'my-stack' },
|
|
},
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([
|
|
{
|
|
Id: 'c1',
|
|
Names: ['/my-svc'],
|
|
State: 'running',
|
|
Image: 'nginx',
|
|
Labels: { 'com.docker.compose.project': 'my-stack' },
|
|
NetworkSettings: {
|
|
Networks: { my_stack_default: { NetworkID: 'n1', IPAddress: '172.20.0.2' } },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData(['my-stack'], false);
|
|
|
|
expect(result[0].containers[0].stack).toBe('my-stack');
|
|
});
|
|
|
|
it('container stack is null when no compose label matches', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{ Id: 'n1', Name: 'custom', Driver: 'bridge', Scope: 'local' },
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([
|
|
{
|
|
Id: 'c1',
|
|
Names: ['/standalone'],
|
|
State: 'running',
|
|
Image: 'busybox',
|
|
Labels: {},
|
|
NetworkSettings: {
|
|
Networks: { custom: { NetworkID: 'n1', IPAddress: '172.25.0.2' } },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData([], false);
|
|
|
|
expect(result[0].containers[0].stack).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── Container deduplication ──────────────────────────────────────────
|
|
|
|
describe('DockerController.getTopologyData - container deduplication', () => {
|
|
it('container on multiple networks appears in each network container list', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{ Id: 'net-a', Name: 'frontend', Driver: 'bridge', Scope: 'local' },
|
|
{ Id: 'net-b', Name: 'backend', Driver: 'bridge', Scope: 'local' },
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([
|
|
{
|
|
Id: 'multi',
|
|
Names: ['/api-gateway'],
|
|
State: 'running',
|
|
Image: 'gateway:1.0',
|
|
Labels: {},
|
|
NetworkSettings: {
|
|
Networks: {
|
|
frontend: { NetworkID: 'net-a', IPAddress: '172.30.0.2' },
|
|
backend: { NetworkID: 'net-b', IPAddress: '172.31.0.2' },
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData([], false);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result.find(n => n.Id === 'net-a')!.containers).toHaveLength(1);
|
|
expect(result.find(n => n.Id === 'net-b')!.containers).toHaveLength(1);
|
|
});
|
|
|
|
it('container IP is network-specific', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{ Id: 'net-a', Name: 'frontend', Driver: 'bridge', Scope: 'local' },
|
|
{ Id: 'net-b', Name: 'backend', Driver: 'bridge', Scope: 'local' },
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([
|
|
{
|
|
Id: 'multi',
|
|
Names: ['/api-gateway'],
|
|
State: 'running',
|
|
Image: 'gateway:1.0',
|
|
Labels: {},
|
|
NetworkSettings: {
|
|
Networks: {
|
|
frontend: { NetworkID: 'net-a', IPAddress: '172.30.0.2' },
|
|
backend: { NetworkID: 'net-b', IPAddress: '172.31.0.2' },
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData([], false);
|
|
|
|
const frontendEntry = result.find(n => n.Id === 'net-a')!.containers[0];
|
|
const backendEntry = result.find(n => n.Id === 'net-b')!.containers[0];
|
|
expect(frontendEntry.ip).toBe('172.30.0.2');
|
|
expect(backendEntry.ip).toBe('172.31.0.2');
|
|
});
|
|
});
|
|
|
|
// ── Edge cases ────────────────────────────────────────────────────────
|
|
|
|
describe('DockerController.getTopologyData - edge cases', () => {
|
|
it('handles containers with missing NetworkSettings', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{ Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' },
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([
|
|
{ Id: 'c1', Names: ['/orphan'], State: 'running', Image: 'busybox', Labels: {} },
|
|
]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData([], false);
|
|
|
|
expect(result[0].containers).toEqual([]);
|
|
});
|
|
|
|
it('handles containers with empty Networks object', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{ Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' },
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([
|
|
{
|
|
Id: 'c1',
|
|
Names: ['/orphan'],
|
|
State: 'running',
|
|
Image: 'busybox',
|
|
Labels: {},
|
|
NetworkSettings: { Networks: {} },
|
|
},
|
|
]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData([], false);
|
|
|
|
expect(result[0].containers).toEqual([]);
|
|
});
|
|
|
|
it('skips network entries without NetworkID', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{ Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' },
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([
|
|
{
|
|
Id: 'c1',
|
|
Names: ['/svc'],
|
|
State: 'running',
|
|
Image: 'busybox',
|
|
Labels: {},
|
|
NetworkSettings: {
|
|
Networks: {
|
|
'broken-net': { NetworkID: undefined, IPAddress: '10.0.0.1' },
|
|
'user-net': { NetworkID: 'n1', IPAddress: '172.20.0.2' },
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData([], false);
|
|
|
|
expect(result[0].containers).toHaveLength(1);
|
|
expect(result[0].containers[0].ip).toBe('172.20.0.2');
|
|
});
|
|
|
|
it('falls back to truncated container ID when Names is empty', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{ Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' },
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([
|
|
{
|
|
Id: 'abc123def4567890abcdef1234567890',
|
|
Names: [],
|
|
State: 'running',
|
|
Image: 'busybox',
|
|
Labels: {},
|
|
NetworkSettings: {
|
|
Networks: { 'user-net': { NetworkID: 'n1', IPAddress: '172.20.0.2' } },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData([], false);
|
|
|
|
expect(result[0].containers[0].name).toBe('abc123def456');
|
|
});
|
|
|
|
it('falls back to truncated container ID when Names is undefined', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{ Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' },
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([
|
|
{
|
|
Id: 'deadbeef00112233445566778899aabb',
|
|
State: 'running',
|
|
Image: 'busybox',
|
|
Labels: {},
|
|
NetworkSettings: {
|
|
Networks: { 'user-net': { NetworkID: 'n1', IPAddress: '172.20.0.2' } },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData([], false);
|
|
|
|
expect(result[0].containers[0].name).toBe('deadbeef0011');
|
|
});
|
|
|
|
it('defaults Driver to bridge when absent from network data', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{ Id: 'n1', Name: 'user-net', Scope: 'local' },
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData([], false);
|
|
|
|
expect(result[0].Driver).toBe('bridge');
|
|
});
|
|
|
|
it('defaults State to unknown when absent from container data', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{ Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' },
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([
|
|
{
|
|
Id: 'c1',
|
|
Names: ['/svc'],
|
|
Image: 'busybox',
|
|
Labels: {},
|
|
NetworkSettings: {
|
|
Networks: { 'user-net': { NetworkID: 'n1', IPAddress: '172.20.0.2' } },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData([], false);
|
|
|
|
expect(result[0].containers[0].state).toBe('unknown');
|
|
});
|
|
|
|
it('defaults Scope to local when absent from network data', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([
|
|
{ Id: 'n1', Name: 'user-net', Driver: 'bridge' },
|
|
]);
|
|
mockDocker.listContainers.mockResolvedValue([]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
const result = await dc.getTopologyData([], false);
|
|
|
|
expect(result[0].Scope).toBe('local');
|
|
});
|
|
});
|
|
|
|
// ── Error handling ────────────────────────────────────────────────────
|
|
|
|
describe('DockerController.getTopologyData - error handling', () => {
|
|
it('propagates Docker API errors from listNetworks', async () => {
|
|
mockDocker.listNetworks.mockRejectedValue(new Error('Cannot connect to Docker daemon'));
|
|
mockDocker.listContainers.mockResolvedValue([]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
await expect(dc.getTopologyData([], false)).rejects.toThrow('Cannot connect to Docker daemon');
|
|
});
|
|
|
|
it('propagates Docker API errors from listContainers', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([]);
|
|
mockDocker.listContainers.mockRejectedValue(new Error('Docker daemon unavailable'));
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
await expect(dc.getTopologyData([], false)).rejects.toThrow('Docker daemon unavailable');
|
|
});
|
|
|
|
it('throws when Docker returns HTML string for networks (wrong port)', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue('<html>Not Docker</html>');
|
|
mockDocker.listContainers.mockResolvedValue([]);
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
await expect(dc.getTopologyData([], false)).rejects.toThrow('Invalid response from Docker API');
|
|
});
|
|
|
|
it('throws when Docker returns HTML string for containers (wrong port)', async () => {
|
|
mockDocker.listNetworks.mockResolvedValue([]);
|
|
mockDocker.listContainers.mockResolvedValue('<html>Not Docker</html>');
|
|
|
|
const dc = DockerController.getInstance(1);
|
|
await expect(dc.getTopologyData([], false)).rejects.toThrow('Invalid response from Docker API');
|
|
});
|
|
});
|