mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
f30a65ee08
Show each updated image and tag transition in the Anatomy banner. Uses the per-image data the update-preview API already returns.
213 lines
8.2 KiB
TypeScript
213 lines
8.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { assembleAnatomyInput, parseAnatomy, parseEnvKeys, formatGitSource, imageName, primaryPublishedHostPort } from './anatomy';
|
|
|
|
const COMPOSE = `services:
|
|
plex:
|
|
image: plexinc/pms-docker
|
|
restart: unless-stopped
|
|
ports:
|
|
- "32400:32400"
|
|
- "1900:1900/udp"
|
|
volumes:
|
|
- ./config:/config
|
|
env_file: .env
|
|
networks:
|
|
- media
|
|
networks:
|
|
media:
|
|
`;
|
|
|
|
describe('parseAnatomy', () => {
|
|
it('extracts services, ports, volumes, restart, env file, and networks', () => {
|
|
const a = parseAnatomy(COMPOSE)!;
|
|
expect(a.services).toEqual(['plex']);
|
|
expect(a.ports.plex).toEqual([
|
|
{ host: '32400', container: '32400', proto: 'tcp', published: true },
|
|
{ host: '1900', container: '1900', proto: 'udp', published: true },
|
|
]);
|
|
expect(a.volumes.plex).toEqual([{ host: './config', container: '/config' }]);
|
|
expect(a.restart).toBe('unless-stopped');
|
|
expect(a.envFiles).toEqual(['.env']);
|
|
expect(a.networks).toContain('media');
|
|
});
|
|
|
|
it('returns null for empty or unparseable compose', () => {
|
|
expect(parseAnatomy('')).toBeNull();
|
|
expect(parseAnatomy(' ')).toBeNull();
|
|
expect(parseAnatomy('::: not yaml :::\n - [')).toBeNull();
|
|
});
|
|
|
|
it('parses the 3-part bind-IP port form, picking host and container', () => {
|
|
const a = parseAnatomy('services:\n web:\n image: x\n ports:\n - "127.0.0.1:8080:80"\n')!;
|
|
expect(a.ports.web).toEqual([{ host: '8080', container: '80', proto: 'tcp', published: true }]);
|
|
});
|
|
|
|
it('parses long-syntax object ports and volumes', () => {
|
|
const a = parseAnatomy(
|
|
'services:\n app:\n image: x\n ports:\n - target: 80\n published: 8080\n protocol: udp\n volumes:\n - type: bind\n source: ./data\n target: /data\n',
|
|
)!;
|
|
expect(a.ports.app).toEqual([{ host: '8080', container: '80', proto: 'udp', published: true }]);
|
|
expect(a.volumes.app).toEqual([{ host: './data', container: '/data' }]);
|
|
});
|
|
|
|
it('treats ${VAR:-default} as satisfied but ${VAR} as referenced', () => {
|
|
const a = parseAnatomy('services:\n app:\n image: x\n environment:\n - A=${NEEDED}\n - B=${HAS:-fallback}\n')!;
|
|
expect(a.referencedVars).toContain('NEEDED');
|
|
expect(a.referencedVars).not.toContain('HAS');
|
|
});
|
|
|
|
it('skips the $${ESCAPED} literal (Compose escape)', () => {
|
|
const a = parseAnatomy('services:\n app:\n image: x\n labels:\n - template=$${ESCAPED}\n')!;
|
|
expect(a.referencedVars).not.toContain('ESCAPED');
|
|
});
|
|
|
|
it('skips $$ escaped vars but still catches real interpolations', () => {
|
|
const a = parseAnatomy('services:\n app:\n image: x\n environment:\n - A=${REAL}\n - B=$${ESCAPED}\n')!;
|
|
expect(a.referencedVars).toContain('REAL');
|
|
expect(a.referencedVars).not.toContain('ESCAPED');
|
|
});
|
|
|
|
it('marks container-only short-form ports as not published', () => {
|
|
const a = parseAnatomy('services:\n web:\n image: x\n ports:\n - "80"\n')!;
|
|
expect(a.ports.web).toEqual([{ host: '80', container: '80', proto: 'tcp', published: false }]);
|
|
});
|
|
});
|
|
|
|
describe('imageName', () => {
|
|
it('strips a plain image tag', () => {
|
|
expect(imageName('nginx:1.25')).toBe('nginx');
|
|
});
|
|
|
|
it('keeps registry host and repository path', () => {
|
|
expect(imageName('ghcr.io/karakeep-app/karakeep:release')).toBe('ghcr.io/karakeep-app/karakeep');
|
|
});
|
|
|
|
it('treats only the colon after the last slash as a tag separator', () => {
|
|
expect(imageName('registry:5000/app:1.2')).toBe('registry:5000/app');
|
|
});
|
|
|
|
it('strips a digest suffix', () => {
|
|
expect(imageName('nginx:1.25@sha256:abc123')).toBe('nginx');
|
|
});
|
|
});
|
|
|
|
describe('primaryPublishedHostPort', () => {
|
|
const portsFrom = (ports: string) =>
|
|
parseAnatomy(`services:\n web:\n image: x\n ports:\n${ports}`)!.ports;
|
|
|
|
it('returns the first published TCP host port as a number', () => {
|
|
expect(primaryPublishedHostPort(portsFrom(' - "8443:443"\n'))).toBe(8443);
|
|
});
|
|
|
|
it('picks the host port from the 3-part bind-IP form', () => {
|
|
expect(primaryPublishedHostPort(portsFrom(' - "127.0.0.1:8080:80"\n'))).toBe(8080);
|
|
});
|
|
|
|
it('returns null for a container-only short-form port', () => {
|
|
expect(primaryPublishedHostPort(portsFrom(' - "80"\n'))).toBeNull();
|
|
});
|
|
|
|
it('returns null for an unresolved variable port', () => {
|
|
expect(primaryPublishedHostPort(portsFrom(' - "${PORT}:80"\n'))).toBeNull();
|
|
});
|
|
|
|
it('returns null for a port range', () => {
|
|
expect(primaryPublishedHostPort(portsFrom(' - "8000-8002:8000-8002"\n'))).toBeNull();
|
|
});
|
|
|
|
it('returns null for an out-of-range host port', () => {
|
|
expect(primaryPublishedHostPort(portsFrom(' - "70000:80"\n'))).toBeNull();
|
|
});
|
|
|
|
it('returns null for a UDP published port (short and long form)', () => {
|
|
expect(primaryPublishedHostPort(portsFrom(' - "5353:53/udp"\n'))).toBeNull();
|
|
const longUdp = parseAnatomy(
|
|
'services:\n web:\n image: x\n ports:\n - target: 53\n published: 5353\n protocol: udp\n',
|
|
)!.ports;
|
|
expect(primaryPublishedHostPort(longUdp)).toBeNull();
|
|
});
|
|
|
|
it('skips a non-published row and returns the next published TCP port', () => {
|
|
expect(primaryPublishedHostPort(portsFrom(' - "80"\n - "8443:443"\n'))).toBe(8443);
|
|
});
|
|
|
|
it('returns null when there are no ports', () => {
|
|
expect(primaryPublishedHostPort({})).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('parseEnvKeys', () => {
|
|
it('collects keys and ignores comments and blank lines', () => {
|
|
const keys = parseEnvKeys('# comment\nFOO=1\n\nBAR=2\n=novalue\n');
|
|
expect(keys.has('FOO')).toBe(true);
|
|
expect(keys.has('BAR')).toBe(true);
|
|
expect(keys.size).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('formatGitSource', () => {
|
|
it('formats a URL into host/repo#branch', () => {
|
|
expect(formatGitSource({ repo_url: 'https://github.com/acme/stack.git', branch: 'main' }))
|
|
.toBe('github.com/acme/stack#main');
|
|
});
|
|
|
|
it('falls back to repo#branch for a non-URL', () => {
|
|
expect(formatGitSource({ repo_url: 'git@host:repo', branch: 'dev' })).toBe('git@host:repo#dev');
|
|
});
|
|
});
|
|
|
|
describe('assembleAnatomyInput', () => {
|
|
it('builds the markdown input, computing env count and missing vars', () => {
|
|
const input = assembleAnatomyInput({
|
|
stackName: 'plex',
|
|
content: 'services:\n plex:\n image: x\n env_file: .env\n environment:\n - TOKEN=${TOKEN}\n',
|
|
envContent: 'OTHER=1\n',
|
|
selectedEnvFile: '.env',
|
|
gitSource: { repo_url: 'https://github.com/acme/plex.git', branch: 'main' },
|
|
})!;
|
|
expect(input.stackName).toBe('plex');
|
|
expect(input.envFile).toBe('.env');
|
|
expect(input.envVarCount).toBe(1);
|
|
expect(input.missingVars).toEqual(['TOKEN']);
|
|
expect(input.gitSource).toBe('github.com/acme/plex#main');
|
|
});
|
|
|
|
it('excludes $$ escaped vars from missingVars while real refs still appear', () => {
|
|
const input = assembleAnatomyInput({
|
|
stackName: 'test',
|
|
content: 'services:\n app:\n image: x\n labels:\n - template=$${ESCAPED}\n environment:\n - TOKEN=${REAL}\n',
|
|
envContent: '',
|
|
selectedEnvFile: null,
|
|
gitSource: null,
|
|
})!;
|
|
expect(input.missingVars).toContain('REAL');
|
|
expect(input.missingVars).not.toContain('ESCAPED');
|
|
});
|
|
|
|
it('returns null when compose cannot be parsed', () => {
|
|
expect(assembleAnatomyInput({
|
|
stackName: 's', content: '', envContent: '', selectedEnvFile: null, gitSource: null,
|
|
})).toBeNull();
|
|
});
|
|
|
|
it('falls back to <stack>_default when no network is declared', () => {
|
|
const input = assembleAnatomyInput({
|
|
stackName: 'web', content: 'services:\n web:\n image: x\n', envContent: '', selectedEnvFile: null, gitSource: null,
|
|
})!;
|
|
expect(input.networkName).toBe('web_default');
|
|
});
|
|
|
|
it('never carries .env values, only key names and counts', () => {
|
|
const input = assembleAnatomyInput({
|
|
stackName: 's',
|
|
content: 'services:\n s:\n image: x\n env_file: .env\n',
|
|
envContent: 'SECRET=hunter2\nAPI_KEY=abc\n',
|
|
selectedEnvFile: '.env',
|
|
gitSource: null,
|
|
})!;
|
|
expect(input.envVarCount).toBe(2);
|
|
expect(JSON.stringify(input)).not.toContain('hunter2');
|
|
expect(JSON.stringify(input)).not.toContain('abc');
|
|
});
|
|
});
|