homeio
PublikFork dari doctor-io/homeio
configurator-mapper.test.ts
import { describe, expect, it } from "vitest";
import {
buildInstallPayloadFromClassic,
buildSettingsPayloadFromClassic,
buildInitialComposeDraft,
classicStateToCompose,
composeToClassicState,
createDefaultClassicState,
safeComposeToClassicState,
} from "@/modules/apps/components/configurator/configurator-mapper";
describe("configurator-mapper", () => {
it("maps compose draft to classic state", () => {
const composeDraft = `services:\n app:\n image: ghcr.io/example/app:latest\n ports:\n - \"8080:80\"\n volumes:\n - \"/data/app:/config\"\n environment:\n APP_URL: \"http://example.local:8080\"\n TZ: \"UTC\"\n network_mode: bridge\n restart: unless-stopped\n privileged: true\n cap_add:\n - NET_ADMIN\n hostname: example-app\n devices:\n - /dev/ttyUSB0:/dev/ttyUSB0\n command: [\"--config\",\"/config/settings.yaml\"]\n`;
const state = composeToClassicState({
composeDraft,
seed: {
title: "Example",
iconUrl: "",
fallbackPort: 8080,
},
});
expect(state.dockerImage).toBe("ghcr.io/example/app:latest");
expect(state.webUi.port).toBe("8080");
expect(state.ports[0]?.container).toBe("80");
expect(state.volumes[0]?.host).toBe("/data/app");
expect(state.envVars.find((entry) => entry.key === "TZ")?.value).toBe("UTC");
expect(state.restartPolicy).toBe("unless-stopped");
expect(state.privileged).toBe(true);
expect(state.capabilities).toContain("NET_ADMIN");
expect(state.devices[0]).toBe("/dev/ttyUSB0:/dev/ttyUSB0");
expect(state.containerCommands).toEqual(["--config", "/config/settings.yaml"]);
});
it("maps long syntax ports and volumes to classic state", () => {
const composeDraft = `services:
app:
image: ghcr.io/example/app:latest
ports:
- target: 80
published: 8080
protocol: tcp
volumes:
- type: bind
source: /data/app
target: /config
- type: volume
source: app-cache
target: /cache
`;
const state = composeToClassicState({
composeDraft,
seed: {
title: "Example",
iconUrl: "",
},
});
expect(state.ports[0]?.host).toBe("8080");
expect(state.ports[0]?.container).toBe("80");
expect(state.volumes[0]?.host).toBe("/data/app");
expect(state.volumes[0]?.container).toBe("/config");
expect(state.volumes[1]?.host).toBe("app-cache");
expect(state.volumes[1]?.container).toBe("/cache");
});
it("prefers app service over db sidecar when appId matches sidecar name", () => {
const composeDraft = `services:
app:
image: photoprism/photoprism:240915
ports:
- "2342:2342"
network_mode: host
photoprism-db:
image: mariadb:10.8
`;
const state = composeToClassicState({
composeDraft,
appId: "photoprism",
seed: {
title: "Photoprism",
iconUrl: "",
},
});
expect(state.dockerImage).toBe("photoprism/photoprism:240915");
expect(state.webUi.port).toBe("2342");
expect(state.network).toBe("host");
expect(state.ports[0]?.host).toBe("2342");
});
it("replaces loopback APP_URL host with fallback host for editing", () => {
const composeDraft = `services:
app:
image: ghcr.io/example/app:latest
environment:
APP_URL: "http://localhost:3000"
`;
const state = composeToClassicState({
composeDraft,
seed: {
title: "Example",
iconUrl: "",
fallbackPort: 3000,
fallbackHost: "192.168.1.12",
},
});
expect(state.webUi.host).toBe("192.168.1.12");
expect(state.webUi.port).toBe("3000");
});
it("maps classic state back to compose while keeping unknown top-level fields", () => {
const seed = createDefaultClassicState({
title: "Sample",
iconUrl: "",
fallbackPort: 8080,
});
const nextState = {
...seed,
dockerImage: "nginx:1.27",
hostname: "sample-app",
envVars: [{ id: "env-1", key: "TZ", value: "UTC" }],
ports: [{ id: "p-1", host: "18080", container: "80", protocol: "TCP" as const }],
};
const previousCompose = `name: sample-stack\nservices:\n app:\n image: old:image\n`;
const compose = classicStateToCompose(nextState, previousCompose);
expect(compose).toContain("name: sample-stack");
expect(compose).toContain("image: nginx:1.27");
expect(compose).toContain("18080:80");
expect(compose).toContain("hostname: sample-app");
expect(compose).toContain("TZ: UTC");
});
it("removes ports when network mode is host", () => {
const seed = createDefaultClassicState({
title: "Host App",
iconUrl: "",
fallbackPort: 8123,
});
const compose = classicStateToCompose({
...seed,
network: "host",
ports: [{ id: "p-1", host: "8123", container: "8123", protocol: "TCP" as const }],
});
expect(compose).toContain("network_mode: host");
expect(compose).not.toContain("ports:");
expect(compose).not.toContain("8123:8123");
});
it("returns parse error for invalid yaml", () => {
const parsed = safeComposeToClassicState({
composeDraft: "services:\n app: [",
seed: {
title: "Broken",
iconUrl: "",
},
});
expect(parsed.state).toBeNull();
expect(parsed.error).toBeTruthy();
});
it("builds install payload including env and web ui port", () => {
const state = createDefaultClassicState({
title: "Install Me",
iconUrl: "",
fallbackPort: 8080,
fallbackHost: "localhost",
});
const payload = buildInstallPayloadFromClassic({
appId: "install-me",
state: {
...state,
envVars: [{ id: "env-1", key: "TZ", value: "UTC" }],
},
});
expect(payload.appId).toBe("install-me");
expect(payload.webUiPort).toBe(8080);
expect(payload.env?.TZ).toBe("UTC");
expect(payload.env?.APP_URL).toContain("localhost:8080");
expect(payload.env?.VOLUMES).toBeUndefined();
});
it("sends the link override on its own, without touching the compose", () => {
const initial = createDefaultClassicState({
title: "Reverse Proxied",
iconUrl: "",
fallbackPort: 8080,
fallbackHost: "homeio.example.com",
});
const payload = buildSettingsPayloadFromClassic({
appId: "reverse-proxied",
current: { ...initial, webUiLink: "https://myapp.example.com" },
initial,
});
expect(payload.webUiUrl).toBe("https://myapp.example.com");
// Nothing that lives in the compose file changed, so no redeploy.
expect(payload.webUiPort).toBeUndefined();
expect(payload.env).toBeUndefined();
});
it("clears the link override when the field is emptied", () => {
const initial = createDefaultClassicState({
title: "Back To Auto",
iconUrl: "",
fallbackPort: 8080,
fallbackHost: "homeio.example.com",
webUiLink: "https://myapp.example.com",
});
const payload = buildSettingsPayloadFromClassic({
appId: "back-to-auto",
current: { ...initial, webUiLink: " " },
initial,
});
expect(payload.webUiUrl).toBeNull();
});
it("leaves the link alone when only the web ui port changes", () => {
const initial = createDefaultClassicState({
title: "Port Only",
iconUrl: "",
fallbackPort: 8080,
fallbackHost: "homeio.example.com",
});
const payload = buildSettingsPayloadFromClassic({
appId: "port-only",
current: { ...initial, webUi: { ...initial.webUi, port: "9090" } },
initial,
});
expect(payload.webUiUrl).toBeUndefined();
expect(payload.webUiPort).toBe(9090);
});
it("keeps the container APP_URL separate from the link override", () => {
const initial = createDefaultClassicState({
title: "Two Concerns",
iconUrl: "",
fallbackPort: 8080,
fallbackHost: "homeio.example.com",
});
const payload = buildSettingsPayloadFromClassic({
appId: "two-concerns",
current: {
...initial,
webUi: { ...initial.webUi, host: "internal.example.com" },
},
initial,
});
// The Web UI row still composes the container's APP_URL...
expect(payload.env?.APP_URL).toBe("http://internal.example.com:8080");
// ...and no longer doubles as the rendered link.
expect(payload.webUiUrl).toBeUndefined();
});
it("does not rebuild compose volumes from legacy VOLUMES env metadata", () => {
const compose = buildInitialComposeDraft({
appId: "legacy-app",
template: {
id: "legacy-app",
name: "Legacy App",
description: "",
platform: "linux",
categories: [],
logoUrl: null,
repositoryUrl: "",
stackFile: "docker-compose.yml",
status: "installed",
webUiPort: null,
updateAvailable: false,
localDigest: null,
remoteDigest: null,
note: "",
env: [],
screenshots: [],
installedConfig: {
appId: "legacy-app",
templateName: "legacy-app",
stackName: "legacy-app",
composePath: "/tmp/docker-compose.yml",
status: "installed",
webUiPort: null,
env: {
VOLUMES: '[{"host":"/DATA/AppData/legacy-app/data","container":"/data"}]',
},
displayName: "Legacy App",
iconUrl: null,
installedAt: null,
updatedAt: new Date().toISOString(),
isUpToDate: true,
lastUpdateCheck: null,
localDigest: null,
remoteDigest: null,
},
},
});
expect(compose).not.toContain("/DATA/AppData/legacy-app/data:/data");
expect(compose).toContain("environment:");
});
it("patches only immich primary service and preserves other compose content", () => {
const immichCompose = `services:
immich-server:
image: ghcr.io/immich-app/immich-server:v2.5.0
ports:
- "2283:2283"
depends_on:
- redis
- database
environment:
TZ: UTC
immich-machine-learning:
image: ghcr.io/immich-app/immich-machine-learning:v2.5.0
redis:
image: redis:7
database:
image: postgres:14
volumes:
- pgdata:/var/lib/postgresql/data
networks:
default:
name: immich_default
volumes:
pgdata:
`;
const parsed = safeComposeToClassicState({
composeDraft: immichCompose,
seed: { title: "Immich", iconUrl: "" },
appId: "immich",
primaryServiceName: "immich-server",
});
expect(parsed.error).toBeNull();
expect(parsed.state?.dockerImage).toBe("ghcr.io/immich-app/immich-server:v2.5.0");
const nextCompose = classicStateToCompose(
{
...parsed.state!,
dockerImage: "ghcr.io/immich-app/immich-server:v2.5.6",
envVars: [{ id: "env-1", key: "TZ", value: "Europe/Paris" }],
},
immichCompose,
{ appId: "immich", primaryServiceName: "immich-server" },
);
expect(nextCompose).toContain("immich-server:");
expect(nextCompose).toContain("image: ghcr.io/immich-app/immich-server:v2.5.6");
expect(nextCompose).toContain("immich-machine-learning:");
expect(nextCompose).toContain("redis:");
expect(nextCompose).toContain("depends_on:");
expect(nextCompose).toContain("volumes:");
expect(nextCompose).toContain("pgdata:");
expect(nextCompose).toContain("networks:");
expect(nextCompose).not.toContain("\n app:\n");
});
});