/** * Unit tests for DockerController — validateApiData, state-safe container ops, * disk usage, classified resources, orphan detection, and error paths. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // ── Hoisted mocks ────────────────────────────────────────────────────── const { mockDocker, composeDirRef, mockExecFileAsync } = 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, composeDirRef: { current: '/test/compose' }, mockExecFileAsync: vi.fn(), }; }); vi.mock('../services/NodeRegistry', () => ({ NodeRegistry: { getInstance: () => ({ getDocker: () => mockDocker, getDefaultNodeId: () => 1, getComposeDir: () => composeDirRef.current, }), }, })); vi.mock('../services/DatabaseService', () => ({ DatabaseService: { getInstance: () => ({ getGitSource: () => undefined, getStackProjectEnvFiles: () => [], }), }, })); // Prevent COMPOSE_DIR related issues vi.mock('child_process', () => ({ exec: vi.fn(), execFile: vi.fn(), })); vi.mock('util', () => ({ promisify: () => mockExecFileAsync, })); import DockerController, { selectMainWebPort, parseExitCode, isContainerFailed } from '../services/DockerController'; import { CacheService } from '../services/CacheService'; import { StackUpdateRecoveryService } from '../services/StackUpdateRecoveryService'; import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; import fs from 'fs/promises'; import os from 'os'; import path from 'path'; beforeEach(() => { vi.clearAllMocks(); composeDirRef.current = '/test/compose'; mockExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' }); }); // ── validateApiData ──────────────────────────────────────────────────── describe('DockerController - validateApiData', () => { it('throws when response is a string (HTML from wrong port)', async () => { mockDocker.listImages.mockResolvedValue('Not Docker'); const dc = DockerController.getInstance(1); await expect(dc.getImages()).rejects.toThrow('Invalid response from Docker API'); }); it('passes through valid object data', async () => { const imageData = [{ Id: 'sha256:abc', RepoTags: ['nginx:latest'], Size: 100 }]; mockDocker.listImages.mockResolvedValue(imageData); const dc = DockerController.getInstance(1); const result = await dc.getImages(); expect(result).toEqual(imageData); }); }); // ── State-safe container operations ──────────────────────────────────── describe('DockerController - startContainer', () => { it('starts a container successfully', async () => { const mockStart = vi.fn().mockResolvedValue(undefined); mockDocker.getContainer.mockReturnValue({ start: mockStart }); const dc = DockerController.getInstance(1); await dc.startContainer('abc123'); expect(mockStart).toHaveBeenCalled(); }); it('silently ignores 304 already-started error', async () => { const mockStart = vi.fn().mockRejectedValue({ statusCode: 304 }); mockDocker.getContainer.mockReturnValue({ start: mockStart }); const dc = DockerController.getInstance(1); await expect(dc.startContainer('abc123')).resolves.toBeUndefined(); }); it('rethrows other errors', async () => { const mockStart = vi.fn().mockRejectedValue(new Error('container not found')); mockDocker.getContainer.mockReturnValue({ start: mockStart }); const dc = DockerController.getInstance(1); await expect(dc.startContainer('abc123')).rejects.toThrow('container not found'); }); }); describe('DockerController - stopContainer', () => { it('stops a container successfully', async () => { const mockStop = vi.fn().mockResolvedValue(undefined); mockDocker.getContainer.mockReturnValue({ stop: mockStop }); const dc = DockerController.getInstance(1); await dc.stopContainer('abc123'); expect(mockStop).toHaveBeenCalled(); }); it('silently ignores 304 already-stopped error', async () => { const mockStop = vi.fn().mockRejectedValue({ statusCode: 304 }); mockDocker.getContainer.mockReturnValue({ stop: mockStop }); const dc = DockerController.getInstance(1); await expect(dc.stopContainer('abc123')).resolves.toBeUndefined(); }); it('rethrows other errors', async () => { const err = new Error('permission denied'); const mockStop = vi.fn().mockRejectedValue(err); mockDocker.getContainer.mockReturnValue({ stop: mockStop }); const dc = DockerController.getInstance(1); await expect(dc.stopContainer('abc123')).rejects.toThrow('permission denied'); }); }); // ── removeContainers ─────────────────────────────────────────────────── describe('DockerController - removeContainers', () => { it('removes multiple containers and returns results', async () => { const mockRemove = vi.fn().mockResolvedValue(undefined); mockDocker.getContainer.mockReturnValue({ remove: mockRemove }); const dc = DockerController.getInstance(1); const results = await dc.removeContainers(['c1', 'c2']); expect(results).toEqual([ { id: 'c1', success: true }, { id: 'c2', success: true }, ]); }); it('returns failure result for containers that cannot be removed', async () => { let callCount = 0; mockDocker.getContainer.mockImplementation(() => ({ remove: vi.fn().mockImplementation(() => { callCount++; if (callCount === 2) throw new Error('in use'); return Promise.resolve(); }), })); const dc = DockerController.getInstance(1); const results = await dc.removeContainers(['c1', 'c2']); expect(results[0]).toEqual({ id: 'c1', success: true }); expect(results[1]).toMatchObject({ id: 'c2', success: false, error: 'in use' }); }); }); // ── getDiskUsage ─────────────────────────────────────────────────────── describe('DockerController - getDiskUsage', () => { it('calculates reclaimable space correctly', async () => { mockDocker.df.mockResolvedValue({ LayersSize: 800, // total image-layer bytes on disk Images: [ { Id: 'img1', Containers: 0, Size: 500, SharedSize: 0 }, // reclaimable (unused) { Id: 'img2', Containers: 1, Size: 300, SharedSize: 0 }, // not reclaimable (in use); used = 300 ], Containers: [ { State: 'running', SizeRw: 100 }, // not reclaimable (running) { State: 'exited', SizeRw: 200 }, // reclaimable (stopped) ], Volumes: [ { UsageData: { RefCount: 0, Size: 400 } }, // reclaimable (unused) { UsageData: { RefCount: 1, Size: 300 } }, // not reclaimable (in use) ], BuildCache: [ { ID: 'bc1', InUse: false, Size: 600 }, // reclaimable { ID: 'bc2', InUse: true, Size: 250 }, // not reclaimable ], }); const dc = DockerController.getInstance(1); const usage = await dc.getDiskUsage(); // 800 LayersSize - 300 used-by-in-use-image = 500 reclaimable expect(usage.reclaimableImages).toBe(500); expect(usage.reclaimableImageCount).toBe(1); expect(usage.reclaimableContainers).toBe(200); expect(usage.reclaimableVolumes).toBe(400); expect(usage.reclaimableBuildCache).toBe(600); expect(usage.reclaimableBuildCacheCount).toBe(1); }); it('uses daemon-reported ImageUsage.Reclaimable when present (API v1.44+)', async () => { // Modern Docker daemons compute Reclaimable server-side and ship the exact // value `docker system df` displays. Trust it over any client-side fallback. mockDocker.df.mockResolvedValue({ LayersSize: 10_000_000_000, ImageUsage: { Reclaimable: 7_837_305_085, TotalSize: 10_000_000_000, ActiveCount: 11, TotalCount: 33 }, Images: [ { Id: 'a', Containers: 0, Size: 5_000_000_000, SharedSize: 0 }, // a fallback formula would say 5G { Id: 'b', Containers: 1, Size: 3_000_000_000, SharedSize: 0 }, ], Containers: [], Volumes: [], }); const dc = DockerController.getInstance(1); const usage = await dc.getDiskUsage(); expect(usage.reclaimableImages).toBe(7_837_305_085); expect(usage.reclaimableImageCount).toBe(1); }); it('does not double-count shared layers across unused images', async () => { // Three images sharing a 400MB base layer: two unused (800MB virtual each), // one in-use (600MB virtual). Total on-disk: 1GB. The in-use image holds // its unique 200MB; pruning the two unused frees the remaining 800MB. // The previous formula summed VirtualSize and would have returned 1.6GB // (impossibly larger than LayersSize). mockDocker.df.mockResolvedValue({ LayersSize: 1_000_000_000, Images: [ { Id: 'a', Containers: 0, VirtualSize: 800_000_000, SharedSize: 400_000_000 }, { Id: 'b', Containers: 0, VirtualSize: 800_000_000, SharedSize: 400_000_000 }, { Id: 'c', Containers: 1, VirtualSize: 600_000_000, SharedSize: 400_000_000 }, ], Containers: [], Volumes: [], }); const dc = DockerController.getInstance(1); const usage = await dc.getDiskUsage(); expect(usage.reclaimableImages).toBe(800_000_000); expect(usage.reclaimableImageCount).toBe(2); }); it('skips active images only when no usable size is available', async () => { // Truly unaccountable image: both VirtualSize and Size are -1 / missing. // Skipping leaks at most one image's worth of bytes into the reclaim // total, but modern daemons never return this shape; the prior "always // skip on -1" path moved any image with SharedSize=-1 into the leak set. mockDocker.df.mockResolvedValue({ LayersSize: 1000, Images: [ { Id: 'known', Containers: 1, VirtualSize: 400, SharedSize: 100 }, { Id: 'truly-lost', Containers: 1, VirtualSize: -1, Size: -1 }, ], Containers: [], Volumes: [], }); const dc = DockerController.getInstance(1); const usage = await dc.getDiskUsage(); // truly-lost is unaccountable and skipped; used = 400 - 100 = 300 expect(usage.reclaimableImages).toBe(700); expect(usage.reclaimableImageCount).toBe(0); }); it('treats SharedSize=-1 conservatively (full Size counts as used)', async () => { // Older daemons may report SharedSize as -1 (unknown) while Size is // accurate. Treating SharedSize as 0 in that case under-reports // reclaimable, which is the safe direction; the prior skip-on-(-1) // path made the image's full Size look reclaimable. mockDocker.df.mockResolvedValue({ LayersSize: 1000, Images: [ { Id: 'modern', Containers: 1, Size: 400, SharedSize: 100 }, { Id: 'no-shared-info', Containers: 1, Size: 300, SharedSize: -1 }, ], Containers: [], Volumes: [], }); const dc = DockerController.getInstance(1); const usage = await dc.getDiskUsage(); // modern: used += 400 - 100 = 300 // no-shared-info: shared treated as 0, used += 300 - 0 = 300; total = 600 // (the old buggy formula skipped no-shared-info, leaving used=300 and // reclaimable=700 — i.e. the in-use 300 bytes looked reclaimable) expect(usage.reclaimableImages).toBe(400); }); it('handles empty arrays gracefully', async () => { mockDocker.df.mockResolvedValue({ Images: [], Containers: [], Volumes: [], }); const dc = DockerController.getInstance(1); const usage = await dc.getDiskUsage(); expect(usage.reclaimableImages).toBe(0); expect(usage.reclaimableContainers).toBe(0); expect(usage.reclaimableVolumes).toBe(0); }); it('handles missing fields gracefully', async () => { mockDocker.df.mockResolvedValue({}); const dc = DockerController.getInstance(1); const usage = await dc.getDiskUsage(); expect(usage.reclaimableImages).toBe(0); expect(usage.reclaimableContainers).toBe(0); expect(usage.reclaimableVolumes).toBe(0); }); it('counts only prune-eligible container states (created/exited/dead)', async () => { // `docker container prune` removes stopped containers (created/exited/dead). // Paused and restarting containers survive it, so counting them would leave // a residue the banner can never clear no matter which prune runs. mockDocker.df.mockResolvedValue({ Images: [], Volumes: [], Containers: [ { State: 'exited', SizeRw: 200 }, // prunable { State: 'created', SizeRw: 50 }, // prunable { State: 'dead', SizeRw: 25 }, // prunable { State: 'paused', SizeRw: 1000 }, // survives prune { State: 'restarting', SizeRw: 1000 }, // survives prune { State: 'running', SizeRw: 1000 }, // in use ], }); const dc = DockerController.getInstance(1); const usage = await dc.getDiskUsage(); expect(usage.reclaimableContainers).toBe(275); expect(usage.reclaimableContainerCount).toBe(3); }); it('sizes stopped containers by the writable layer (SizeRw), not SizeRootFs', async () => { // SizeRootFs includes the read-only image layers, which removing a // container never frees. A stopped container that wrote nothing reclaims 0. mockDocker.df.mockResolvedValue({ Images: [], Volumes: [], Containers: [ { State: 'exited', SizeRw: 0, SizeRootFs: 500_000_000 }, // wrote nothing { State: 'exited', SizeRw: 1_500, SizeRootFs: 900_000 }, // writable layer only ], }); const dc = DockerController.getInstance(1); const usage = await dc.getDiskUsage(); expect(usage.reclaimableContainers).toBe(1_500); expect(usage.reclaimableContainerCount).toBe(2); }); }); // ── pruneSystem ──────────────────────────────────────────────────────── describe('DockerController - pruneSystem', () => { it('prunes containers and returns reclaimed bytes', async () => { mockDocker.pruneContainers.mockResolvedValue({ SpaceReclaimed: 1024 }); const dc = DockerController.getInstance(1); const result = await dc.pruneSystem('containers'); expect(result).toEqual({ success: true, reclaimedBytes: 1024 }); }); it('prunes images with dangling false filter', async () => { mockDocker.pruneImages.mockResolvedValue({ SpaceReclaimed: 2048 }); const dc = DockerController.getInstance(1); await dc.pruneSystem('images'); expect(mockDocker.pruneImages).toHaveBeenCalledWith({ filters: expect.objectContaining({ dangling: { 'false': true } }), }); }); it('includes label filter when provided', async () => { mockDocker.pruneContainers.mockResolvedValue({ SpaceReclaimed: 0 }); const dc = DockerController.getInstance(1); await dc.pruneSystem('containers', 'com.example=true'); expect(mockDocker.pruneContainers).toHaveBeenCalledWith({ filters: { label: ['com.example=true'] }, }); }); it('prunes volumes with all true filter', async () => { mockDocker.pruneVolumes.mockResolvedValue({ SpaceReclaimed: 4096 }); const dc = DockerController.getInstance(1); await dc.pruneSystem('volumes'); expect(mockDocker.pruneVolumes).toHaveBeenCalledWith({ filters: { all: ['true'] }, }); }); }); // ── pruneDanglingImages ──────────────────────────────────────────────── describe('DockerController - pruneDanglingImages', () => { it('prunes only dangling images and returns reclaimed bytes', async () => { mockDocker.pruneImages.mockResolvedValue({ SpaceReclaimed: 5000 }); const dc = DockerController.getInstance(1); const result = await dc.pruneDanglingImages(); // dangling:true keeps the prune to untagged layers, unlike pruneSystem('images') // which uses dangling:false to remove every unused image. expect(mockDocker.pruneImages).toHaveBeenCalledWith({ filters: { dangling: { 'true': true } }, }); expect(result).toEqual({ success: true, reclaimedBytes: 5000 }); }); }); // ── Prune holds (): images held for a pending service-update // recovery must never be deleted by any image-prune path. ────────────── describe('DockerController - prune holds (isImageHeld)', () => { it('pruneSystem("images") falls back to enumerate-and-delete when isImageHeld is given, skipping the held image', async () => { mockDocker.listImages.mockResolvedValue([ { Id: 'img-held', RepoTags: ['app:1'], Containers: 0 }, { Id: 'img-free', RepoTags: ['app:2'], Containers: 0 }, ]); mockDocker.df .mockResolvedValueOnce({ LayersSize: 5000 }) .mockResolvedValueOnce({ LayersSize: 3000 }); const removeFn = vi.fn().mockResolvedValue(undefined); mockDocker.getImage.mockReturnValue({ remove: removeFn }); const dc = DockerController.getInstance(1); const result = await dc.pruneSystem('images', undefined, (id: string) => id === 'img-held'); expect(mockDocker.pruneImages).not.toHaveBeenCalled(); expect(removeFn).toHaveBeenCalledTimes(1); expect(mockDocker.getImage).toHaveBeenCalledWith('img-free'); expect(result).toEqual({ success: true, reclaimedBytes: 2000 }); }); it('pruneDanglingImages falls back to enumerate-and-delete when isImageHeld is given, skipping the held image', async () => { mockDocker.listImages.mockResolvedValue([ { Id: 'img-held', RepoTags: [], Containers: 0 }, { Id: 'img-free', RepoTags: [':'], Containers: 0 }, { Id: 'img-tagged', RepoTags: ['app:1'], Containers: 0 }, // not dangling; excluded regardless of hold ]); mockDocker.df .mockResolvedValueOnce({ LayersSize: 5000 }) .mockResolvedValueOnce({ LayersSize: 4000 }); const removeFn = vi.fn().mockResolvedValue(undefined); mockDocker.getImage.mockReturnValue({ remove: removeFn }); const dc = DockerController.getInstance(1); const result = await dc.pruneDanglingImages((id: string) => id === 'img-held'); expect(mockDocker.pruneImages).not.toHaveBeenCalled(); expect(removeFn).toHaveBeenCalledTimes(1); expect(mockDocker.getImage).toHaveBeenCalledWith('img-free'); expect(result).toEqual({ success: true, reclaimedBytes: 1000 }); }); it('pruneManagedOnly("images") re-checks isImageHeld immediately before each delete', async () => { mockDocker.df .mockResolvedValueOnce({ LayersSize: 5000, Images: [] }) .mockResolvedValueOnce({ LayersSize: 4000, Images: [] }); mockDocker.listImages.mockResolvedValue([ { Id: 'img-held', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, { Id: 'img-free', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, ]); mockDocker.listContainers.mockResolvedValue([]); const removeFn = vi.fn().mockResolvedValue(undefined); mockDocker.getImage.mockReturnValue({ remove: removeFn }); const dc = DockerController.getInstance(1); const result = await dc.pruneManagedOnly('images', ['any-stack'], (id: string) => id === 'img-held'); expect(removeFn).toHaveBeenCalledTimes(1); expect(mockDocker.getImage).toHaveBeenCalledWith('img-free'); expect(result.success).toBe(true); }); it('pruneSystem("images") keeps the bulk Docker prune API when no isImageHeld predicate is given', async () => { mockDocker.pruneImages.mockResolvedValue({ SpaceReclaimed: 999 }); const dc = DockerController.getInstance(1); const result = await dc.pruneSystem('images'); expect(mockDocker.pruneImages).toHaveBeenCalled(); expect(mockDocker.listImages).not.toHaveBeenCalled(); expect(result).toEqual({ success: true, reclaimedBytes: 999 }); }); }); // ── getClassifiedResources ───────────────────────────────────────────── describe('DockerController - getClassifiedResources', () => { beforeEach(() => { CacheService.getInstance().invalidate('project-name-map'); }); afterEach(() => { vi.restoreAllMocks(); }); it('classifies managed and unmanaged images', async () => { mockDocker.listImages.mockResolvedValue([ { Id: 'img1', RepoTags: ['nginx:latest'], Size: 100, Containers: 1 }, { Id: 'img2', RepoTags: ['redis:latest'], Size: 200, Containers: 1 }, { Id: 'img3', RepoTags: ['old:v1'], Size: 50, Containers: 0 }, ]); mockDocker.listContainers.mockResolvedValue([ { ImageID: 'img1', Labels: { 'com.docker.compose.project': 'my-stack' } }, { ImageID: 'img2', Labels: { 'com.docker.compose.project': 'unknown-stack' } }, ]); mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); mockDocker.listNetworks.mockResolvedValue([]); const dc = DockerController.getInstance(1); const result = await dc.getClassifiedResources(['my-stack']); const managed = result.images.find(i => i.Id === 'img1'); expect(managed!.managedStatus).toBe('managed'); expect(managed!.managedBy).toBe('my-stack'); expect(managed!.usedByStacks).toEqual(['my-stack']); const unmanaged = result.images.find(i => i.Id === 'img2'); expect(unmanaged!.managedStatus).toBe('unmanaged'); expect(unmanaged!.usedByStacks).toEqual([]); const unused = result.images.find(i => i.Id === 'img3'); expect(unused!.managedStatus).toBe('unused'); expect(unused!.usedByStacks).toEqual([]); }); it('aggregates multi-stack usedByStacks with stable sort and managedBy first entry', async () => { mockDocker.listImages.mockResolvedValue([ { Id: 'img-shared', RepoTags: ['postgres:16'], Size: 100, Containers: 2 }, ]); mockDocker.listContainers.mockResolvedValue([ { ImageID: 'img-shared', Labels: { 'com.docker.compose.project': 'zeta' } }, { ImageID: 'img-shared', Labels: { 'com.docker.compose.project': 'alpha' } }, { ImageID: 'img-shared', Labels: { 'com.docker.compose.project': 'alpha' } }, ]); mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); mockDocker.listNetworks.mockResolvedValue([]); const dc = DockerController.getInstance(1); const result = await dc.getClassifiedResources(['alpha', 'zeta']); const img = result.images.find(i => i.Id === 'img-shared'); expect(img!.usedByStacks).toEqual(['alpha', 'zeta']); expect(img!.managedBy).toBe('alpha'); expect(img!.managedStatus).toBe('managed'); }); it('classifies system networks', async () => { mockDocker.listImages.mockResolvedValue([]); mockDocker.listContainers.mockResolvedValue([]); mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); mockDocker.listNetworks.mockResolvedValue([ { Id: 'n1', Name: 'bridge', Driver: 'bridge', Scope: 'local' }, { Id: 'n2', Name: 'host', Driver: 'host', Scope: 'local' }, { Id: 'n3', Name: 'none', Driver: 'null', Scope: 'local' }, { Id: 'n4', Name: 'my-stack_default', Driver: 'bridge', Scope: 'local', Labels: { 'com.docker.compose.project': 'my-stack' } }, ]); const dc = DockerController.getInstance(1); const result = await dc.getClassifiedResources(['my-stack']); expect(result.networks.filter(n => n.managedStatus === 'system')).toHaveLength(3); expect(result.networks.find(n => n.Name === 'my-stack_default')!.managedStatus).toBe('managed'); }); it('classifies managed and unmanaged volumes', async () => { mockDocker.listImages.mockResolvedValue([]); mockDocker.listContainers.mockResolvedValue([]); mockDocker.listNetworks.mockResolvedValue([]); mockDocker.listVolumes.mockResolvedValue({ Volumes: [ { Name: 'my-stack_data', Driver: 'local', Mountpoint: '/var/lib/docker/volumes/my-stack_data', Labels: { 'com.docker.compose.project': 'my-stack' } }, { Name: 'random_vol', Driver: 'local', Mountpoint: '/var/lib/docker/volumes/random', Labels: {} }, ], }); const dc = DockerController.getInstance(1); const result = await dc.getClassifiedResources(['my-stack']); expect(result.volumes.find(v => v.Name === 'my-stack_data')!.managedStatus).toBe('managed'); expect(result.volumes.find(v => v.Name === 'random_vol')!.managedStatus).toBe('unmanaged'); }); it('excludes an image whose only tag is a synthetic sencho-rb rollback hold', async () => { mockDocker.listImages.mockResolvedValue([ { Id: 'img-hold-only', RepoTags: ['sencho-rb/abc123456789/web:hold'], Size: 50, Containers: 0 }, { Id: 'img-normal', RepoTags: ['nginx:latest'], Size: 100, Containers: 0 }, ]); mockDocker.listContainers.mockResolvedValue([]); mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); mockDocker.listNetworks.mockResolvedValue([]); const dc = DockerController.getInstance(1); const result = await dc.getClassifiedResources(['my-stack']); expect(result.images.find(i => i.Id === 'img-hold-only')).toBeUndefined(); expect(result.images.find(i => i.Id === 'img-normal')).toBeDefined(); }); it('keeps an image visible when it carries both a normal tag and a sencho-rb hold tag', async () => { mockDocker.listImages.mockResolvedValue([ { Id: 'img-multi-tag', RepoTags: ['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold'], Size: 100, Containers: 0 }, ]); mockDocker.listContainers.mockResolvedValue([]); mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); mockDocker.listNetworks.mockResolvedValue([]); const dc = DockerController.getInstance(1); const result = await dc.getClassifiedResources(['my-stack']); const img = result.images.find(i => i.Id === 'img-multi-tag'); expect(img).toBeDefined(); expect(img!.RepoTags).toEqual(['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold']); }); it('marks an image rollbackProtected with kind "stack" when StackUpdateRecoveryService holds it', async () => { mockDocker.listImages.mockResolvedValue([ { Id: 'img-stack-held', RepoTags: ['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold'], Size: 100, Containers: 0 }, ]); mockDocker.listContainers.mockResolvedValue([]); mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); mockDocker.listNetworks.mockResolvedValue([]); vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['img-stack-held'])); vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); const dc = DockerController.getInstance(1); const result = await dc.getClassifiedResources(['my-stack']); const img = result.images.find(i => i.Id === 'img-stack-held'); expect(img?.rollbackProtected).toBe(true); expect(img?.rollbackProtectionKind).toBe('stack'); }); it('marks an image rollbackProtected with kind "service" when only ServiceUpdateRecoveryService holds it', async () => { mockDocker.listImages.mockResolvedValue([ { Id: 'img-service-held', RepoTags: ['myregistry/app:1.4'], Size: 100, Containers: 0 }, ]); mockDocker.listContainers.mockResolvedValue([]); mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); mockDocker.listNetworks.mockResolvedValue([]); vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['img-service-held'])); const dc = DockerController.getInstance(1); const result = await dc.getClassifiedResources(['my-stack']); const img = result.images.find(i => i.Id === 'img-service-held'); expect(img?.rollbackProtected).toBe(true); expect(img?.rollbackProtectionKind).toBe('service'); }); it('fails closed (marks every image rollbackProtected) when a held-image lookup fails', async () => { mockDocker.listImages.mockResolvedValue([ { Id: 'img-unrelated', RepoTags: ['myregistry/app:1.4'], Size: 100, Containers: 0 }, ]); mockDocker.listContainers.mockResolvedValue([]); mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); mockDocker.listNetworks.mockResolvedValue([]); // getHeldImageIds returns null when its own DB lookup fails (already logs // internally); the badge must fail the same direction as the delete guard // (recoveryHeldImages.ts), not the opposite. vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null); vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); const dc = DockerController.getInstance(1); const result = await dc.getClassifiedResources(['my-stack']); const img = result.images.find(i => i.Id === 'img-unrelated'); expect(img?.rollbackProtected).toBe(true); }); }); // ── pruneManagedOnly / estimateManagedReclaim (images) ───────────────── describe('DockerController - managed image prune accounting', () => { // Two unused images each report a 1000-byte rolled-up Size, but 400 of those // bytes live in a layer shared with a third (in-use) image, so removing // the unused pair only frees the 600 unique bytes from each. The buggy // sum-of-Size accounting would have reported 2000; the fix should report // 1200 (600 × 2). const sharedLayerDfMock = { Images: [ { Id: 'img-a', SharedSize: 400 }, { Id: 'img-b', SharedSize: 400 }, { Id: 'img-c', SharedSize: 400 }, ], }; const unusedManagedListMock = [ { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, { Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, { Id: 'img-c', Containers: 1, Size: 800 }, // in-use; filtered by Containers===0 ]; it('estimateManagedReclaim subtracts SharedSize per prunable image', async () => { mockDocker.df.mockResolvedValue(sharedLayerDfMock); mockDocker.listImages.mockResolvedValue(unusedManagedListMock); mockDocker.listContainers.mockResolvedValue([]); const dc = DockerController.getInstance(1); const result = await dc.estimateManagedReclaim('images', ['any-stack']); expect(result.reclaimableBytes).toBe(1200); }); it('pruneManagedOnly reports the LayersSize delta as the reclaimed total', async () => { // df-before reports 5000 bytes on disk; df-after reports 3400. The // honest reclaim is 1600, independent of per-image Size/SharedSize. // Two prunable images that share a layer exclusively would be // undercounted by the per-image formula (1200) but Docker actually // frees the shared layer too. mockDocker.df .mockResolvedValueOnce({ LayersSize: 5000, Images: [ { Id: 'img-a', SharedSize: 400 }, { Id: 'img-b', SharedSize: 400 }, ], }) .mockResolvedValueOnce({ LayersSize: 3400, Images: [] }); mockDocker.listImages.mockResolvedValue([ { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, { Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, ]); mockDocker.listContainers.mockResolvedValue([]); const removeFn = vi.fn().mockResolvedValue(undefined); mockDocker.getImage.mockReturnValue({ remove: removeFn }); const dc = DockerController.getInstance(1); const result = await dc.pruneManagedOnly('images', ['any-stack']); expect(result).toEqual({ success: true, reclaimedBytes: 1600 }); expect(removeFn).toHaveBeenCalledTimes(2); }); it('pruneManagedOnly falls back to per-image lower bound using before-snapshot when after-df fails', async () => { // Before-snapshot succeeded (SharedSize known); after-snapshot failed. // Fall back to Σ(Size - SharedSize) over the prunable set: a // conservative lower bound, but defensible because each pruned image's // SharedSize was known at the start. mockDocker.df .mockResolvedValueOnce({ Images: [ { Id: 'img-a', SharedSize: 400 }, { Id: 'img-b', SharedSize: 400 }, ], }) .mockRejectedValueOnce(new Error('df unavailable after prune')); mockDocker.listImages.mockResolvedValue([ { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, { Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, ]); mockDocker.listContainers.mockResolvedValue([]); mockDocker.getImage.mockReturnValue({ remove: vi.fn().mockResolvedValue(undefined) }); const dc = DockerController.getInstance(1); const result = await dc.pruneManagedOnly('images', ['any-stack']); // (1000 - 400) + (1000 - 400) = 1200 expect(result).toEqual({ success: true, reclaimedBytes: 1200 }); }); it('pruneManagedOnly reports 0 reclaimed when both df snapshots fail', async () => { // Without the before-snapshot we cannot build a meaningful per-image // bound (after-only has stale image IDs missing from its view), so the // destructive path completes the removes and reports 0 rather than a // misleading approximation. mockDocker.df .mockRejectedValueOnce(new Error('df unavailable before')) .mockRejectedValueOnce(new Error('df unavailable after')); mockDocker.listImages.mockResolvedValue([ { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, ]); mockDocker.listContainers.mockResolvedValue([]); const removeFn = vi.fn().mockResolvedValue(undefined); mockDocker.getImage.mockReturnValue({ remove: removeFn }); const dc = DockerController.getInstance(1); const result = await dc.pruneManagedOnly('images', ['any-stack']); expect(result).toEqual({ success: true, reclaimedBytes: 0 }); expect(removeFn).toHaveBeenCalledTimes(1); }); it('estimateManagedReclaim under-reports when layers are shared exclusively between prunable images', async () => { // Two prunable images that share a 400-byte layer with no retained // image. Docker would free that layer once on prune (1600 bytes total: // each image's unique 600 plus the shared 400). The estimate formula // subtracts 400 from each image and reports 1200, a conservative // lower bound documented in the JSDoc. mockDocker.df.mockResolvedValue({ Images: [ { Id: 'img-a', SharedSize: 400 }, { Id: 'img-b', SharedSize: 400 }, ], }); mockDocker.listImages.mockResolvedValue([ { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, { Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, ]); mockDocker.listContainers.mockResolvedValue([]); const dc = DockerController.getInstance(1); const result = await dc.estimateManagedReclaim('images', ['any-stack']); expect(result.reclaimableBytes).toBe(1200); }); it('invariant: pruneManagedOnly reports >= estimateManagedReclaim on the same inputs', async () => { // The estimate is a conservative lower bound; the destructive path // reports the truth (df-delta). On any input, destructive >= estimate. // This locks the contract so future changes to either formula cannot // accidentally flip the direction. const dfBefore = { LayersSize: 5000, Images: [ { Id: 'img-a', SharedSize: 400 }, { Id: 'img-b', SharedSize: 400 }, ], }; const dfAfter = { LayersSize: 3400, Images: [] }; const listImages = [ { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, { Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, ]; // Estimate path: df called once. mockDocker.df.mockResolvedValueOnce(dfBefore); mockDocker.listImages.mockResolvedValue(listImages); mockDocker.listContainers.mockResolvedValue([]); const dc = DockerController.getInstance(1); const estimate = await dc.estimateManagedReclaim('images', ['any-stack']); // Reset the df mock for the destructive path: before then after. mockDocker.df.mockReset(); mockDocker.df.mockResolvedValueOnce(dfBefore).mockResolvedValueOnce(dfAfter); mockDocker.getImage.mockReturnValue({ remove: vi.fn().mockResolvedValue(undefined) }); const prune = await dc.pruneManagedOnly('images', ['any-stack']); expect(prune.reclaimedBytes).toBeGreaterThanOrEqual(estimate.reclaimableBytes); }); it('treats images missing from the df map as having no shared layers', async () => { // df reports only img-a; img-b is omitted (stale / race / older daemon). // The accounting should still process img-b, defaulting its SharedSize to 0. mockDocker.df.mockResolvedValue({ Images: [{ Id: 'img-a', SharedSize: 400 }] }); mockDocker.listImages.mockResolvedValue([ { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, { Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, ]); mockDocker.listContainers.mockResolvedValue([]); const dc = DockerController.getInstance(1); const result = await dc.estimateManagedReclaim('images', ['any-stack']); // img-a: 1000-400=600; img-b: 1000-0=1000; total 1600 expect(result.reclaimableBytes).toBe(1600); }); it('degrades to no-sharing assumption when df fails', async () => { // If df throws, the helper returns an empty map and the accounting falls // back to full per-image Size. This preserves prior behavior (over-report) // rather than failing the prune. mockDocker.df.mockRejectedValue(new Error('daemon df failed')); mockDocker.listImages.mockResolvedValue([ { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, { Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } }, ]); mockDocker.listContainers.mockResolvedValue([]); const dc = DockerController.getInstance(1); const result = await dc.estimateManagedReclaim('images', ['any-stack']); expect(result.reclaimableBytes).toBe(2000); }); }); // ── getOrphanContainers ──────────────────────────────────────────────── describe('DockerController - getOrphanContainers', () => { it('returns containers whose project label is not in known stacks', async () => { mockDocker.listContainers.mockResolvedValue([ { Id: 'c1', Names: ['/c1'], State: 'running', Status: 'Up', Image: 'nginx', Labels: { 'com.docker.compose.project': 'orphan-stack' } }, { Id: 'c2', Names: ['/c2'], State: 'running', Status: 'Up', Image: 'redis', Labels: { 'com.docker.compose.project': 'known-stack' } }, ]); const dc = DockerController.getInstance(1); const result = await dc.getOrphanContainers(['known-stack']); expect(result['orphan-stack']).toHaveLength(1); expect(result['orphan-stack'][0].Id).toBe('c1'); expect(result['known-stack']).toBeUndefined(); }); it('returns empty when all containers belong to known stacks', async () => { mockDocker.listContainers.mockResolvedValue([ { Id: 'c1', Names: ['/c1'], State: 'running', Status: 'Up', Image: 'nginx', Labels: { 'com.docker.compose.project': 'my-stack' } }, ]); const dc = DockerController.getInstance(1); const result = await dc.getOrphanContainers(['my-stack']); expect(Object.keys(result)).toHaveLength(0); }); }); // ── Docker daemon unreachable ────────────────────────────────────────── describe('DockerController - error paths', () => { it('propagates connection errors from Docker daemon', async () => { mockDocker.listContainers.mockRejectedValue( new Error('connect ECONNREFUSED /var/run/docker.sock') ); const dc = DockerController.getInstance(1); await expect(dc.getOrphanContainers(['x'])).rejects.toThrow('ECONNREFUSED'); }); it('propagates errors from df() call', async () => { mockDocker.df.mockRejectedValue(new Error('daemon not running')); const dc = DockerController.getInstance(1); await expect(dc.getDiskUsage()).rejects.toThrow('daemon not running'); }); }); // ── inspectNetwork ──────────────────────────────────────────────────── describe('DockerController - inspectNetwork', () => { it('returns full network details from Docker API', async () => { const inspectData = { Id: 'abc123', Name: 'my-network', Driver: 'bridge', Scope: 'local', IPAM: { Config: [{ Subnet: '172.20.0.0/16', Gateway: '172.20.0.1' }] }, Containers: { 'ctr1': { Name: 'web', IPv4Address: '172.20.0.2/16', MacAddress: '02:42:ac:14:00:02' }, }, }; mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectData) }); const dc = DockerController.getInstance(1); const result = await dc.inspectNetwork('abc123'); expect(result).toEqual(inspectData); expect(mockDocker.getNetwork).toHaveBeenCalledWith('abc123'); }); it('propagates errors when network not found', async () => { mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockRejectedValue(new Error('network not found')), }); const dc = DockerController.getInstance(1); await expect(dc.inspectNetwork('nonexistent')).rejects.toThrow('network not found'); }); }); // ── createNetwork ───────────────────────────────────────────────────── describe('DockerController - createNetwork', () => { it('creates a network with valid options', async () => { mockDocker.createNetwork.mockResolvedValue({ id: 'new-net-123' }); const dc = DockerController.getInstance(1); const result = await dc.createNetwork({ Name: 'test-network', Driver: 'bridge' }); expect(result).toEqual({ id: 'new-net-123' }); expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'test-network', Driver: 'bridge' }); }); it('rejects empty network name', async () => { const dc = DockerController.getInstance(1); await expect(dc.createNetwork({ Name: '' })).rejects.toThrow('Invalid network name'); }); it('rejects network name with invalid characters', async () => { const dc = DockerController.getInstance(1); await expect(dc.createNetwork({ Name: '../escape' })).rejects.toThrow('Invalid network name'); }); it('accepts names with hyphens, underscores, and dots', async () => { mockDocker.createNetwork.mockResolvedValue({ id: 'ok-123' }); const dc = DockerController.getInstance(1); await dc.createNetwork({ Name: 'my-net_v2.0' }); expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'my-net_v2.0' }); }); it('propagates Docker daemon errors', async () => { mockDocker.createNetwork.mockRejectedValue(new Error('network with name test already exists')); const dc = DockerController.getInstance(1); await expect(dc.createNetwork({ Name: 'test' })).rejects.toThrow('already exists'); }); it('rejects names starting with a dot', async () => { const dc = DockerController.getInstance(1); await expect(dc.createNetwork({ Name: '.hidden' })).rejects.toThrow('Invalid network name'); }); it('rejects names starting with a hyphen', async () => { const dc = DockerController.getInstance(1); await expect(dc.createNetwork({ Name: '-leading-dash' })).rejects.toThrow('Invalid network name'); }); it('rejects names with spaces', async () => { const dc = DockerController.getInstance(1); await expect(dc.createNetwork({ Name: 'has spaces' })).rejects.toThrow('Invalid network name'); }); it('rejects names with slashes (path traversal)', async () => { const dc = DockerController.getInstance(1); await expect(dc.createNetwork({ Name: 'foo/bar' })).rejects.toThrow('Invalid network name'); }); it('passes IPAM config through to Docker', async () => { mockDocker.createNetwork.mockResolvedValue({ id: 'ipam-net' }); const dc = DockerController.getInstance(1); const options = { Name: 'ipam-test', Driver: 'bridge' as const, IPAM: { Config: [{ Subnet: '10.0.0.0/24', Gateway: '10.0.0.1' }] }, Internal: true, Attachable: true, }; await dc.createNetwork(options); expect(mockDocker.createNetwork).toHaveBeenCalledWith(options); }); it('passes labels through to Docker', async () => { mockDocker.createNetwork.mockResolvedValue({ id: 'labeled-net' }); const dc = DockerController.getInstance(1); const options = { Name: 'labeled', Labels: { env: 'test', team: 'infra' }, }; await dc.createNetwork(options); expect(mockDocker.createNetwork).toHaveBeenCalledWith(options); }); it('accepts single-character name', async () => { mockDocker.createNetwork.mockResolvedValue({ id: 'single' }); const dc = DockerController.getInstance(1); await dc.createNetwork({ Name: 'a' }); expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'a' }); }); }); // ── inspectNetwork edge cases ───────────────────────────────────────── describe('DockerController - inspectNetwork edge cases', () => { it('returns network with empty containers map', async () => { const inspectData = { Id: 'empty-net', Name: 'isolated', Driver: 'bridge', Containers: {}, }; mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectData) }); const dc = DockerController.getInstance(1); const result = await dc.inspectNetwork('empty-net'); expect(result.Containers).toEqual({}); }); it('returns network with multiple containers', async () => { const inspectData = { Id: 'multi-net', Name: 'busy-network', Driver: 'bridge', Containers: { 'c1': { Name: 'web', IPv4Address: '172.20.0.2/16' }, 'c2': { Name: 'db', IPv4Address: '172.20.0.3/16' }, 'c3': { Name: 'cache', IPv4Address: '172.20.0.4/16' }, }, }; mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectData) }); const dc = DockerController.getInstance(1); const result = await dc.inspectNetwork('multi-net'); expect(Object.keys(result.Containers ?? {})).toHaveLength(3); }); it('propagates Docker daemon connection errors', async () => { mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockRejectedValue(new Error('Cannot connect to Docker daemon')), }); const dc = DockerController.getInstance(1); await expect(dc.inspectNetwork('any-id')).rejects.toThrow('Cannot connect to Docker daemon'); }); }); // --- inspectImage -------------------------------------------------------------- describe('DockerController - inspectImage', () => { it('returns combined inspect + history payload', async () => { const inspectData = { Id: 'sha256:abc12345', RepoTags: ['nginx:1.27'], Size: 187_000_000, Architecture: 'amd64', Os: 'linux', Config: { Cmd: ['nginx', '-g', 'daemon off;'] }, }; const historyData = [ { Id: 'layer1', Created: 1700000000, CreatedBy: '/bin/sh -c #(nop) ADD file:abc', Size: 72_000_000, Tags: null, Comment: '' }, { Id: 'layer2', Created: 1700000010, CreatedBy: 'ENV NGINX_VERSION=1.27', Size: 0, Tags: null, Comment: '' }, ]; mockDocker.getImage.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectData), history: vi.fn().mockResolvedValue(historyData), }); const dc = DockerController.getInstance(1); const result = await dc.inspectImage('sha256:abc12345'); expect(result.inspect).toEqual(inspectData); expect(result.history).toEqual(historyData); expect(mockDocker.getImage).toHaveBeenCalledWith('sha256:abc12345'); }); it('propagates 404 from Dockerode when image is missing', async () => { mockDocker.getImage.mockReturnValue({ inspect: vi.fn().mockRejectedValue(Object.assign(new Error('No such image: missing'), { statusCode: 404 })), history: vi.fn().mockResolvedValue([]), }); const dc = DockerController.getInstance(1); await expect(dc.inspectImage('missing')).rejects.toThrow('No such image'); }); it('returns empty history when an image has none', async () => { mockDocker.getImage.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ Id: 'sha256:empty', Size: 0 }), history: vi.fn().mockResolvedValue([]), }); const dc = DockerController.getInstance(1); const result = await dc.inspectImage('sha256:empty'); expect(result.history).toEqual([]); expect(result.inspect.Id).toBe('sha256:empty'); }); }); // --- resolveImageId -------------------------------------------------------------- describe('DockerController - resolveImageId', () => { it('returns the canonical full Id from docker.getImage(id).inspect()', async () => { mockDocker.getImage.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ Id: 'sha256:' + 'a'.repeat(64) }), }); const dc = DockerController.getInstance(1); const result = await dc.resolveImageId('a'.repeat(12)); expect(result).toBe('sha256:' + 'a'.repeat(64)); expect(mockDocker.getImage).toHaveBeenCalledWith('a'.repeat(12)); }); it('returns null on a 404 from Docker', async () => { mockDocker.getImage.mockReturnValue({ inspect: vi.fn().mockRejectedValue(Object.assign(new Error('No such image'), { statusCode: 404 })), }); const dc = DockerController.getInstance(1); expect(await dc.resolveImageId('missing')).toBeNull(); }); it('rethrows a non-404 Docker error', async () => { mockDocker.getImage.mockReturnValue({ inspect: vi.fn().mockRejectedValue(Object.assign(new Error('docker daemon unreachable'), { statusCode: 500 })), }); const dc = DockerController.getInstance(1); await expect(dc.resolveImageId('sha256:abc')).rejects.toThrow('docker daemon unreachable'); }); }); // --- label / image inspection for the label inventory -------------------------- describe('DockerController - inspectImageLabels', () => { it('returns the image label map', async () => { mockDocker.getImage.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'org.opencontainers.image.title': 'Plex' } } }), }); const dc = DockerController.getInstance(1); const result = await dc.inspectImageLabels('sha256:img'); expect(result).toEqual({ labels: { 'org.opencontainers.image.title': 'Plex' } }); expect(mockDocker.getImage).toHaveBeenCalledWith('sha256:img'); }); it('returns null (not a silent empty map) and logs when the image inspect fails', async () => { mockDocker.getImage.mockReturnValue({ inspect: vi.fn().mockRejectedValue(new Error('No such image')), }); const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const dc = DockerController.getInstance(1); expect(await dc.inspectImageLabels('missing')).toBeNull(); expect(errSpy).toHaveBeenCalled(); errSpy.mockRestore(); }); it('short-circuits an empty image id without inspecting', async () => { const dc = DockerController.getInstance(1); expect(await dc.inspectImageLabels('')).toBeNull(); expect(mockDocker.getImage).not.toHaveBeenCalled(); }); }); describe('DockerController - inspectContainerLabelsAndImage', () => { it('returns labels and the image ref from container inspect', async () => { mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'traefik.enable': 'true' } }, Image: 'sha256:imgref' }), }); const dc = DockerController.getInstance(1); const result = await dc.inspectContainerLabelsAndImage('c1'); expect(result).toEqual({ labels: { 'traefik.enable': 'true' }, imageId: 'sha256:imgref' }); }); it('returns null and logs when the container inspect fails', async () => { mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockRejectedValue(new Error('no such container')), }); const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const dc = DockerController.getInstance(1); expect(await dc.inspectContainerLabelsAndImage('gone')).toBeNull(); expect(errSpy).toHaveBeenCalled(); errSpy.mockRestore(); }); }); // --- createNetwork validation -------------------------------------------------- describe('createNetwork', () => { it('rejects empty network name', async () => { const dc = DockerController.getInstance(1); await expect(dc.createNetwork({ Name: '' })).rejects.toThrow('Invalid network name'); }); it('rejects names with invalid characters', async () => { const dc = DockerController.getInstance(1); await expect(dc.createNetwork({ Name: 'net work' })).rejects.toThrow('Invalid network name'); await expect(dc.createNetwork({ Name: '../escape' })).rejects.toThrow('Invalid network name'); await expect(dc.createNetwork({ Name: 'net;rm' })).rejects.toThrow('Invalid network name'); }); it('accepts valid network names and passes through to Docker', async () => { mockDocker.createNetwork.mockResolvedValue({ id: 'new-net-id' }); const dc = DockerController.getInstance(1); const result = await dc.createNetwork({ Name: 'my-network_v2' }); expect(result.id).toBe('new-net-id'); expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'my-network_v2' }); }); }); // --- removeContainers mixed results ------------------------------------------- describe('removeContainers', () => { it('returns mixed results when some removals fail', async () => { mockDocker.getContainer.mockImplementation((id: string) => { if (id === 'fail-id') { return { remove: vi.fn().mockRejectedValue(new Error('no such container')) }; } return { remove: vi.fn().mockResolvedValue(undefined) }; }); const dc = DockerController.getInstance(1); const results = await dc.removeContainers(['ok-id-000000', 'fail-id']); expect(results).toHaveLength(2); expect(results[0]).toEqual({ id: 'ok-id-000000', success: true }); expect(results[1]).toMatchObject({ id: 'fail-id', success: false }); }); it('returns empty array for empty input', async () => { const dc = DockerController.getInstance(1); const results = await dc.removeContainers([]); expect(results).toEqual([]); }); }); // ── Network connect / disconnect helpers ─────────────────────────────── describe('DockerController - connectContainerToNetwork', () => { it('attaches a container to a network with no static IP', async () => { const connect = vi.fn().mockResolvedValue(undefined); mockDocker.getNetwork.mockReturnValue({ connect }); const dc = DockerController.getInstance(1); await dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234'); expect(mockDocker.getNetwork).toHaveBeenCalledWith('sencho_mesh'); expect(connect).toHaveBeenCalledWith({ Container: 'sencho-host-1234' }); }); it('attaches with a static IPv4 address when provided', async () => { const connect = vi.fn().mockResolvedValue(undefined); mockDocker.getNetwork.mockReturnValue({ connect }); const dc = DockerController.getInstance(1); await dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234', { ipv4Address: '172.30.0.2' }); expect(connect).toHaveBeenCalledWith({ Container: 'sencho-host-1234', EndpointConfig: { IPAMConfig: { IPv4Address: '172.30.0.2' } }, }); }); it('treats 403 already-connected as success (idempotent)', async () => { const connect = vi.fn().mockRejectedValue({ statusCode: 403, message: 'endpoint already exists' }); mockDocker.getNetwork.mockReturnValue({ connect }); const dc = DockerController.getInstance(1); await expect(dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234')).resolves.toBeUndefined(); }); it('rethrows non-idempotent errors', async () => { const connect = vi.fn().mockRejectedValue({ statusCode: 500, message: 'server error' }); mockDocker.getNetwork.mockReturnValue({ connect }); const dc = DockerController.getInstance(1); await expect(dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234')).rejects.toMatchObject({ statusCode: 500, }); }); it('rethrows a 403 whose message is unrelated to already-attached', async () => { const connect = vi.fn().mockRejectedValue({ statusCode: 403, message: 'host-mode container cannot join network' }); mockDocker.getNetwork.mockReturnValue({ connect }); const dc = DockerController.getInstance(1); await expect(dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234')).rejects.toMatchObject({ statusCode: 403, }); }); }); describe('DockerController - disconnectContainerFromNetwork', () => { it('detaches a container from a network with force=true', async () => { const disconnect = vi.fn().mockResolvedValue(undefined); mockDocker.getNetwork.mockReturnValue({ disconnect }); const dc = DockerController.getInstance(1); await dc.disconnectContainerFromNetwork('sencho_mesh', 'sencho-host-1234'); expect(mockDocker.getNetwork).toHaveBeenCalledWith('sencho_mesh'); expect(disconnect).toHaveBeenCalledWith({ Container: 'sencho-host-1234', Force: true }); }); it('treats 404 not-connected as success (idempotent)', async () => { const disconnect = vi.fn().mockRejectedValue({ statusCode: 404, message: 'no such network endpoint' }); mockDocker.getNetwork.mockReturnValue({ disconnect }); const dc = DockerController.getInstance(1); await expect(dc.disconnectContainerFromNetwork('sencho_mesh', 'sencho-host-1234')).resolves.toBeUndefined(); }); it('rethrows non-idempotent errors', async () => { const disconnect = vi.fn().mockRejectedValue({ statusCode: 500, message: 'server error' }); mockDocker.getNetwork.mockReturnValue({ disconnect }); const dc = DockerController.getInstance(1); await expect(dc.disconnectContainerFromNetwork('sencho_mesh', 'sencho-host-1234')).rejects.toMatchObject({ statusCode: 500, }); }); }); // ── getDependencySnapshot ────────────────────────────────────────────── describe('DockerController - getDependencySnapshot', () => { beforeEach(() => { // resolveProjectNameMap caches under a constant key; clear it so each test // resolves stack ownership from its own mocked compose set. CacheService.getInstance().invalidate('project-name-map'); }); it('maps service identity, networks, volume mounts, and published ports', async () => { mockDocker.listContainers.mockResolvedValue([ { Id: 'abc123def456', Names: ['/web-1'], Image: 'nginx:alpine', State: 'running', Labels: { 'com.docker.compose.project': 'web', 'com.docker.compose.service': 'api' }, NetworkSettings: { Networks: { web_frontend: { NetworkID: 'net1', IPAddress: '172.18.0.2' } } }, Mounts: [ { Type: 'volume', Name: 'web_data', Destination: '/data' }, { Type: 'bind', Source: '/host/path', Destination: '/app' }, ], Ports: [ { IP: '0.0.0.0', PrivatePort: 80, PublicPort: 8080, Type: 'tcp' }, { PrivatePort: 9090, Type: 'tcp' }, ], }, ]); mockDocker.listNetworks.mockResolvedValue([ { Id: 'b', Name: 'bridge' }, { Id: 'net1', Name: 'web_frontend', Driver: 'bridge', Scope: 'local', Labels: { 'com.docker.compose.project': 'web' } }, ]); mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: 'web_data', Driver: 'local', Labels: { 'com.docker.compose.project': 'web' } }] }); const dc = DockerController.getInstance(1); const snap = await dc.getDependencySnapshot(['web']); const c = snap.containers[0]; expect(c.service).toBe('api'); expect(c.stack).toBe('web'); expect(c.networks).toEqual([{ name: 'web_frontend', id: 'net1', ip: '172.18.0.2' }]); expect(c.volumes).toEqual(['web_data']); // bind mount dropped expect(c.ports).toEqual([{ ip: '0.0.0.0', publishedPort: 8080, privatePort: 80, protocol: 'tcp' }]); // unpublished 9090 dropped expect(c.exitCode).toBeNull(); expect(snap.networks.find((n) => n.name === 'bridge')?.isSystem).toBe(true); const frontend = snap.networks.find((n) => n.name === 'web_frontend'); expect(frontend?.isSystem).toBe(false); expect(frontend?.stack).toBe('web'); expect(snap.volumes[0]).toMatchObject({ name: 'web_data', stack: 'web', composeProject: 'web' }); }); it('classifies a non-compose container as having no service or stack', async () => { mockDocker.listContainers.mockResolvedValue([ { Id: 'x', Names: ['/manual'], Image: 'redis', State: 'running', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] }, ]); mockDocker.listNetworks.mockResolvedValue([]); mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); const dc = DockerController.getInstance(1); const snap = await dc.getDependencySnapshot([]); expect(snap.containers[0].service).toBeNull(); expect(snap.containers[0].stack).toBeNull(); expect(snap.containers[0].composeProject).toBeNull(); }); it('parses exitCode from list Status for exited containers', async () => { mockDocker.listContainers.mockResolvedValue([ { Id: 'a', Names: ['/job-0'], Image: 'busybox', State: 'exited', Status: 'Exited (0) 5 minutes ago', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] }, { Id: 'b', Names: ['/crash-0'], Image: 'busybox', State: 'exited', Status: 'Exited (137) 1 minute ago', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] }, { Id: 'c', Names: ['/up-0'], Image: 'busybox', State: 'running', Status: 'Up 3 hours', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] }, { Id: 'd', Names: ['/odd-0'], Image: 'busybox', State: 'exited', Status: 'Exited', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] }, ]); mockDocker.listNetworks.mockResolvedValue([]); mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); const snap = await DockerController.getInstance(1).getDependencySnapshot([]); expect(snap.containers.find(c => c.id === 'a')?.exitCode).toBe(0); expect(snap.containers.find(c => c.id === 'b')?.exitCode).toBe(137); expect(snap.containers.find(c => c.id === 'c')?.exitCode).toBeNull(); expect(snap.containers.find(c => c.id === 'd')?.exitCode).toBeNull(); }); }); // --- getBulkStackStatuses uptime (runningSince from StartedAt) ----------------- describe('DockerController - getBulkStackStatuses uptime', () => { beforeEach(() => { // resolveProjectNameMap caches under a single key; flush so each test's // stack names map cleanly to themselves (compose files do not exist here). CacheService.getInstance().flush(); }); const runningContainer = (id: string, project: string, created: number) => ({ Id: id, Names: [`/${id}`], State: 'running', Status: 'Up', Image: 'nginx', Created: created, Labels: { 'com.docker.compose.project': project }, }); it('uses StartedAt (not Created) for runningSince', async () => { const created = 1000; // ancient creation time const startedIso = '2026-06-09T12:00:00.000Z'; const startedUnix = Math.floor(Date.parse(startedIso) / 1000); mockDocker.listContainers.mockResolvedValue([runningContainer('sa-c1', 'sa-stack', created)]); mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ State: { StartedAt: startedIso } }), }); const dc = DockerController.getInstance(1); const result = await dc.getBulkStackStatuses(['sa-stack']); expect(result['sa-stack'].status).toBe('running'); expect(result['sa-stack'].runningSince).toBe(startedUnix); expect(result['sa-stack'].runningSince).not.toBe(created); }); it('picks the oldest StartedAt across running containers in a stack', async () => { const olderIso = '2026-06-09T10:00:00.000Z'; const newerIso = '2026-06-09T11:30:00.000Z'; const olderUnix = Math.floor(Date.parse(olderIso) / 1000); mockDocker.listContainers.mockResolvedValue([ runningContainer('ow-c1', 'ow-stack', 5000), runningContainer('ow-c2', 'ow-stack', 6000), ]); const byId: Record = { 'ow-c1': newerIso, 'ow-c2': olderIso }; mockDocker.getContainer.mockImplementation((id: string) => ({ inspect: vi.fn().mockResolvedValue({ State: { StartedAt: byId[id] } }), })); const dc = DockerController.getInstance(1); const result = await dc.getBulkStackStatuses(['ow-stack']); expect(result['ow-stack'].runningSince).toBe(olderUnix); }); it('falls back to Created when inspect fails', async () => { const created = 1234; mockDocker.listContainers.mockResolvedValue([runningContainer('fb-c1', 'fb-stack', created)]); mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockRejectedValue(new Error('inspect boom')), }); const dc = DockerController.getInstance(1); const result = await dc.getBulkStackStatuses(['fb-stack']); expect(result['fb-stack'].status).toBe('running'); expect(result['fb-stack'].runningSince).toBe(created); }); it('falls back to Created when StartedAt is the Docker zero-time', async () => { const created = 4321; mockDocker.listContainers.mockResolvedValue([runningContainer('zt-c1', 'zt-stack', created)]); mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '0001-01-01T00:00:00Z' } }), }); const dc = DockerController.getInstance(1); const result = await dc.getBulkStackStatuses(['zt-stack']); expect(result['zt-stack'].runningSince).toBe(created); }); it('derives uptime only from running containers, ignoring exited ones in the stack', async () => { const startedIso = '2026-06-09T08:00:00.000Z'; const startedUnix = Math.floor(Date.parse(startedIso) / 1000); mockDocker.listContainers.mockResolvedValue([ { ...runningContainer('me-run', 'me-stack', 7000) }, { Id: 'me-exit', Names: ['/me-exit'], State: 'exited', Status: 'Exited (0)', Image: 'nginx', Created: 100, Labels: { 'com.docker.compose.project': 'me-stack' } }, ]); mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ State: { StartedAt: startedIso } }), }); const dc = DockerController.getInstance(1); const result = await dc.getBulkStackStatuses(['me-stack']); expect(result['me-stack'].status).toBe('running'); // The exited container's ancient Created (100) must not drag uptime down. expect(result['me-stack'].runningSince).toBe(startedUnix); }); it('caches StartedAt so repeated calls do not re-inspect within the TTL', async () => { const startedIso = '2026-06-09T09:00:00.000Z'; mockDocker.listContainers.mockResolvedValue([runningContainer('ca-c1', 'ca-stack', 1000)]); const inspect = vi.fn().mockResolvedValue({ State: { StartedAt: startedIso } }); mockDocker.getContainer.mockReturnValue({ inspect }); // getInstance() hands out fresh instances per request, so a shared (static) // cache is what must dedupe the inspect across separate calls. const startedUnix = Math.floor(Date.parse(startedIso) / 1000); await DockerController.getInstance(1).getBulkStackStatuses(['ca-stack']); const second = await DockerController.getInstance(1).getBulkStackStatuses(['ca-stack']); expect(inspect).toHaveBeenCalledTimes(1); // The cache must be read, not merely populated: the second call still // resolves the real StartedAt rather than falling back to Created. expect(second['ca-stack'].runningSince).toBe(startedUnix); }); }); describe('DockerController - getBulkStackStatuses mainPort', () => { beforeEach(() => { CacheService.getInstance().flush(); }); const withPorts = (project: string, ports: { PrivatePort: number; PublicPort: number; Type?: string }[]) => ({ Id: `${project}-c1`, Names: [`/${project}-c1`], State: 'running', Status: 'Up', Image: 'nginx', Created: 1000, Labels: { 'com.docker.compose.project': project }, Ports: ports, }); it('does not set mainPort for a UDP-only published port', async () => { mockDocker.listContainers.mockResolvedValue([withPorts('udp-stack', [{ PrivatePort: 53, PublicPort: 5353, Type: 'udp' }])]); mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '2026-06-09T12:00:00.000Z' } }) }); const result = await DockerController.getInstance(1).getBulkStackStatuses(['udp-stack']); expect(result['udp-stack'].status).toBe('running'); expect(result['udp-stack'].mainPort).toBeUndefined(); }); it('selects a TCP web port as mainPort', async () => { mockDocker.listContainers.mockResolvedValue([withPorts('tcp-stack', [ { PrivatePort: 53, PublicPort: 5353, Type: 'udp' }, { PrivatePort: 80, PublicPort: 8080, Type: 'tcp' }, ])]); mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '2026-06-09T12:00:00.000Z' } }) }); const result = await DockerController.getInstance(1).getBulkStackStatuses(['tcp-stack']); expect(result['tcp-stack'].mainPort).toBe(8080); }); }); describe('selectMainWebPort', () => { it('prefers a known web-UI container port', () => { expect(selectMainWebPort([ { PrivatePort: 5432, PublicPort: 5432, Type: 'tcp' }, { PrivatePort: 8080, PublicPort: 18080, Type: 'tcp' }, ])).toBe(18080); }); it('skips UDP ports and picks the TCP one', () => { expect(selectMainWebPort([ { PrivatePort: 53, PublicPort: 5353, Type: 'udp' }, { PrivatePort: 80, PublicPort: 8080, Type: 'tcp' }, ])).toBe(8080); }); it('returns undefined for a UDP-only published port', () => { expect(selectMainWebPort([{ PrivatePort: 53, PublicPort: 5353, Type: 'udp' }])).toBeUndefined(); }); it('treats a missing protocol as TCP', () => { expect(selectMainWebPort([{ PrivatePort: 80, PublicPort: 8080 }])).toBe(8080); }); it('falls back to the first TCP port when none match the web-UI list', () => { expect(selectMainWebPort([{ PrivatePort: 12000, PublicPort: 12000, Type: 'tcp' }])).toBe(12000); }); it('returns undefined when there are no ports', () => { expect(selectMainWebPort([])).toBeUndefined(); }); }); describe('parseExitCode', () => { it('extracts the code from an exited Status string', () => { expect(parseExitCode('Exited (0) 5 minutes ago')).toBe(0); expect(parseExitCode('Exited (137) 2 minutes ago')).toBe(137); }); it('reads the code from a restarting Status string', () => { expect(parseExitCode('Restarting (1) 3 seconds ago')).toBe(1); }); it('returns null when no parenthesized code is present', () => { expect(parseExitCode('Up 3 hours')).toBeNull(); expect(parseExitCode('Up 2 hours (healthy)')).toBeNull(); expect(parseExitCode('Created')).toBeNull(); expect(parseExitCode(undefined)).toBeNull(); }); }); describe('isContainerFailed', () => { it('treats a dead container as failed', () => { expect(isContainerFailed('dead', 'Dead')).toBe(true); }); it('treats a crash-looping restart as failed but a clean restart as not', () => { expect(isContainerFailed('restarting', 'Restarting (1) 5 seconds ago')).toBe(true); expect(isContainerFailed('restarting', 'Restarting (0) 2 seconds ago')).toBe(false); }); it('treats a non-zero exit as failed and a clean exit as not failed', () => { expect(isContainerFailed('exited', 'Exited (137) 2 minutes ago')).toBe(true); expect(isContainerFailed('exited', 'Exited (0) 5 minutes ago')).toBe(false); }); it('treats an exited container with an unreadable code as failed', () => { expect(isContainerFailed('exited', 'Exited')).toBe(true); }); it('does not treat running, created, or paused containers as failed', () => { expect(isContainerFailed('running', 'Up 2 hours')).toBe(false); expect(isContainerFailed('created', 'Created')).toBe(false); expect(isContainerFailed('paused', 'Up 2 hours (Paused)')).toBe(false); }); }); describe('DockerController - getBulkStackStatuses partial status', () => { beforeEach(() => { CacheService.getInstance().flush(); }); const container = (id: string, project: string, state: string, status: string) => ({ Id: id, Names: [`/${id}`], State: state, Status: status, Image: 'nginx', Created: 1000, Labels: { 'com.docker.compose.project': project }, }); // Any running container triggers an inspect for uptime; stub a valid StartedAt. const stubInspect = () => { mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '2026-06-09T12:00:00.000Z' } }), }); }; it('keeps a stack UP when a one-shot container exits cleanly', async () => { stubInspect(); mockDocker.listContainers.mockResolvedValue([ container('clean-run', 'clean-stack', 'running', 'Up 2 hours'), container('clean-init', 'clean-stack', 'exited', 'Exited (0) 5 minutes ago'), ]); const result = await DockerController.getInstance(1).getBulkStackStatuses(['clean-stack']); expect(result['clean-stack'].status).toBe('running'); expect(result['clean-stack'].running).toBe(1); expect(result['clean-stack'].total).toBe(2); }); it('marks a stack partial when a container crashes alongside a running one', async () => { stubInspect(); mockDocker.listContainers.mockResolvedValue([ container('crash-run', 'crash-stack', 'running', 'Up 2 hours'), container('crash-exit', 'crash-stack', 'exited', 'Exited (137) 1 minute ago'), ]); const result = await DockerController.getInstance(1).getBulkStackStatuses(['crash-stack']); expect(result['crash-stack'].status).toBe('partial'); expect(result['crash-stack'].running).toBe(1); expect(result['crash-stack'].total).toBe(2); }); it('marks a stack partial for a dead or restart-looping container', async () => { stubInspect(); mockDocker.listContainers.mockResolvedValue([ container('d-run', 'dead-stack', 'running', 'Up 2 hours'), container('d-dead', 'dead-stack', 'dead', 'Dead'), container('r-run', 'restart-stack', 'running', 'Up 2 hours'), container('r-loop', 'restart-stack', 'restarting', 'Restarting (1) 5 seconds ago'), ]); const result = await DockerController.getInstance(1).getBulkStackStatuses(['dead-stack', 'restart-stack']); expect(result['dead-stack'].status).toBe('partial'); expect(result['restart-stack'].status).toBe('partial'); }); it('keeps a stack running when a sibling is paused rather than crashed', async () => { stubInspect(); mockDocker.listContainers.mockResolvedValue([ container('p-run', 'paused-stack', 'running', 'Up 2 hours'), container('p-paused', 'paused-stack', 'paused', 'Up 2 hours (Paused)'), ]); const result = await DockerController.getInstance(1).getBulkStackStatuses(['paused-stack']); expect(result['paused-stack'].status).toBe('running'); expect(result['paused-stack'].running).toBe(1); expect(result['paused-stack'].total).toBe(2); }); it('reports running when every container is up', async () => { stubInspect(); mockDocker.listContainers.mockResolvedValue([ container('all-1', 'all-stack', 'running', 'Up 2 hours'), container('all-2', 'all-stack', 'running', 'Up 1 hour'), ]); const result = await DockerController.getInstance(1).getBulkStackStatuses(['all-stack']); expect(result['all-stack'].status).toBe('running'); expect(result['all-stack'].running).toBe(2); expect(result['all-stack'].total).toBe(2); }); it('reports exited when no container is running, even if some crashed', async () => { mockDocker.listContainers.mockResolvedValue([ container('down-1', 'down-stack', 'exited', 'Exited (1) 3 minutes ago'), container('down-2', 'down-stack', 'exited', 'Exited (0) 3 minutes ago'), ]); const result = await DockerController.getInstance(1).getBulkStackStatuses(['down-stack']); expect(result['down-stack'].status).toBe('exited'); expect(result['down-stack'].running).toBe(0); expect(result['down-stack'].total).toBe(2); }); it('reports unknown for a stack with no containers', async () => { mockDocker.listContainers.mockResolvedValue([]); const result = await DockerController.getInstance(1).getBulkStackStatuses(['empty-stack']); expect(result['empty-stack'].status).toBe('unknown'); expect(result['empty-stack'].running).toBeUndefined(); expect(result['empty-stack'].total).toBeUndefined(); }); }); // ── getLegacyOrphanContainersByStack (#1565) ─────────────────────────── type OrphanDcSpies = { fetchComposePsContainers: (...a: unknown[]) => Promise; smartFallback: (...a: unknown[]) => Promise; }; function spyOrphanDc( dc: ReturnType, method: keyof OrphanDcSpies, ) { return vi.spyOn(dc as unknown as OrphanDcSpies, method); } describe('DockerController - getLegacyOrphanContainersByStack', () => { it('returns [] when compose ps already manages containers', async () => { const dc = DockerController.getInstance(1); const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([{ ID: 'managed-c1', Name: 'web-1' }]); const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }]); await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]); expect(fallbackSpy).not.toHaveBeenCalled(); fetchSpy.mockRestore(); fallbackSpy.mockRestore(); }); it('returns legacy orphan IDs when compose ps is empty', async () => { const dc = DockerController.getInstance(1); const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]); const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }, { Id: '' }, {}]); await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([{ Id: 'legacy-c1' }]); fetchSpy.mockRestore(); fallbackSpy.mockRestore(); }); it('returns [] when compose ps throws, even if fallback would match containers', async () => { const dc = DockerController.getInstance(1); const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockRejectedValue(new Error('compose ps failed')); const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-compose-c2' }]); await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]); // Must not consult name-matching fallback: those IDs may be healthy Compose runtimes. expect(fallbackSpy).not.toHaveBeenCalled(); fetchSpy.mockRestore(); fallbackSpy.mockRestore(); }); it('returns [] and skips fallback when compose ps stdout is unparseable', async () => { mockExecFileAsync.mockResolvedValue({ stdout: 'this is not json at all just random text { broken [', stderr: '', }); const dc = DockerController.getInstance(1); const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-c1' }]); await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]); expect(fallbackSpy).not.toHaveBeenCalled(); fallbackSpy.mockRestore(); }); }); describe('DockerController - classifyLegacyOrphansForUpdate', () => { async function classifyDc() { const { default: DockerController } = await import('../services/DockerController'); return DockerController.getInstance(1); } it('returns none when compose ps already manages containers', async () => { const dc = await classifyDc(); spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([{ ID: 'c1' }]); await expect(dc.classifyLegacyOrphansForUpdate('my-stack')).resolves.toEqual({ status: 'none' }); }); it('returns orphans when compose ps is empty and fallback finds legacy containers', async () => { const dc = await classifyDc(); spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]); spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }, { Id: 'legacy-c2' }]); await expect(dc.classifyLegacyOrphansForUpdate('my-stack')).resolves.toEqual({ status: 'orphans', ids: ['legacy-c1', 'legacy-c2'], }); }); it('returns classification_failed when compose ps throws, even if fallback would match containers', async () => { const dc = await classifyDc(); spyOrphanDc(dc, 'fetchComposePsContainers').mockRejectedValue(new Error('compose boom')); const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-compose-c1' }]); const result = await dc.classifyLegacyOrphansForUpdate('my-stack'); expect(result).toEqual({ status: 'classification_failed', error: 'compose boom' }); // Must not consult name-matching fallback: those IDs may be healthy Compose runtimes. expect(fallbackSpy).not.toHaveBeenCalled(); }); it('returns classification_failed when compose ps is empty and fallback throws', async () => { const dc = await classifyDc(); spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]); spyOrphanDc(dc, 'smartFallback').mockRejectedValue(new Error('fallback boom')); const result = await dc.classifyLegacyOrphansForUpdate('my-stack'); expect(result).toEqual({ status: 'classification_failed', error: 'fallback boom' }); }); it('returns classification_failed when compose ps stdout is unparseable', async () => { mockExecFileAsync.mockResolvedValue({ stdout: 'this is not json at all just random text { broken [', stderr: '', }); const dc = await classifyDc(); const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-c1' }]); const result = await dc.classifyLegacyOrphansForUpdate('my-stack'); expect(result.status).toBe('classification_failed'); expect(String((result as { error?: string }).error ?? '')).toMatch(/unparseable JSON/i); expect(fallbackSpy).not.toHaveBeenCalled(); }); }); describe('DockerController - smartFallback stack-dir evidence', () => { const composeYaml = `services:\n web:\n image: nginx:alpine\n`; let stackDir: string; let tmpRoot: string; function runningContainer(id: string, name: string, labels: Record = {}) { return { Id: id, Names: [name], Labels: labels, State: 'running', Status: 'Up', Ports: [] }; } async function orphansWithEmptyPs() { const dc = DockerController.getInstance(1); spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]); return dc.getLegacyOrphanContainersByStack('my-stack'); } beforeEach(async () => { tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-orphan-')); composeDirRef.current = tmpRoot; stackDir = path.join(tmpRoot, 'my-stack'); await fs.mkdir(stackDir); await fs.writeFile(path.join(stackDir, 'docker-compose.yml'), composeYaml); }); afterEach(async () => { composeDirRef.current = '/test/compose'; await fs.rm(tmpRoot, { recursive: true, force: true }); }); it('excludes name-matched containers without stack-dir evidence', async () => { mockDocker.listContainers.mockResolvedValue([ runningContainer('unrelated-web', '/web'), ]); await expect(orphansWithEmptyPs()).resolves.toEqual([]); }); it('includes name-matched containers whose working_dir equals the stack dir', async () => { mockDocker.listContainers.mockResolvedValue([ runningContainer('legacy-web', '/web', { 'com.docker.compose.project.working_dir': stackDir, }), ]); await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-web' }]); }); it('matches working_dir case-insensitively on win32', async () => { if (process.platform !== 'win32') return; mockDocker.listContainers.mockResolvedValue([ runningContainer('legacy-web-case', '/web', { 'com.docker.compose.project.working_dir': stackDir.toUpperCase(), }), ]); await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-web-case' }]); }); it('excludes name-matched containers whose working_dir points elsewhere', async () => { mockDocker.listContainers.mockResolvedValue([ runningContainer('other-stack-web', '/web', { 'com.docker.compose.project.working_dir': path.join(tmpRoot, 'other-stack'), }), ]); await expect(orphansWithEmptyPs()).resolves.toEqual([]); }); it('includes name-matched containers whose config_files path is under the stack dir', async () => { mockDocker.listContainers.mockResolvedValue([ runningContainer('legacy-cfg', '/my-stack-web-1', { 'com.docker.compose.project.config_files': path.join(stackDir, 'docker-compose.yml'), }), ]); await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-cfg' }]); }); it('surfaces listContainers failure as classification_failed on update', async () => { mockDocker.listContainers.mockRejectedValue(new Error('daemon unavailable')); const { default: DockerController } = await import('../services/DockerController'); const dc = DockerController.getInstance(1); spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]); const result = await dc.classifyLegacyOrphansForUpdate('my-stack'); expect(result).toEqual({ status: 'classification_failed', error: 'daemon unavailable' }); }); });