Compare commits

...

4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
6f330cd1dc fix: restore post-job compose logs header
Co-authored-by: neilime <314088+neilime@users.noreply.github.com>
Signed-off-by: Emilien Escalle <emilien.escalle@escemi.com>
2026-08-18 16:29:33 +02:00
copilot-swe-agent[bot]
75b024b03b fix: avoid buffering compose logs during cleanup
Co-authored-by: neilime <314088+neilime@users.noreply.github.com>
2026-08-18 15:39:05 +02:00
copilot-swe-agent[bot]
26f805aa21 fix: stream compose logs during post cleanup
Co-authored-by: neilime <314088+neilime@users.noreply.github.com>
2026-08-18 15:39:05 +02:00
Emilien Escalle
e56276d1d3 fix: lint issues
Some checks failed
Internal - Main - Continuous Integration / ci (push) Has been cancelled
Need fix to Issue / main (push) Has been cancelled
Prepare release / release (push) Has been cancelled
Internal - Main - Continuous Integration / prepare-docs (push) Has been cancelled
Internal - Main - Continuous Integration / sync-docs (push) Has been cancelled
Mark stale issues and pull requests / main (push) Has been cancelled
Signed-off-by: Emilien Escalle <emilien.escalle@escemi.com>
2026-08-18 15:24:47 +02:00
10 changed files with 539 additions and 117 deletions

3
.github/linters/.codespellrc vendored Normal file
View File

@ -0,0 +1,3 @@
[codespell]
# Skip third-party/generated paths and French-locale content files.
skip = .git,*.svg,**/package-lock.json,**/dist/**,**/node_modules/**,**/coverage/**,**/junit.xml

View File

@ -21,7 +21,7 @@ jobs:
- name: Build dist/ Directory - name: Build dist/ Directory
id: package id: package
run: ${{ steps.setup-node.outputs.run-script-command }} package run: npm run package
# This will fail the workflow if the PR wasn't created by Dependabot. # This will fail the workflow if the PR wasn't created by Dependabot.
- name: Compare Directories - name: Compare Directories

107
dist/index.js generated vendored
View File

@ -26549,7 +26549,7 @@ var require_dist2 = __commonJS({
return (0, exports.restartMany)([service], options); return (0, exports.restartMany)([service], options);
}; };
exports.restartOne = restartOne; exports.restartOne = restartOne;
var logs2 = function(services, options = {}) { var logs = function(services, options = {}) {
const args = Array.isArray(services) ? services : [services]; const args = Array.isArray(services) ? services : [services];
if (options.follow) { if (options.follow) {
args.unshift("--follow"); args.unshift("--follow");
@ -26559,7 +26559,7 @@ var require_dist2 = __commonJS({
} }
return (0, exports.execCompose)("logs", args, options); return (0, exports.execCompose)("logs", args, options);
}; };
exports.logs = logs2; exports.logs = logs;
var port = async function(service, containerPort, options) { var port = async function(service, containerPort, options) {
const args = [service, containerPort]; const args = [service, containerPort];
try { try {
@ -31374,6 +31374,7 @@ function info(message) {
// src/services/docker-compose.service.ts // src/services/docker-compose.service.ts
var import_docker_compose = __toESM(require_dist2(), 1); var import_docker_compose = __toESM(require_dist2(), 1);
import { spawn as spawn2 } from "node:child_process";
var DockerComposeService = class { var DockerComposeService = class {
async up({ upFlags, services, ...optionsInputs }) { async up({ upFlags, services, ...optionsInputs }) {
const options = { const options = {
@ -31402,15 +31403,48 @@ var DockerComposeService = class {
} }
} }
async logs({ services, ...optionsInputs }) { async logs({ services, ...optionsInputs }) {
const options = { const options = this.getCommonOptions(optionsInputs);
...this.getCommonOptions(optionsInputs), const { executablePath, executableArgs } = this.getDockerComposeCommandExecution("logs", services, options);
follow: false return new Promise((resolve2) => {
}; let settled = false;
const { err, out } = await (0, import_docker_compose.logs)(services, options); const childProcess = spawn2(executablePath, executableArgs, {
return { cwd: options.cwd
error: err, });
output: out childProcess.on("error", (error2) => {
}; if (settled) {
return;
}
settled = true;
resolve2({
error: `Unable to collect docker compose logs: ${error2.message}`,
output: ""
});
});
if (!childProcess.stdout || !childProcess.stderr) {
settled = true;
resolve2({
error: "Unable to collect docker compose logs: stdout/stderr unavailable",
output: ""
});
return;
}
childProcess.stdout.on("data", (chunk) => {
options.callback?.(Buffer.from(chunk), "stdout");
});
childProcess.stderr.on("data", (chunk) => {
options.callback?.(Buffer.from(chunk), "stderr");
});
childProcess.on("close", (exitCode, signal) => {
if (settled) {
return;
}
settled = true;
resolve2({
error: signal ? `Docker Compose logs command failed with signal ${signal}` : exitCode !== null && exitCode !== 0 ? `Docker Compose logs command failed with exit code ${exitCode}` : "",
output: ""
});
});
});
} }
getCommonOptions({ getCommonOptions({
dockerFlags, dockerFlags,
@ -31434,8 +31468,51 @@ var DockerComposeService = class {
* Formats docker-compose errors into proper Error objects with readable messages * Formats docker-compose errors into proper Error objects with readable messages
*/ */
formatDockerComposeError(error2) { formatDockerComposeError(error2) {
return new Error(this.getDockerComposeErrorMessage(error2));
}
getDockerComposeCommandExecution(command, commandArgs, options) {
const composeArgs = [
...this.getComposeOptionArgs(options.composeOptions),
...this.getConfigArgs(options.config),
command,
...this.getComposeOptionArgs(options.commandOptions),
...commandArgs
];
if (options.executable?.standalone) {
return {
executablePath: options.executable.executablePath ?? "docker-compose",
executableArgs: composeArgs
};
}
return {
executablePath: options.executable?.executablePath ?? "docker",
executableArgs: [
...this.getComposeOptionArgs(options.executable?.options),
"compose",
...composeArgs
]
};
}
getConfigArgs(config) {
if (typeof config === "undefined") {
return [];
}
if (typeof config === "string") {
return ["-f", config];
}
return config.flatMap((item) => ["-f", item]);
}
getComposeOptionArgs(composeOptions) {
if (!composeOptions) {
return [];
}
return composeOptions.flatMap(
(option) => Array.isArray(option) ? option : [option]
);
}
getDockerComposeErrorMessage(error2) {
if (error2 instanceof Error) { if (error2 instanceof Error) {
return error2; return error2.message;
} }
if (this.isDockerComposeResult(error2)) { if (this.isDockerComposeResult(error2)) {
const parts = []; const parts = [];
@ -31454,12 +31531,12 @@ var DockerComposeService = class {
parts.push("\nStandard output:"); parts.push("\nStandard output:");
parts.push(error2.out.trim()); parts.push(error2.out.trim());
} }
return new Error(parts.join("\n")); return parts.join("\n");
} }
if (typeof error2 === "string") { if (typeof error2 === "string") {
return new Error(error2); return error2;
} }
return new Error(JSON.stringify(error2)); return JSON.stringify(error2);
} }
/** /**
* Type guard to check if an object is a docker-compose result * Type guard to check if an object is a docker-compose result

136
dist/post.js generated vendored
View File

@ -26549,7 +26549,7 @@ var require_dist2 = __commonJS({
return (0, exports.restartMany)([service], options); return (0, exports.restartMany)([service], options);
}; };
exports.restartOne = restartOne; exports.restartOne = restartOne;
var logs2 = function(services, options = {}) { var logs = function(services, options = {}) {
const args = Array.isArray(services) ? services : [services]; const args = Array.isArray(services) ? services : [services];
if (options.follow) { if (options.follow) {
args.unshift("--follow"); args.unshift("--follow");
@ -26559,7 +26559,7 @@ var require_dist2 = __commonJS({
} }
return (0, exports.execCompose)("logs", args, options); return (0, exports.execCompose)("logs", args, options);
}; };
exports.logs = logs2; exports.logs = logs;
var port = async function(service, containerPort, options) { var port = async function(service, containerPort, options) {
const args = [service, containerPort]; const args = [service, containerPort];
try { try {
@ -27111,6 +27111,7 @@ function info(message) {
// src/services/docker-compose.service.ts // src/services/docker-compose.service.ts
var import_docker_compose = __toESM(require_dist2(), 1); var import_docker_compose = __toESM(require_dist2(), 1);
import { spawn } from "node:child_process";
var DockerComposeService = class { var DockerComposeService = class {
async up({ upFlags, services, ...optionsInputs }) { async up({ upFlags, services, ...optionsInputs }) {
const options = { const options = {
@ -27139,15 +27140,48 @@ var DockerComposeService = class {
} }
} }
async logs({ services, ...optionsInputs }) { async logs({ services, ...optionsInputs }) {
const options = { const options = this.getCommonOptions(optionsInputs);
...this.getCommonOptions(optionsInputs), const { executablePath, executableArgs } = this.getDockerComposeCommandExecution("logs", services, options);
follow: false return new Promise((resolve) => {
}; let settled = false;
const { err, out } = await (0, import_docker_compose.logs)(services, options); const childProcess = spawn(executablePath, executableArgs, {
return { cwd: options.cwd
error: err, });
output: out childProcess.on("error", (error2) => {
}; if (settled) {
return;
}
settled = true;
resolve({
error: `Unable to collect docker compose logs: ${error2.message}`,
output: ""
});
});
if (!childProcess.stdout || !childProcess.stderr) {
settled = true;
resolve({
error: "Unable to collect docker compose logs: stdout/stderr unavailable",
output: ""
});
return;
}
childProcess.stdout.on("data", (chunk) => {
options.callback?.(Buffer.from(chunk), "stdout");
});
childProcess.stderr.on("data", (chunk) => {
options.callback?.(Buffer.from(chunk), "stderr");
});
childProcess.on("close", (exitCode, signal) => {
if (settled) {
return;
}
settled = true;
resolve({
error: signal ? `Docker Compose logs command failed with signal ${signal}` : exitCode !== null && exitCode !== 0 ? `Docker Compose logs command failed with exit code ${exitCode}` : "",
output: ""
});
});
});
} }
getCommonOptions({ getCommonOptions({
dockerFlags, dockerFlags,
@ -27171,8 +27205,51 @@ var DockerComposeService = class {
* Formats docker-compose errors into proper Error objects with readable messages * Formats docker-compose errors into proper Error objects with readable messages
*/ */
formatDockerComposeError(error2) { formatDockerComposeError(error2) {
return new Error(this.getDockerComposeErrorMessage(error2));
}
getDockerComposeCommandExecution(command, commandArgs, options) {
const composeArgs = [
...this.getComposeOptionArgs(options.composeOptions),
...this.getConfigArgs(options.config),
command,
...this.getComposeOptionArgs(options.commandOptions),
...commandArgs
];
if (options.executable?.standalone) {
return {
executablePath: options.executable.executablePath ?? "docker-compose",
executableArgs: composeArgs
};
}
return {
executablePath: options.executable?.executablePath ?? "docker",
executableArgs: [
...this.getComposeOptionArgs(options.executable?.options),
"compose",
...composeArgs
]
};
}
getConfigArgs(config) {
if (typeof config === "undefined") {
return [];
}
if (typeof config === "string") {
return ["-f", config];
}
return config.flatMap((item) => ["-f", item]);
}
getComposeOptionArgs(composeOptions) {
if (!composeOptions) {
return [];
}
return composeOptions.flatMap(
(option) => Array.isArray(option) ? option : [option]
);
}
getDockerComposeErrorMessage(error2) {
if (error2 instanceof Error) { if (error2 instanceof Error) {
return error2; return error2.message;
} }
if (this.isDockerComposeResult(error2)) { if (this.isDockerComposeResult(error2)) {
const parts = []; const parts = [];
@ -27191,12 +27268,12 @@ var DockerComposeService = class {
parts.push("\nStandard output:"); parts.push("\nStandard output:");
parts.push(error2.out.trim()); parts.push(error2.out.trim());
} }
return new Error(parts.join("\n")); return parts.join("\n");
} }
if (typeof error2 === "string") { if (typeof error2 === "string") {
return new Error(error2); return error2;
} }
return new Error(JSON.stringify(error2)); return JSON.stringify(error2);
} }
/** /**
* Type guard to check if an object is a docker-compose result * Type guard to check if an object is a docker-compose result
@ -27335,20 +27412,25 @@ async function run() {
const inputService = new InputService(); const inputService = new InputService();
const dockerComposeService = new DockerComposeService(); const dockerComposeService = new DockerComposeService();
const inputs = inputService.getInputs(); const inputs = inputService.getInputs();
const { error: error2, output } = await dockerComposeService.logs({ try {
dockerFlags: inputs.dockerFlags, loggerService.debug("docker compose logs:");
composeFiles: inputs.composeFiles, const { error: error2 } = await dockerComposeService.logs({
composeFlags: inputs.composeFlags, dockerFlags: inputs.dockerFlags,
cwd: inputs.cwd, composeFiles: inputs.composeFiles,
services: inputs.services, composeFlags: inputs.composeFlags,
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel) cwd: inputs.cwd,
}); services: inputs.services,
if (error2) { serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel)
loggerService.debug(`docker compose error: });
if (error2) {
loggerService.debug(`docker compose error:
${error2}`); ${error2}`);
}
} catch (error2) {
loggerService.warn(
`Unable to collect docker compose logs before cleanup: ${error2 instanceof Error ? error2.message : JSON.stringify(error2)}`
);
} }
loggerService.debug(`docker compose logs:
${output}`);
await dockerComposeService.down({ await dockerComposeService.down({
dockerFlags: inputs.dockerFlags, dockerFlags: inputs.dockerFlags,
composeFiles: inputs.composeFiles, composeFiles: inputs.composeFiles,

6
package-lock.json generated
View File

@ -3145,9 +3145,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.17", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {

View File

@ -42,6 +42,7 @@ const { DockerComposeService } = await import(
describe("run", () => { describe("run", () => {
let infoMock: ReturnType<typeof vi.spyOn>; let infoMock: ReturnType<typeof vi.spyOn>;
let debugMock: ReturnType<typeof vi.spyOn>; let debugMock: ReturnType<typeof vi.spyOn>;
let warnMock: ReturnType<typeof vi.spyOn>;
let getInputsMock: ReturnType<typeof vi.spyOn>; let getInputsMock: ReturnType<typeof vi.spyOn>;
let serviceDownMock: ReturnType<typeof vi.spyOn>; let serviceDownMock: ReturnType<typeof vi.spyOn>;
let serviceLogsMock: ReturnType<typeof vi.spyOn>; let serviceLogsMock: ReturnType<typeof vi.spyOn>;
@ -55,12 +56,15 @@ describe("run", () => {
debugMock = vi debugMock = vi
.spyOn(LoggerService.prototype, "debug") .spyOn(LoggerService.prototype, "debug")
.mockImplementation(() => {}); .mockImplementation(() => {});
warnMock = vi
.spyOn(LoggerService.prototype, "warn")
.mockImplementation(() => {});
getInputsMock = vi.spyOn(InputService.prototype, "getInputs"); getInputsMock = vi.spyOn(InputService.prototype, "getInputs");
serviceDownMock = vi.spyOn(DockerComposeService.prototype, "down"); serviceDownMock = vi.spyOn(DockerComposeService.prototype, "down");
serviceLogsMock = vi.spyOn(DockerComposeService.prototype, "logs"); serviceLogsMock = vi.spyOn(DockerComposeService.prototype, "logs");
}); });
it("should bring down docker compose service(s) and log output", async () => { it("should bring down docker compose service(s)", async () => {
// Arrange // Arrange
getInputsMock.mockImplementation(() => ({ getInputsMock.mockImplementation(() => ({
dockerFlags: [], dockerFlags: [],
@ -75,7 +79,7 @@ describe("run", () => {
serviceLogLevel: LogLevel.Debug, serviceLogLevel: LogLevel.Debug,
})); }));
serviceLogsMock.mockResolvedValue({ error: "", output: "test logs" }); serviceLogsMock.mockResolvedValue({ error: "", output: "" });
serviceDownMock.mockResolvedValue(); serviceDownMock.mockResolvedValue();
// Act // Act
@ -100,12 +104,11 @@ describe("run", () => {
serviceLogger: debugMock, serviceLogger: debugMock,
}); });
expect(debugMock).toHaveBeenCalledWith("docker compose logs:\ntest logs");
expect(infoMock).toHaveBeenCalledWith("docker compose is down"); expect(infoMock).toHaveBeenCalledWith("docker compose is down");
expect(setFailedMock).not.toHaveBeenCalled(); expect(setFailedMock).not.toHaveBeenCalled();
}); });
it("should log docker composer errors if any", async () => { it("should log docker compose command errors if any", async () => {
// Arrange // Arrange
getInputsMock.mockImplementation(() => ({ getInputsMock.mockImplementation(() => ({
dockerFlags: [], dockerFlags: [],
@ -131,15 +134,40 @@ describe("run", () => {
await run(); await run();
// Assert // Assert
expect(debugMock).toHaveBeenCalledWith("docker compose logs:");
expect(debugMock).toHaveBeenCalledWith( expect(debugMock).toHaveBeenCalledWith(
"docker compose error:\ntest logs error", "docker compose error:\ntest logs error",
); );
expect(debugMock).toHaveBeenCalledWith(
"docker compose logs:\ntest logs output",
);
expect(infoMock).toHaveBeenCalledWith("docker compose is down"); expect(infoMock).toHaveBeenCalledWith("docker compose is down");
}); });
it("should continue cleanup when collecting logs fails", async () => {
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: [],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
serviceLogsMock.mockRejectedValue(new Error("Test logs error"));
serviceDownMock.mockResolvedValue();
await run();
expect(warnMock).toHaveBeenCalledWith(
"Unable to collect docker compose logs before cleanup: Test logs error",
);
expect(serviceDownMock).toHaveBeenCalled();
expect(infoMock).toHaveBeenCalledWith("docker compose is down");
expect(setFailedMock).not.toHaveBeenCalled();
});
it("should set failed when an error occurs", async () => { it("should set failed when an error occurs", async () => {
// Arrange // Arrange
getInputsMock.mockImplementation(() => { getInputsMock.mockImplementation(() => {

View File

@ -15,21 +15,27 @@ export async function run(): Promise<void> {
const inputs = inputService.getInputs(); const inputs = inputService.getInputs();
const { error, output } = await dockerComposeService.logs({ try {
dockerFlags: inputs.dockerFlags, loggerService.debug("docker compose logs:");
composeFiles: inputs.composeFiles,
composeFlags: inputs.composeFlags,
cwd: inputs.cwd,
services: inputs.services,
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel),
});
if (error) { const { error } = await dockerComposeService.logs({
loggerService.debug(`docker compose error:\n${error}`); dockerFlags: inputs.dockerFlags,
composeFiles: inputs.composeFiles,
composeFlags: inputs.composeFlags,
cwd: inputs.cwd,
services: inputs.services,
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel),
});
if (error) {
loggerService.debug(`docker compose error:\n${error}`);
}
} catch (error) {
loggerService.warn(
`Unable to collect docker compose logs before cleanup: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
);
} }
loggerService.debug(`docker compose logs:\n${output}`);
await dockerComposeService.down({ await dockerComposeService.down({
dockerFlags: inputs.dockerFlags, dockerFlags: inputs.dockerFlags,
composeFiles: inputs.composeFiles, composeFiles: inputs.composeFiles,

View File

@ -73,7 +73,7 @@ describe("post", () => {
serviceLogLevel: LogLevel.Debug, serviceLogLevel: LogLevel.Debug,
})); }));
serviceLogsMock.mockResolvedValue({ error: "", output: "test logs" }); serviceLogsMock.mockResolvedValue({ error: "", output: "" });
serviceDownMock.mockResolvedValueOnce(); serviceDownMock.mockResolvedValueOnce();
await import("./post.js"); await import("./post.js");
@ -97,10 +97,6 @@ describe("post", () => {
serviceLogger: debugMock, serviceLogger: debugMock,
}); });
expect(debugMock).toHaveBeenNthCalledWith(
1,
"docker compose logs:\ntest logs",
);
expect(infoMock).toHaveBeenNthCalledWith(1, "docker compose is down"); expect(infoMock).toHaveBeenNthCalledWith(1, "docker compose is down");
expect(setFailedMock).not.toHaveBeenCalled(); expect(setFailedMock).not.toHaveBeenCalled();

View File

@ -1,5 +1,5 @@
import { EventEmitter } from "node:events";
import type { import type {
IDockerComposeLogOptions,
IDockerComposeOptions, IDockerComposeOptions,
IDockerComposeResult, IDockerComposeResult,
} from "docker-compose"; } from "docker-compose";
@ -17,19 +17,16 @@ const upManyMock =
>(); >();
const downMock = const downMock =
vi.fn<(options: IDockerComposeOptions) => Promise<IDockerComposeResult>>(); vi.fn<(options: IDockerComposeOptions) => Promise<IDockerComposeResult>>();
const logsMock = const spawnMock = vi.fn();
vi.fn<
(
services: string[],
options: IDockerComposeLogOptions,
) => Promise<IDockerComposeResult>
>();
vi.doMock("docker-compose", () => ({ vi.doMock("docker-compose", () => ({
upAll: upAllMock, upAll: upAllMock,
upMany: upManyMock, upMany: upManyMock,
down: downMock, down: downMock,
logs: logsMock, }));
vi.doMock("node:child_process", () => ({
spawn: spawnMock,
})); }));
// Dynamic import after mock setup // Dynamic import after mock setup
@ -360,31 +357,164 @@ describe("DockerComposeService", () => {
}); });
describe("logs", () => { describe("logs", () => {
it("should call logs with correct options", async () => { it("should stream logs with wrapper-compatible command arguments", async () => {
const debugMock = vi.fn(); const debugMock = vi.fn();
const logsInputs = { const logsInputs = {
dockerFlags: [] as string[], dockerFlags: ["--context", "dev"] as string[],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: ["helloworld2", "helloworld3"], services: ["helloworld2", "helloworld3"],
composeFlags: [] as string[], composeFlags: ["--profile", "ci"] as string[],
cwd: "/current/working/dir", cwd: "/current/working/dir",
serviceLogger: debugMock, serviceLogger: debugMock,
}; };
logsMock.mockResolvedValue({ exitCode: 0, err: "", out: "logs" }); const stdout = new EventEmitter();
const stderr = new EventEmitter();
const childProcess = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
};
childProcess.stdout = stdout;
childProcess.stderr = stderr;
spawnMock.mockReturnValue(childProcess);
await service.logs(logsInputs); const logsPromise = service.logs(logsInputs);
expect(logsMock).toHaveBeenCalledWith(["helloworld2", "helloworld3"], { expect(spawnMock).toHaveBeenCalledWith(
composeOptions: [], "docker",
config: ["docker-compose.yml"], [
cwd: "/current/working/dir", "--context",
executable: { "dev",
executablePath: "docker", "compose",
options: [], "--profile",
"ci",
"-f",
"docker-compose.yml",
"logs",
"helloworld2",
"helloworld3",
],
{
cwd: "/current/working/dir",
}, },
follow: false, );
callback: expect.any(Function),
stdout.emit("data", Buffer.from("logs"));
stderr.emit("data", Buffer.from("error logs"));
childProcess.emit("close", 0);
await expect(logsPromise).resolves.toEqual({ error: "", output: "" });
expect(debugMock).toHaveBeenNthCalledWith(1, "logs");
expect(debugMock).toHaveBeenNthCalledWith(2, "error logs");
});
it("should return a non-fatal error message when logs command fails", async () => {
const logsInputs = {
dockerFlags: ["--context", "dev"] as string[],
composeFiles: ["docker-compose.yml"] as string[],
services: [] as string[],
composeFlags: ["--profile", "ci"] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
const childProcess = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
};
childProcess.stdout = new EventEmitter();
childProcess.stderr = new EventEmitter();
spawnMock.mockReturnValue(childProcess);
const logsPromise = service.logs(logsInputs);
childProcess.emit("close", 1);
await expect(logsPromise).resolves.toEqual({
error: "Docker Compose logs command failed with exit code 1",
output: "",
});
});
it("should return a non-fatal error message when logs command is terminated by a signal", async () => {
const logsInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"] as string[],
services: [] as string[],
composeFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
const childProcess = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
};
childProcess.stdout = new EventEmitter();
childProcess.stderr = new EventEmitter();
spawnMock.mockReturnValue(childProcess);
const logsPromise = service.logs(logsInputs);
childProcess.emit("close", null, "SIGTERM");
await expect(logsPromise).resolves.toEqual({
error: "Docker Compose logs command failed with signal SIGTERM",
output: "",
});
});
it("should return a non-fatal error message when spawning logs fails", async () => {
const logsInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"] as string[],
services: [] as string[],
composeFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
const childProcess = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
};
childProcess.stdout = new EventEmitter();
childProcess.stderr = new EventEmitter();
spawnMock.mockReturnValue(childProcess);
const logsPromise = service.logs(logsInputs);
childProcess.emit("error", new Error("spawn ENOENT"));
await expect(logsPromise).resolves.toEqual({
error: "Unable to collect docker compose logs: spawn ENOENT",
output: "",
});
});
it("should return a non-fatal error message when output streams are unavailable", async () => {
const logsInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"] as string[],
services: [] as string[],
composeFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
const childProcess = new EventEmitter() as EventEmitter & {
stdout: EventEmitter | null;
stderr: EventEmitter | null;
};
childProcess.stdout = null;
childProcess.stderr = null;
spawnMock.mockReturnValue(childProcess);
await expect(service.logs(logsInputs)).resolves.toEqual({
error:
"Unable to collect docker compose logs: stdout/stderr unavailable",
output: "",
}); });
}); });
}); });

View File

@ -1,9 +1,8 @@
import { spawn } from "node:child_process";
import { import {
down, down,
type IDockerComposeLogOptions,
type IDockerComposeOptions, type IDockerComposeOptions,
type IDockerComposeResult, type IDockerComposeResult,
logs,
upAll, upAll,
upMany, upMany,
} from "docker-compose"; } from "docker-compose";
@ -60,17 +59,62 @@ export class DockerComposeService {
error: string; error: string;
output: string; output: string;
}> { }> {
const options: IDockerComposeLogOptions = { const options = this.getCommonOptions(optionsInputs);
...this.getCommonOptions(optionsInputs), const { executablePath, executableArgs } =
follow: false, this.getDockerComposeCommandExecution("logs", services, options);
};
const { err, out } = await logs(services, options); return new Promise((resolve) => {
let settled = false;
const childProcess = spawn(executablePath, executableArgs, {
cwd: options.cwd,
});
return { childProcess.on("error", (error) => {
error: err, if (settled) {
output: out, return;
}; }
settled = true;
resolve({
error: `Unable to collect docker compose logs: ${error.message}`,
output: "",
});
});
if (!childProcess.stdout || !childProcess.stderr) {
settled = true;
resolve({
error:
"Unable to collect docker compose logs: stdout/stderr unavailable",
output: "",
});
return;
}
childProcess.stdout.on("data", (chunk: Buffer | string) => {
options.callback?.(Buffer.from(chunk), "stdout");
});
childProcess.stderr.on("data", (chunk: Buffer | string) => {
options.callback?.(Buffer.from(chunk), "stderr");
});
childProcess.on("close", (exitCode, signal) => {
if (settled) {
return;
}
settled = true;
resolve({
error: signal
? `Docker Compose logs command failed with signal ${signal}`
: exitCode !== null && exitCode !== 0
? `Docker Compose logs command failed with exit code ${exitCode}`
: "",
output: "",
});
});
});
} }
private getCommonOptions({ private getCommonOptions({
@ -96,16 +140,77 @@ export class DockerComposeService {
* Formats docker-compose errors into proper Error objects with readable messages * Formats docker-compose errors into proper Error objects with readable messages
*/ */
private formatDockerComposeError(error: unknown): Error { private formatDockerComposeError(error: unknown): Error {
// If it's already an Error, return it return new Error(this.getDockerComposeErrorMessage(error));
if (error instanceof Error) { }
return error;
private getDockerComposeCommandExecution(
command: string,
commandArgs: string[],
options: IDockerComposeOptions,
): {
executablePath: string;
executableArgs: string[];
} {
const composeArgs = [
...this.getComposeOptionArgs(options.composeOptions),
...this.getConfigArgs(options.config),
command,
...this.getComposeOptionArgs(options.commandOptions),
...commandArgs,
];
if (options.executable?.standalone) {
return {
executablePath: options.executable.executablePath ?? "docker-compose",
executableArgs: composeArgs,
};
}
return {
executablePath: options.executable?.executablePath ?? "docker",
executableArgs: [
...this.getComposeOptionArgs(options.executable?.options),
"compose",
...composeArgs,
],
};
}
private getConfigArgs(config: IDockerComposeOptions["config"]): string[] {
if (typeof config === "undefined") {
return [];
}
if (typeof config === "string") {
return ["-f", config];
}
return config.flatMap((item) => ["-f", item]);
}
private getComposeOptionArgs(
composeOptions:
| IDockerComposeOptions["composeOptions"]
| IDockerComposeOptions["commandOptions"]
| NonNullable<IDockerComposeOptions["executable"]>["options"],
): string[] {
if (!composeOptions) {
return [];
}
return composeOptions.flatMap((option) =>
Array.isArray(option) ? option : [option],
);
}
private getDockerComposeErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
} }
// Handle docker-compose result objects
if (this.isDockerComposeResult(error)) { if (this.isDockerComposeResult(error)) {
const parts: string[] = []; const parts: string[] = [];
// Add exit code information
if (error.exitCode !== null) { if (error.exitCode !== null) {
parts.push( parts.push(
`Docker Compose command failed with exit code ${error.exitCode}`, `Docker Compose command failed with exit code ${error.exitCode}`,
@ -114,30 +219,25 @@ export class DockerComposeService {
parts.push("Docker Compose command failed"); parts.push("Docker Compose command failed");
} }
// Add error stream output if available
if (error.err?.trim()) { if (error.err?.trim()) {
parts.push("\nError output:"); parts.push("\nError output:");
parts.push(error.err.trim()); parts.push(error.err.trim());
} }
// Add standard output if available and different from error output
if (error.out?.trim() && error.out !== error.err) { if (error.out?.trim() && error.out !== error.err) {
parts.push("\nStandard output:"); parts.push("\nStandard output:");
parts.push(error.out.trim()); parts.push(error.out.trim());
} }
return new Error(parts.join("\n")); return parts.join("\n");
} }
// Handle string errors
if (typeof error === "string") { if (typeof error === "string") {
return new Error(error); return error;
} }
// Fallback for unknown error types return JSON.stringify(error);
return new Error(JSON.stringify(error));
} }
/** /**
* Type guard to check if an object is a docker-compose result * Type guard to check if an object is a docker-compose result
*/ */