mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
The Services tool window lists run configurations next to Docker. Spring Boot configurations appear by default; other types (Application, npm, Maven, Gradle, …) are added with + › Run Configuration Type and removed with Remove from Services, remembered per project. Nodes group by type and show running / stopped, with Run / Rerun, Debug, Stop, Show in Run Tool Window and Edit Configuration… in the toolbar and context menu; the detail pane shows the same console as the Run tool window. Running Spring Boot apps show the HTTP port read from the embedded server's "started on port" line. Rerun now waits for the old process to exit before starting the new one, so a restarted server no longer fails with its port already in use, and a process stopped by the user no longer raises an exit-code notification. Closes #150 Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { DockerContainer, DockerSnapshot } from "@/platform/ipc";
|
|
import { composeTargets, dockerfileTargets, fileTargets, imageTag, isComposeFile, isDockerfile } from "./compose";
|
|
import {
|
|
DOCKERFILE,
|
|
DOCKER_COMPOSE,
|
|
containerName,
|
|
dockerComposeType,
|
|
dockerImageType,
|
|
gutterActions,
|
|
shellQuote,
|
|
} from "./runTypes";
|
|
import {
|
|
childrenOf,
|
|
findNode,
|
|
nodeDetail,
|
|
nodeLabel,
|
|
parseTreeKey,
|
|
rootNodes,
|
|
treeKey,
|
|
type ServiceNode,
|
|
} from "./model";
|
|
import { endpointFrom } from "./dialogs";
|
|
import { dockerItems } from "./docker";
|
|
import { addServiceMenu, expandableKeys, indexTree, setProviderRoots, useServicesTree } from "./tree";
|
|
|
|
const container = (name: string, state: string, project?: string, service?: string): DockerContainer => ({
|
|
id: `${name}-id`,
|
|
name,
|
|
image: "img",
|
|
command: "",
|
|
state,
|
|
status: state === "running" ? "Up 1 hour" : "Exited (0)",
|
|
ports: "",
|
|
createdAt: "",
|
|
composeProject: project ?? null,
|
|
composeService: service ?? null,
|
|
composeWorkingDir: null,
|
|
composeFiles: [],
|
|
});
|
|
|
|
const snapshot: DockerSnapshot = {
|
|
availability: { kind: "ready", clientVersion: "27", serverVersion: "27", context: null },
|
|
executable: "/usr/bin/docker",
|
|
containers: [
|
|
container("web", "exited"),
|
|
container("shop-db-1", "running", "shop", "db"),
|
|
container("shop-api-1", "exited", "shop", "api"),
|
|
],
|
|
images: [{ id: "sha256:abc", repository: "postgres", tag: "16", size: "1MB", createdSince: "now" }],
|
|
volumes: [],
|
|
networks: [],
|
|
errors: [],
|
|
};
|
|
|
|
describe("services tree", () => {
|
|
it("groups containers by compose project before standalone ones", () => {
|
|
const children = childrenOf(snapshot);
|
|
const [docker] = rootNodes();
|
|
const groups = children(docker!)!;
|
|
expect(groups.map(nodeLabel)).toEqual(["Containers", "Images", "Volumes", "Networks"]);
|
|
expect(nodeDetail(groups[0]!)).toBe("3");
|
|
const containers = children(groups[0]!)!;
|
|
expect(containers.map((n) => n.kind)).toEqual(["compose", "container"]);
|
|
const shop = containers[0] as Extract<ServiceNode, { kind: "compose" }>;
|
|
expect(nodeDetail(shop)).toBe("1/2 running");
|
|
expect(children(shop)!.map(nodeLabel)).toEqual(["api", "db"]);
|
|
expect(nodeLabel(children(groups[1]!)![0]!)).toBe("postgres:16");
|
|
});
|
|
|
|
it("finds nodes again in a new snapshot", () => {
|
|
expect(findNode(snapshot, "container:shop-db-1-id")?.kind).toBe("container");
|
|
expect(findNode(snapshot, "container:gone")).toBeNull();
|
|
});
|
|
|
|
it("has no children while docker is down", () => {
|
|
const down: DockerSnapshot = { ...snapshot, availability: { kind: "daemonDown", message: "x", context: null } };
|
|
expect(childrenOf(down)(rootNodes()[0]!)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("services tree host", () => {
|
|
const conns = [
|
|
{ id: "default", name: "Docker", endpoint: { kind: "default" as const } },
|
|
{ id: "c2", name: "Build box", endpoint: { kind: "tcp" as const, host: "build:2376", tlsCertDir: null } },
|
|
];
|
|
const state = { default: { snapshot, loading: false, error: null } };
|
|
|
|
it("puts every connection and registry at the root, with connection-scoped keys", () => {
|
|
const items = dockerItems(conns, [{ id: "r1", name: "GHCR", address: "ghcr.io", username: "me" }], state);
|
|
expect(items.map((i) => [i.key, i.label, i.detail])).toEqual([
|
|
["docker:default", "Docker", undefined],
|
|
["docker:c2", "Build box", undefined],
|
|
["docker-registry:r1", "GHCR", "ghcr.io"],
|
|
]);
|
|
expect(items[1]!.dim).toBe(true);
|
|
expect(items[1]!.children).toEqual([]);
|
|
const containers = items[0]!.children![0]!;
|
|
expect(containers.key).toBe("docker:default:group:containers");
|
|
expect(containers.children!.map((c) => c.key)).toEqual([
|
|
"docker:default:compose:shop",
|
|
"docker:default:container:web-id",
|
|
]);
|
|
expect(containers.children![1]!.dim).toBe(true);
|
|
const index = indexTree({ docker: items });
|
|
expect(index.get("docker:default:container:shop-db-1-id")?.item.label).toBe("db");
|
|
expect(expandableKeys(items)).toContain("docker:default:compose:shop");
|
|
});
|
|
|
|
it("round-trips tree keys", () => {
|
|
const node = findNode(snapshot, "container:web-id")!;
|
|
expect(treeKey("c2", node)).toBe("docker:c2:container:web-id");
|
|
expect(parseTreeKey("docker:c2:container:web-id")).toEqual({ conn: "c2", local: "container:web-id" });
|
|
expect(parseTreeKey("docker:c2")).toEqual({ conn: "c2", local: "docker" });
|
|
expect(treeKey("c2", rootNodes()[0]!)).toBe("docker:c2");
|
|
expect(parseTreeKey("run:type:cargo")).toBeNull();
|
|
});
|
|
|
|
it("joins the providers' Add Service entries", () => {
|
|
const providers = [
|
|
{ id: "a", useRoots: () => [], addMenu: () => [{ label: "Run Configuration Type" }] },
|
|
{ id: "b", useRoots: () => [] },
|
|
{ id: "c", useRoots: () => [], addMenu: () => [{ label: "Docker Registry…" }, { label: "Docker Connection…" }] },
|
|
];
|
|
expect(addServiceMenu(providers).map((m) => ("label" in m ? m.label : m.kind))).toEqual([
|
|
"Run Configuration Type",
|
|
"separator",
|
|
"Docker Registry…",
|
|
"Docker Connection…",
|
|
]);
|
|
});
|
|
|
|
it("expands nodes asking for it once, when they first appear", () => {
|
|
const group = () => ({ key: "run:type:npm", label: "npm", icon: "x", expanded: true, children: [] });
|
|
setProviderRoots("run", [group()]);
|
|
expect(useServicesTree.getState().expanded.has("run:type:npm")).toBe(true);
|
|
useServicesTree.setState({ expanded: new Set() });
|
|
setProviderRoots("run", [group()]);
|
|
expect(useServicesTree.getState().expanded.has("run:type:npm")).toBe(false);
|
|
});
|
|
|
|
it("builds connection endpoints from the dialog fields", () => {
|
|
const f = { path: " /var/run/docker.sock ", host: "", tls: "", context: "" };
|
|
expect(endpointFrom("socket", f)).toEqual({ kind: "socket", path: "/var/run/docker.sock" });
|
|
expect(endpointFrom("tcp", f)).toBeNull();
|
|
expect(endpointFrom("tcp", { ...f, host: "tcp://h:2376", tls: "/certs" })).toEqual({
|
|
kind: "tcp",
|
|
host: "tcp://h:2376",
|
|
tlsCertDir: "/certs",
|
|
});
|
|
expect(endpointFrom("tcp", { ...f, host: "h:2375", tls: " " })).toEqual({
|
|
kind: "tcp",
|
|
host: "h:2375",
|
|
tlsCertDir: null,
|
|
});
|
|
expect(endpointFrom("context", { ...f, context: "--evil" })).toBeNull();
|
|
expect(endpointFrom("context", { ...f, context: "orbstack" })).toEqual({ kind: "context", name: "orbstack" });
|
|
expect(endpointFrom("default", f)).toEqual({ kind: "default" });
|
|
});
|
|
});
|
|
|
|
describe("docker run targets", () => {
|
|
it("recognises file names", () => {
|
|
expect(isDockerfile("/p/Dockerfile")).toBe(true);
|
|
expect(isDockerfile("/p/Dockerfile.dev")).toBe(true);
|
|
expect(isDockerfile("/p/api.dockerfile")).toBe(true);
|
|
expect(isDockerfile("/p/Dockerfiles.md")).toBe(false);
|
|
expect(isComposeFile("/p/docker-compose.yml")).toBe(true);
|
|
expect(isComposeFile("/p/compose.override.yaml")).toBe(true);
|
|
expect(isComposeFile("/p/values.yaml")).toBe(false);
|
|
});
|
|
|
|
it("finds compose services directly under services:", () => {
|
|
const text = [
|
|
"name: shop",
|
|
"services:",
|
|
" # the database",
|
|
" db:",
|
|
" image: postgres:16",
|
|
" environment:",
|
|
" POSTGRES_PASSWORD: x",
|
|
' "api":',
|
|
" build: .",
|
|
" depends_on:",
|
|
" - db",
|
|
"volumes:",
|
|
" pgdata:",
|
|
].join("\n");
|
|
expect(composeTargets(text)).toEqual([
|
|
{ kind: "composeAll", line: 1 },
|
|
{ kind: "composeService", line: 3, service: "db" },
|
|
{ kind: "composeService", line: 7, service: "api" },
|
|
]);
|
|
expect(composeTargets("version: '3'\n")).toEqual([]);
|
|
});
|
|
|
|
it("builds each FROM stage", () => {
|
|
const text =
|
|
"FROM node:22 AS build\nRUN npm ci\nFROM --platform=linux/amd64 nginx:1.27\nCOPY --from=build /app /usr/share/nginx/html\n";
|
|
expect(dockerfileTargets(text)).toEqual([
|
|
{ kind: "build", line: 0, stage: "build", last: false },
|
|
{ kind: "build", line: 2, stage: null, last: true },
|
|
]);
|
|
expect(fileTargets("/p/README.md", text)).toEqual([]);
|
|
});
|
|
|
|
it("creates Dockerfile and Docker Compose run configurations", () => {
|
|
expect(imageTag("/work/My App/Dockerfile")).toBe("my-app:latest");
|
|
const [stage] = gutterActions(
|
|
{ kind: "build", line: 0, stage: "build", last: false },
|
|
"/work/app/docker/Dockerfile",
|
|
"/work/app",
|
|
);
|
|
expect(stage!.title).toBe("Build Stage 'build'");
|
|
expect(stage!.config).toMatchObject({
|
|
kind: DOCKERFILE,
|
|
cwd: null,
|
|
params: { dockerfile: "docker/Dockerfile", target: "build" },
|
|
});
|
|
expect(stage!.config.command).toBe("docker build -f docker/Dockerfile -t docker:latest --target build docker");
|
|
expect(stage!.config.stopCommand).toBeUndefined();
|
|
|
|
const [run, buildOnly] = gutterActions(
|
|
{ kind: "build", line: 4, stage: null, last: true },
|
|
"/work/app/Dockerfile",
|
|
"/work/app",
|
|
);
|
|
expect(run!.title).toBe("Run 'Dockerfile'");
|
|
expect(run!.config.command).toBe(
|
|
"docker build -f Dockerfile -t app:latest . && (docker rm -f app >/dev/null 2>&1; docker run --name app app:latest)",
|
|
);
|
|
expect(run!.config.stopCommand).toBe("docker stop app");
|
|
expect(buildOnly!.title).toBe("Build Image 'app:latest'");
|
|
|
|
const [up] = gutterActions(
|
|
{ kind: "composeService", line: 3, service: "db" },
|
|
"/work/app/compose.yaml",
|
|
"/work/app",
|
|
);
|
|
expect(up!.config).toMatchObject({
|
|
id: "docker:compose:compose.yaml:db",
|
|
kind: DOCKER_COMPOSE,
|
|
command: "docker compose -f compose.yaml up db",
|
|
stopCommand: "docker compose -f compose.yaml down",
|
|
});
|
|
});
|
|
|
|
it("builds container command lines from the form", () => {
|
|
const image = dockerImageType.commandLine({
|
|
image: "postgres:16",
|
|
pull: "true",
|
|
ports: "5432:5432, 9187:9187",
|
|
volumes: "pg data:/var/lib/postgresql/data",
|
|
containerEnv: "POSTGRES_PASSWORD=it's; A=b",
|
|
runOptions: "--network host",
|
|
command: "postgres -c fsync=off",
|
|
})!;
|
|
expect(image.command).toBe(
|
|
"(docker rm -f postgres >/dev/null 2>&1; docker run --name postgres --pull always -p 5432:5432 -p 9187:9187 " +
|
|
"-v 'pg data:/var/lib/postgresql/data' -e 'POSTGRES_PASSWORD=it'\\''s' -e A=b --network host postgres:16 postgres -c fsync=off)",
|
|
);
|
|
expect(image.stop).toBe("docker stop postgres");
|
|
expect(containerName({ containerName: " web " }, "x")).toBe("web");
|
|
expect(containerName({}, "ghcr.io/org/my-app@sha256:abc")).toBe("my-app");
|
|
const detached = dockerComposeType.commandLine({
|
|
files: "a.yml; b.yml",
|
|
project: "demo",
|
|
detach: "true",
|
|
build: "true",
|
|
})!;
|
|
expect(detached).toMatchObject({ command: "docker compose -f a.yml -f b.yml -p demo up -d --build", stop: null });
|
|
expect(shellQuote('a "b"', true)).toBe('"a ""b"""');
|
|
});
|
|
});
|