import { describe, expect, it } from "vitest";
import type { ComposeProject } from "../types.js";
import {
formatComposeBuildMarkdown,
formatComposeDownMarkdown,
formatComposeListMarkdown,
formatComposeLogsMarkdown,
formatComposePullMarkdown,
formatComposeRecreateMarkdown,
formatComposeRefreshMarkdown,
formatComposeRestartMarkdown,
formatComposeStatusMarkdown,
formatComposeUpMarkdown,
} from "./compose.js";
describe("formatComposeUpMarkdown", () => {
it("should format title per STYLE.md 3.1", () => {
const result = formatComposeUpMarkdown(
{ name: "myapp", services: [], status: "running", configFiles: [] },
"squirts",
{ servicesStarted: 3 }
);
expect(result).toContain("Compose Up for myapp on squirts");
expect(result).not.toMatch(/^##/); // No markdown headers
});
it("should include services started count", () => {
const result = formatComposeUpMarkdown(
{ name: "myapp", services: [], status: "running", configFiles: [] },
"squirts",
{ servicesStarted: 3 }
);
expect(result).toMatch(/Services started: 3/);
});
it("should use canonical β symbol", () => {
const result = formatComposeUpMarkdown(
{ name: "myapp", services: [], status: "running", configFiles: [] },
"squirts",
{ servicesStarted: 3 }
);
expect(result).toContain("β");
expect(result).not.toMatch(/[π’β
]/u); // No emoji
});
it("should handle zero services", () => {
const result = formatComposeUpMarkdown(
{ name: "myapp", services: [], status: "running", configFiles: [] },
"squirts",
{ servicesStarted: 0 }
);
expect(result).toContain("No services started");
});
});
describe("formatComposeDownMarkdown", () => {
const testProject: ComposeProject = {
name: "myapp",
services: [],
status: "stopped",
configFiles: [],
};
it("should format title per STYLE.md 3.1", () => {
const result = formatComposeDownMarkdown(testProject, "squirts", {
servicesStopped: 3,
});
expect(result).toContain("Compose Down for myapp on squirts");
expect(result).not.toMatch(/^##/);
});
it("should include services stopped count", () => {
const result = formatComposeDownMarkdown(testProject, "squirts", {
servicesStopped: 3,
});
expect(result).toMatch(/Services stopped: 3/);
});
it("should use canonical β symbol for stopped", () => {
const result = formatComposeDownMarkdown(testProject, "squirts", {
servicesStopped: 3,
});
expect(result).toContain("β");
expect(result).not.toMatch(/[π΄β]/u);
});
it("should include volumes removed when provided", () => {
const result = formatComposeDownMarkdown(testProject, "squirts", {
servicesStopped: 3,
volumesRemoved: 2,
});
expect(result).toMatch(/Volumes removed: 2/);
});
it("should handle zero services", () => {
const result = formatComposeDownMarkdown(testProject, "squirts", {
servicesStopped: 0,
});
expect(result).toContain("No services stopped");
});
});
describe("formatComposeRestartMarkdown", () => {
const testProject: ComposeProject = {
name: "myapp",
services: [],
status: "running",
configFiles: [],
};
it("should format title per STYLE.md 3.1", () => {
const result = formatComposeRestartMarkdown(testProject, "squirts", {
servicesRestarted: 3,
});
expect(result).toContain("Compose Restart for myapp on squirts");
expect(result).not.toMatch(/^##/);
});
it("should include services restarted count", () => {
const result = formatComposeRestartMarkdown(testProject, "squirts", {
servicesRestarted: 3,
});
expect(result).toMatch(/Services restarted: 3/);
});
it("should use canonical β symbol for restarting", () => {
const result = formatComposeRestartMarkdown(testProject, "squirts", {
servicesRestarted: 3,
});
expect(result).toContain("β");
expect(result).not.toContain("π‘");
expect(result).not.toContain("βΈ");
});
it("should handle zero services", () => {
const result = formatComposeRestartMarkdown(testProject, "squirts", {
servicesRestarted: 0,
});
expect(result).toContain("No services restarted");
});
});
describe("formatComposeRecreateMarkdown", () => {
const testProject: ComposeProject = {
name: "myapp",
services: [],
status: "running",
configFiles: [],
};
it("should format title per STYLE.md 3.1", () => {
const result = formatComposeRecreateMarkdown(testProject, "squirts", {
servicesRecreated: 3,
});
expect(result).toContain("Compose Recreate for myapp on squirts");
expect(result).not.toMatch(/^##/);
});
it("should include services recreated count", () => {
const result = formatComposeRecreateMarkdown(testProject, "squirts", {
servicesRecreated: 3,
});
expect(result).toMatch(/Services recreated: 3/);
});
it("should use canonical β symbol (recreated = running)", () => {
const result = formatComposeRecreateMarkdown(testProject, "squirts", {
servicesRecreated: 3,
});
expect(result).toContain("β");
expect(result).not.toMatch(/[π’β
]/u);
});
it("should handle zero services", () => {
const result = formatComposeRecreateMarkdown(testProject, "squirts", {
servicesRecreated: 0,
});
expect(result).toContain("No services recreated");
});
});
describe("formatComposePullMarkdown", () => {
const testProject: ComposeProject = {
name: "myapp",
services: [],
status: "running",
configFiles: [],
};
it("should format title per STYLE.md 3.1", () => {
const result = formatComposePullMarkdown(testProject, "squirts", {
imagesPulled: 2,
imagesUpToDate: 1,
});
expect(result).toContain("Compose Pull for myapp on squirts");
expect(result).not.toMatch(/^##/);
});
it("should include both counts when both present", () => {
const result = formatComposePullMarkdown(testProject, "squirts", {
imagesPulled: 2,
imagesUpToDate: 1,
});
expect(result).toMatch(/Images pulled: 2/);
expect(result).toMatch(/Up-to-date: 1/);
});
it("should use canonical β symbol", () => {
const result = formatComposePullMarkdown(testProject, "squirts", {
imagesPulled: 2,
imagesUpToDate: 1,
});
expect(result).toContain("β");
expect(result).not.toContain("β
");
expect(result).not.toContain("βοΈ");
});
it("should handle zero pulled (all up-to-date)", () => {
const result = formatComposePullMarkdown(testProject, "squirts", {
imagesPulled: 0,
imagesUpToDate: 3,
});
expect(result).toMatch(/Up-to-date: 3/);
expect(result).not.toMatch(/Images pulled:/);
});
it("should handle both zero", () => {
const result = formatComposePullMarkdown(testProject, "squirts", {
imagesPulled: 0,
imagesUpToDate: 0,
});
expect(result).toContain("No images to pull");
});
});
describe("formatComposeBuildMarkdown", () => {
const testProject: ComposeProject = {
name: "myapp",
services: [],
status: "running",
configFiles: [],
};
it("should format title per STYLE.md 3.1", () => {
const result = formatComposeBuildMarkdown(testProject, "squirts", {
servicesBuilt: 2,
duration: 45,
});
expect(result).toContain("Compose Build for myapp on squirts");
expect(result).not.toMatch(/^##/);
});
it("should include both services count and duration", () => {
const result = formatComposeBuildMarkdown(testProject, "squirts", {
servicesBuilt: 2,
duration: 45,
});
expect(result).toMatch(/Services built: 2/);
expect(result).toMatch(/Duration: 45s/);
});
it("should use canonical β symbol", () => {
const result = formatComposeBuildMarkdown(testProject, "squirts", {
servicesBuilt: 2,
duration: 45,
});
expect(result).toContain("β");
expect(result).not.toContain("β
");
expect(result).not.toContain("βοΈ");
});
it("should handle missing duration", () => {
const result = formatComposeBuildMarkdown(testProject, "squirts", {
servicesBuilt: 2,
});
expect(result).toMatch(/Services built: 2/);
expect(result).not.toMatch(/Duration:/);
});
it("should handle zero services", () => {
const result = formatComposeBuildMarkdown(testProject, "squirts", {
servicesBuilt: 0,
});
expect(result).toContain("No services built");
});
});
describe("formatComposeLogsMarkdown", () => {
const testProject: ComposeProject = {
name: "myapp",
services: [],
status: "running",
configFiles: [],
};
it("should format title per STYLE.md 3.1", () => {
const result = formatComposeLogsMarkdown(testProject, "squirts", {
logs: [{ stream: "stdout", message: "line 1" }],
requested: 10,
truncated: false,
follow: false,
});
expect(result).toContain("Container Logs for myapp on squirts");
expect(result).not.toMatch(/^##/);
});
it("should include summary with all flags", () => {
const result = formatComposeLogsMarkdown(testProject, "squirts", {
logs: [{ stream: "stdout", message: "line 1" }],
requested: 10,
truncated: false,
follow: false,
});
expect(result).toMatch(/Lines returned: 1 \(requested: 10\)/);
expect(result).toMatch(/truncated: no/);
expect(result).toMatch(/follow: no/);
});
it("should show all lines when count β€10", () => {
const result = formatComposeLogsMarkdown(testProject, "squirts", {
logs: [
{ stream: "stdout", message: "line 1" },
{ stream: "stderr", message: "line 2" },
],
requested: 10,
truncated: false,
follow: false,
});
expect(result).toContain("[stdout] line 1");
expect(result).toContain("[stderr] line 2");
expect(result).not.toContain("Preview");
});
it("should use preview format when count >10", () => {
const logs = Array.from({ length: 15 }, (_, i) => ({
stream: "stdout" as const,
message: `line ${i + 1}`,
}));
const result = formatComposeLogsMarkdown(testProject, "squirts", {
logs,
requested: 15,
truncated: false,
follow: false,
});
expect(result).toContain("Preview (first 5):");
expect(result).toContain("[stdout] line 1");
expect(result).toContain("Preview (last 5):");
expect(result).toContain("[stdout] line 15");
expect(result).not.toContain("line 8"); // Middle lines hidden
});
it("should label streams correctly", () => {
const result = formatComposeLogsMarkdown(testProject, "squirts", {
logs: [
{ stream: "stdout", message: "output" },
{ stream: "stderr", message: "error" },
],
requested: 10,
truncated: false,
follow: false,
});
expect(result).toContain("[stdout]");
expect(result).toContain("[stderr]");
});
});
describe("formatComposeRefreshMarkdown", () => {
it("should format title per STYLE.md 3.1", () => {
const result = formatComposeRefreshMarkdown("squirts", {
discovered: 2,
updated: 1,
});
expect(result).toContain("Compose Refresh on squirts");
expect(result).not.toMatch(/^##/);
});
it("should include both discovered and updated counts", () => {
const result = formatComposeRefreshMarkdown("squirts", {
discovered: 2,
updated: 1,
});
expect(result).toMatch(/Discovered: 2/);
expect(result).toMatch(/Updated: 1/);
});
it("should use canonical β symbol", () => {
const result = formatComposeRefreshMarkdown("squirts", {
discovered: 2,
updated: 1,
});
expect(result).toContain("β");
expect(result).not.toContain("β
");
expect(result).not.toContain("βοΈ");
});
it("should handle both zero (no changes)", () => {
const result = formatComposeRefreshMarkdown("squirts", {
discovered: 0,
updated: 0,
});
expect(result).toContain("No changes detected");
});
});
describe("formatComposeListMarkdown - Request Echo (STYLE.md 3.5)", () => {
const testProjects = [
{
name: "web-app",
services: ["nginx", "api"],
status: "running" as const,
configFiles: ["/compose/web/docker-compose.yml"],
},
{
name: "db-stack",
services: ["postgres", "redis"],
status: "running" as const,
configFiles: ["/compose/db/docker-compose.yml"],
},
];
it("should include filter echo when filters are present", () => {
const result = formatComposeListMarkdown(
testProjects,
"squirts",
undefined,
undefined,
undefined,
{ state: "running", host: "squirts" }
);
expect(result).toContain("Filters: state=running, host=squirts");
});
it("should omit filter echo when no filters provided", () => {
const result = formatComposeListMarkdown(testProjects, "squirts");
expect(result).not.toContain("Filters:");
});
it("should format filter line between summary and body", () => {
const result = formatComposeListMarkdown(
testProjects,
"squirts",
undefined,
undefined,
undefined,
{ state: "running" }
);
const lines = result.split("\n");
const summaryIdx = lines.findIndex((l) => l.includes("Status breakdown:"));
const filterIdx = lines.findIndex((l) => l.includes("Filters:"));
const tableIdx = lines.findIndex((l) => l.trim().startsWith("Stack"));
expect(filterIdx).toBeGreaterThan(summaryIdx);
expect(filterIdx).toBeLessThan(tableIdx);
});
it("should handle multiple filter values", () => {
const result = formatComposeListMarkdown(
testProjects,
"squirts",
undefined,
undefined,
undefined,
{ state: "running", host: "squirts", limit: 50 }
);
expect(result).toContain("Filters:");
expect(result).toContain("state=running");
expect(result).toContain("host=squirts");
expect(result).toContain("limit=50");
});
it("should handle boolean filter values", () => {
const result = formatComposeListMarkdown(
testProjects,
"squirts",
undefined,
undefined,
undefined,
{ includeInactive: false }
);
expect(result).toContain("Filters: includeInactive=false");
});
});
describe("formatComposeLogsMarkdown - Timestamp (STYLE.md 3.6)", () => {
const testProject: ComposeProject = {
name: "myapp",
services: [],
status: "running",
configFiles: [],
};
it("should include freshness timestamp", () => {
const result = formatComposeLogsMarkdown(testProject, "squirts", {
logs: [{ stream: "stdout", message: "test log" }],
requested: 10,
truncated: false,
follow: false,
});
expect(result).toMatch(/As of \([^)]+\): \d{2}:\d{2}:\d{2} \| \d{2}\/\d{2}\/\d{4}/);
});
it("should have timestamp after summary", () => {
const result = formatComposeLogsMarkdown(testProject, "squirts", {
logs: [{ stream: "stdout", message: "test" }],
requested: 10,
truncated: false,
follow: false,
});
const lines = result.split("\n");
const summaryIdx = lines.findIndex((l) => l.includes("Lines returned"));
const timestampIdx = lines.findIndex((l) => /^As of \([^)]+\):/.test(l));
expect(timestampIdx).toBeGreaterThan(summaryIdx);
});
});
describe("formatComposeStatusMarkdown - Timestamp (STYLE.md 3.6)", () => {
const testProject: ComposeProject = {
name: "myapp",
services: [
{
name: "web",
status: "running",
health: "healthy",
publishers: [{ publishedPort: "8080", targetPort: "80", protocol: "tcp" }],
},
],
status: "running",
configFiles: [],
};
it("should include freshness timestamp", () => {
const result = formatComposeStatusMarkdown(testProject);
expect(result).toMatch(/As of \([^)]+\): \d{2}:\d{2}:\d{2} \| \d{2}\/\d{2}\/\d{4}/);
});
it("should have timestamp after summary", () => {
const result = formatComposeStatusMarkdown(testProject);
const lines = result.split("\n");
const summaryIdx = lines.findIndex((l) => l.includes("Services:"));
const timestampIdx = lines.findIndex((l) => /^As of \([^)]+\):/.test(l));
expect(timestampIdx).toBeGreaterThan(summaryIdx);
});
});
describe("formatComposeListMarkdown - Timestamp (STYLE.md 3.6)", () => {
const testProjects: ComposeProject[] = [
{ name: "myapp", services: [], status: "running", configFiles: [] },
{ name: "otherapp", services: [], status: "stopped", configFiles: [] },
];
it("should include freshness timestamp", () => {
const result = formatComposeListMarkdown(testProjects, "squirts");
expect(result).toMatch(/As of \([^)]+\): \d{2}:\d{2}:\d{2} \| \d{2}\/\d{2}\/\d{4}/);
});
it("should have timestamp after summary/filters and before table", () => {
const result = formatComposeListMarkdown(testProjects, "squirts");
const lines = result.split("\n");
const summaryIdx = lines.findIndex((l) => l.includes("Status breakdown"));
const timestampIdx = lines.findIndex((l) => /^As of \([^)]+\):/.test(l));
const tableIdx = lines.findIndex((l) => l.trim().startsWith("Stack"));
expect(timestampIdx).toBeGreaterThan(summaryIdx);
expect(timestampIdx).toBeLessThan(tableIdx);
});
it("should place timestamp after filters if present", () => {
const result = formatComposeListMarkdown(
testProjects,
"squirts",
undefined,
undefined,
undefined,
{ state: "running" }
);
const lines = result.split("\n");
const filterIdx = lines.findIndex((l) => l.includes("Filters:"));
const timestampIdx = lines.findIndex((l) => /^As of \([^)]+\):/.test(l));
expect(filterIdx).toBeLessThan(timestampIdx);
});
});