test(#344): Add comprehensive tests for CI operations service
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- Add 52 tests achieving 99.3% coverage
- Test all public methods: getLatestPipeline, getPipeline, waitForPipeline, getPipelineLogs
- Test auto-diagnosis for all failure categories
- Test pipeline parsing and status handling
- Mock ConfigService and child_process exec
- All tests passing with >85% coverage requirement met

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 11:27:35 -06:00
parent a69904a47b
commit e20aea99b9

View File

@@ -0,0 +1,921 @@
import { ConfigService } from "@nestjs/config";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CIOperationsService } from "./ci-operations.service";
import {
CIOperationError,
FailureCategory,
PipelineStatus,
type PipelineWaitOptions,
} from "./types";
// Create mockExecAsync with vi.hoisted to make it available in vi.mock
const { mockExecAsync } = vi.hoisted(() => ({
mockExecAsync: vi.fn(),
}));
// Mock node:util promisify
vi.mock("node:util", () => ({
promisify: vi.fn(() => mockExecAsync),
}));
describe("CIOperationsService", () => {
let service: CIOperationsService;
let mockConfigService: ConfigService;
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Create mock config service
mockConfigService = {
get: vi.fn((key: string, defaultValue?: unknown) => {
if (key === "orchestrator.ci.woodpeckerServer") return "https://ci.example.com";
if (key === "orchestrator.ci.woodpeckerToken") return "test-token";
if (key === "orchestrator.ci.timeout") return defaultValue ?? 1800;
if (key === "orchestrator.ci.pollInterval") return defaultValue ?? 10;
return undefined;
}),
} as unknown as ConfigService;
// Create service with mock
service = new CIOperationsService(mockConfigService);
});
describe("Configuration", () => {
it("should initialize with config service values", () => {
expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.ci.woodpeckerServer");
expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.ci.woodpeckerToken");
expect(service.isConfigured()).toBe(true);
});
it("should fall back to environment variables if config not set", () => {
const configWithoutCI = {
get: vi.fn(() => undefined),
} as unknown as ConfigService;
process.env.WOODPECKER_SERVER = "https://ci.env.com";
process.env.WOODPECKER_TOKEN = "env-token";
const envService = new CIOperationsService(configWithoutCI);
expect(envService.isConfigured()).toBe(true);
// Clean up
delete process.env.WOODPECKER_SERVER;
delete process.env.WOODPECKER_TOKEN;
});
it("should not be configured when missing server", () => {
const noServerConfig = {
get: vi.fn((key: string, defaultValue?: unknown) => {
if (key === "orchestrator.ci.woodpeckerServer") return "";
if (key === "orchestrator.ci.woodpeckerToken") return "test-token";
if (key === "orchestrator.ci.timeout") return defaultValue ?? 1800;
if (key === "orchestrator.ci.pollInterval") return defaultValue ?? 10;
return undefined;
}),
} as unknown as ConfigService;
const noServerService = new CIOperationsService(noServerConfig);
expect(noServerService.isConfigured()).toBe(false);
});
it("should not be configured when missing token", () => {
const noTokenConfig = {
get: vi.fn((key: string, defaultValue?: unknown) => {
if (key === "orchestrator.ci.woodpeckerServer") return "https://ci.example.com";
if (key === "orchestrator.ci.woodpeckerToken") return "";
if (key === "orchestrator.ci.timeout") return defaultValue ?? 1800;
if (key === "orchestrator.ci.pollInterval") return defaultValue ?? 10;
return undefined;
}),
} as unknown as ConfigService;
const noTokenService = new CIOperationsService(noTokenConfig);
expect(noTokenService.isConfigured()).toBe(false);
});
it("should use default timeout and poll interval", () => {
const minimalConfig = {
get: vi.fn((key: string, defaultValue?: unknown) => {
if (key === "orchestrator.ci.woodpeckerServer") return "https://ci.example.com";
if (key === "orchestrator.ci.woodpeckerToken") return "test-token";
return defaultValue;
}),
} as unknown as ConfigService;
const minimalService = new CIOperationsService(minimalConfig);
expect(minimalService.isConfigured()).toBe(true);
// Defaults are verified through behavior in other tests
});
});
describe("isConfigured", () => {
it("should return true when both server and token are set", () => {
expect(service.isConfigured()).toBe(true);
});
it("should return false when not configured", () => {
const emptyConfig = {
get: vi.fn(() => ""),
} as unknown as ConfigService;
const emptyService = new CIOperationsService(emptyConfig);
expect(emptyService.isConfigured()).toBe(false);
});
});
describe("getLatestPipeline", () => {
it("should throw error when not configured", async () => {
const unconfiguredConfig = {
get: vi.fn(() => ""),
} as unknown as ConfigService;
const unconfiguredService = new CIOperationsService(unconfiguredConfig);
await expect(unconfiguredService.getLatestPipeline("owner/repo")).rejects.toThrow(
CIOperationError
);
try {
await unconfiguredService.getLatestPipeline("owner/repo");
} catch (e) {
expect(e).toBeInstanceOf(CIOperationError);
expect((e as CIOperationError).message).toBe("Woodpecker CI is not configured");
expect((e as CIOperationError).operation).toBe("getLatestPipeline");
}
});
it("should return latest pipeline successfully", async () => {
const mockOutput = `Number | Status | Event | Branch | Commit | Author
123 | success | push | main | abc123 | user`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getLatestPipeline("owner/repo");
expect(mockExecAsync).toHaveBeenCalledWith("woodpecker pipeline ls owner/repo --limit 1", {
env: expect.objectContaining({
WOODPECKER_SERVER: "https://ci.example.com",
WOODPECKER_TOKEN: "test-token",
}),
});
expect(result).toEqual({
number: 123,
status: PipelineStatus.SUCCESS,
event: "push",
branch: "main",
commit: "abc123",
});
});
it("should return null when no pipelines found", async () => {
const mockOutput = `Number | Status | Event | Branch | Commit | Author`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getLatestPipeline("owner/repo");
expect(result).toBeNull();
});
it("should handle empty output", async () => {
mockExecAsync.mockResolvedValue({ stdout: "", stderr: "" });
const result = await service.getLatestPipeline("owner/repo");
expect(result).toBeNull();
});
it("should throw CIOperationError on exec failure", async () => {
const execError = new Error("Command failed");
mockExecAsync.mockRejectedValue(execError);
await expect(service.getLatestPipeline("owner/repo")).rejects.toThrow(CIOperationError);
try {
await service.getLatestPipeline("owner/repo");
} catch (e) {
expect(e).toBeInstanceOf(CIOperationError);
expect((e as CIOperationError).message).toContain("Failed to get latest pipeline");
expect((e as CIOperationError).operation).toBe("getLatestPipeline");
expect((e as CIOperationError).cause).toBe(execError);
}
});
});
describe("getPipeline", () => {
it("should throw error when not configured", async () => {
const unconfiguredConfig = {
get: vi.fn(() => ""),
} as unknown as ConfigService;
const unconfiguredService = new CIOperationsService(unconfiguredConfig);
await expect(unconfiguredService.getPipeline("owner/repo", 123)).rejects.toThrow(
CIOperationError
);
try {
await unconfiguredService.getPipeline("owner/repo", 123);
} catch (e) {
expect(e).toBeInstanceOf(CIOperationError);
expect((e as CIOperationError).message).toBe("Woodpecker CI is not configured");
expect((e as CIOperationError).operation).toBe("getPipeline");
}
});
it("should get specific pipeline successfully", async () => {
const mockOutput = `Number: 123
Status: success
Event: push
Branch: main
Commit: abc123
Started: Mon Jan 01 2024 10:00:00 GMT+0000
Finished: Mon Jan 01 2024 10:05:00 GMT+0000`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getPipeline("owner/repo", 123);
expect(mockExecAsync).toHaveBeenCalledWith("woodpecker pipeline info owner/repo 123", {
env: expect.objectContaining({
WOODPECKER_SERVER: "https://ci.example.com",
WOODPECKER_TOKEN: "test-token",
}),
});
expect(result).toEqual({
number: 123,
status: PipelineStatus.SUCCESS,
event: "push",
branch: "main",
commit: "abc123",
started: expect.any(Number),
finished: expect.any(Number),
error: undefined,
});
expect(result.started).toBeGreaterThan(0);
expect(result.finished).toBeGreaterThan(0);
});
it("should handle pipeline with error message", async () => {
const mockOutput = `Number: 123
Status: failure
Event: push
Branch: main
Commit: abc123
Error: Build failed`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getPipeline("owner/repo", 123);
expect(result).toEqual({
number: 123,
status: PipelineStatus.FAILURE,
event: "push",
branch: "main",
commit: "abc123",
error: "Build failed",
});
});
it("should handle pipeline info with minimal fields", async () => {
const mockOutput = `Status: running`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getPipeline("owner/repo", 456);
expect(result).toEqual({
number: 456,
status: PipelineStatus.RUNNING,
event: "",
branch: "",
commit: "",
});
});
it("should throw CIOperationError on exec failure", async () => {
const execError = new Error("Command failed");
mockExecAsync.mockRejectedValue(execError);
await expect(service.getPipeline("owner/repo", 123)).rejects.toThrow(CIOperationError);
try {
await service.getPipeline("owner/repo", 123);
} catch (e) {
expect(e).toBeInstanceOf(CIOperationError);
expect((e as CIOperationError).message).toContain("Failed to get pipeline #123");
expect((e as CIOperationError).operation).toBe("getPipeline");
expect((e as CIOperationError).cause).toBe(execError);
}
});
});
describe("waitForPipeline", () => {
it("should throw error when not configured", async () => {
const unconfiguredConfig = {
get: vi.fn(() => ""),
} as unknown as ConfigService;
const unconfiguredService = new CIOperationsService(unconfiguredConfig);
await expect(unconfiguredService.waitForPipeline("owner/repo", 123)).rejects.toThrow(
CIOperationError
);
try {
await unconfiguredService.waitForPipeline("owner/repo", 123);
} catch (e) {
expect(e).toBeInstanceOf(CIOperationError);
expect((e as CIOperationError).message).toBe("Woodpecker CI is not configured");
expect((e as CIOperationError).operation).toBe("waitForPipeline");
}
});
it("should return success when pipeline succeeds immediately", async () => {
const mockOutput = `Number: 123
Status: success
Event: push
Branch: main
Commit: abc123`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.waitForPipeline("owner/repo", 123);
expect(result).toEqual({
success: true,
pipeline: {
number: 123,
status: PipelineStatus.SUCCESS,
event: "push",
branch: "main",
commit: "abc123",
},
});
expect(mockExecAsync).toHaveBeenCalledTimes(1);
});
it("should poll until pipeline succeeds", async () => {
const runningOutput = `Number: 123
Status: running
Event: push
Branch: main
Commit: abc123`;
const successOutput = `Number: 123
Status: success
Event: push
Branch: main
Commit: abc123`;
mockExecAsync
.mockResolvedValueOnce({ stdout: runningOutput, stderr: "" })
.mockResolvedValueOnce({ stdout: runningOutput, stderr: "" })
.mockResolvedValueOnce({ stdout: successOutput, stderr: "" });
const options: PipelineWaitOptions = {
timeout: 60,
pollInterval: 0.001, // 1ms for testing
};
const result = await service.waitForPipeline("owner/repo", 123, options);
expect(result.success).toBe(true);
expect(mockExecAsync).toHaveBeenCalledTimes(3);
});
it("should fetch logs and diagnose on failure", async () => {
const failureOutput = `Number: 123
Status: failure
Event: push
Branch: main
Commit: abc123`;
const logsOutput = `Error: eslint errors found
src/test.ts:10:5 - error: Missing semicolon`;
mockExecAsync
.mockResolvedValueOnce({ stdout: failureOutput, stderr: "" }) // getPipeline
.mockResolvedValueOnce({ stdout: logsOutput, stderr: "" }); // getPipelineLogs
const result = await service.waitForPipeline("owner/repo", 123);
expect(result.success).toBe(false);
expect(result.logs).toBe(logsOutput);
expect(result.diagnosis).toEqual({
category: FailureCategory.LINT,
message: "Linting errors detected",
suggestion: "Run 'pnpm lint' locally to identify and fix linting issues",
details: expect.stringContaining("error"),
});
expect(mockExecAsync).toHaveBeenCalledWith("woodpecker log show owner/repo 123", {
env: expect.anything(),
});
});
it("should not fetch logs when fetchLogsOnFailure is false", async () => {
const failureOutput = `Number: 123
Status: failure
Event: push
Branch: main
Commit: abc123`;
mockExecAsync.mockResolvedValue({ stdout: failureOutput, stderr: "" });
const options: PipelineWaitOptions = {
fetchLogsOnFailure: false,
};
const result = await service.waitForPipeline("owner/repo", 123, options);
expect(result.success).toBe(false);
expect(result.logs).toBeUndefined();
expect(result.diagnosis).toBeUndefined();
expect(mockExecAsync).toHaveBeenCalledTimes(1); // Only getPipeline, no logs
});
it("should handle killed status", async () => {
const killedOutput = `Number: 123
Status: killed
Event: push
Branch: main
Commit: abc123`;
const logsOutput = `Pipeline cancelled by user`;
mockExecAsync
.mockResolvedValueOnce({ stdout: killedOutput, stderr: "" })
.mockResolvedValueOnce({ stdout: logsOutput, stderr: "" });
const result = await service.waitForPipeline("owner/repo", 123);
expect(result.success).toBe(false);
expect(result.pipeline.status).toBe(PipelineStatus.KILLED);
});
it("should handle error status", async () => {
const errorOutput = `Number: 123
Status: error
Event: push
Branch: main
Commit: abc123`;
mockExecAsync
.mockResolvedValueOnce({ stdout: errorOutput, stderr: "" })
.mockResolvedValueOnce({ stdout: "Error log", stderr: "" });
const result = await service.waitForPipeline("owner/repo", 123);
expect(result.success).toBe(false);
expect(result.pipeline.status).toBe(PipelineStatus.ERROR);
});
it("should timeout when pipeline does not complete", async () => {
const runningOutput = `Number: 123
Status: running
Event: push
Branch: main
Commit: abc123`;
mockExecAsync.mockResolvedValue({ stdout: runningOutput, stderr: "" });
const options: PipelineWaitOptions = {
timeout: 0.01, // 10ms
pollInterval: 0.001, // 1ms
};
await expect(service.waitForPipeline("owner/repo", 123, options)).rejects.toThrow(
CIOperationError
);
try {
await service.waitForPipeline("owner/repo", 123, options);
} catch (e) {
expect(e).toBeInstanceOf(CIOperationError);
expect((e as CIOperationError).message).toContain("did not complete within");
expect((e as CIOperationError).message).toContain("status: running");
expect((e as CIOperationError).operation).toBe("waitForPipeline");
}
});
it("should use custom timeout and poll interval", async () => {
const successOutput = `Number: 123
Status: success
Event: push
Branch: main
Commit: abc123`;
mockExecAsync.mockResolvedValue({ stdout: successOutput, stderr: "" });
const options: PipelineWaitOptions = {
timeout: 3600, // 1 hour
pollInterval: 5, // 5 seconds
};
const result = await service.waitForPipeline("owner/repo", 123, options);
expect(result.success).toBe(true);
});
});
describe("getPipelineLogs", () => {
it("should throw error when not configured", async () => {
const unconfiguredConfig = {
get: vi.fn(() => ""),
} as unknown as ConfigService;
const unconfiguredService = new CIOperationsService(unconfiguredConfig);
await expect(unconfiguredService.getPipelineLogs("owner/repo", 123)).rejects.toThrow(
CIOperationError
);
try {
await unconfiguredService.getPipelineLogs("owner/repo", 123);
} catch (e) {
expect(e).toBeInstanceOf(CIOperationError);
expect((e as CIOperationError).message).toBe("Woodpecker CI is not configured");
expect((e as CIOperationError).operation).toBe("getPipelineLogs");
}
});
it("should get pipeline logs successfully", async () => {
const mockLogs = `Step 1: Build
Building project...
Step 2: Test
Running tests...`;
mockExecAsync.mockResolvedValue({ stdout: mockLogs, stderr: "" });
const result = await service.getPipelineLogs("owner/repo", 123);
expect(mockExecAsync).toHaveBeenCalledWith("woodpecker log show owner/repo 123", {
env: expect.objectContaining({
WOODPECKER_SERVER: "https://ci.example.com",
WOODPECKER_TOKEN: "test-token",
}),
});
expect(result).toBe(mockLogs);
});
it("should get logs for specific step", async () => {
const mockLogs = `Step 2: Test
Running tests...
Test passed`;
mockExecAsync.mockResolvedValue({ stdout: mockLogs, stderr: "" });
const result = await service.getPipelineLogs("owner/repo", 123, "test");
expect(mockExecAsync).toHaveBeenCalledWith("woodpecker log show owner/repo 123 test", {
env: expect.anything(),
});
expect(result).toBe(mockLogs);
});
it("should throw CIOperationError on exec failure", async () => {
const execError = new Error("Command failed");
mockExecAsync.mockRejectedValue(execError);
await expect(service.getPipelineLogs("owner/repo", 123)).rejects.toThrow(CIOperationError);
try {
await service.getPipelineLogs("owner/repo", 123);
} catch (e) {
expect(e).toBeInstanceOf(CIOperationError);
expect((e as CIOperationError).message).toContain("Failed to get logs");
expect((e as CIOperationError).operation).toBe("getPipelineLogs");
expect((e as CIOperationError).cause).toBe(execError);
}
});
});
describe("parsePipelineList", () => {
it("should parse pipeline list with multiple entries", async () => {
const mockOutput = `Number | Status | Event | Branch | Commit | Author
123 | success | push | main | abc123 | user1
122 | failure | push | develop | def456 | user2
121 | running | push | main | ghi789 | user3`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getLatestPipeline("owner/repo");
// We get only the first one due to --limit 1, but parsing should handle multiple
expect(result).toBeDefined();
expect(result?.number).toBe(123);
});
it("should parse pipeline list with different statuses", async () => {
const mockOutput = `Number | Status | Event | Branch | Commit | Author
123 | pending | push | main | abc123 | user1`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getLatestPipeline("owner/repo");
expect(result?.status).toBe(PipelineStatus.PENDING);
});
it("should handle malformed lines in pipeline list", async () => {
const mockOutput = `Number | Status | Event | Branch | Commit | Author
123 | success | push | main | abc123 | user1
invalid line
not enough columns`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getLatestPipeline("owner/repo");
expect(result).toBeDefined();
expect(result?.number).toBe(123);
});
it("should handle non-numeric pipeline numbers", async () => {
const mockOutput = `Number | Status | Event | Branch | Commit | Author
abc | success | push | main | abc123 | user1`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getLatestPipeline("owner/repo");
expect(result).toBeNull(); // Invalid number should be filtered out
});
});
describe("parsePipelineInfo", () => {
it("should parse all status types correctly", async () => {
const statuses = [
{ input: "success", expected: PipelineStatus.SUCCESS },
{ input: "failure", expected: PipelineStatus.FAILURE },
{ input: "failed", expected: PipelineStatus.FAILURE },
{ input: "running", expected: PipelineStatus.RUNNING },
{ input: "pending", expected: PipelineStatus.PENDING },
{ input: "killed", expected: PipelineStatus.KILLED },
{ input: "cancelled", expected: PipelineStatus.KILLED },
{ input: "error", expected: PipelineStatus.ERROR },
{ input: "skipped", expected: PipelineStatus.SKIPPED },
{ input: "blocked", expected: PipelineStatus.BLOCKED },
];
for (const { input, expected } of statuses) {
const mockOutput = `Status: ${input}`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getPipeline("owner/repo", 123);
expect(result.status).toBe(expected);
}
});
it("should handle unknown status", async () => {
const mockOutput = `Status: unknownstatus`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getPipeline("owner/repo", 123);
expect(result.status).toBe(PipelineStatus.PENDING); // Default to pending
});
it("should handle case-insensitive status", async () => {
const mockOutput = `Status: SUCCESS`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getPipeline("owner/repo", 123);
expect(result.status).toBe(PipelineStatus.SUCCESS);
});
it("should handle status with extra whitespace", async () => {
const mockOutput = `Status: success `;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getPipeline("owner/repo", 123);
expect(result.status).toBe(PipelineStatus.SUCCESS);
});
it("should parse timestamps correctly", async () => {
const mockOutput = `Status: success
Started: Mon Jan 01 2024 10:00:00 GMT+0000
Finished: Mon Jan 01 2024 10:05:00 GMT+0000`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getPipeline("owner/repo", 123);
expect(result.started).toBeGreaterThan(0);
expect(result.finished).toBeGreaterThan(0);
expect(Number.isNaN(result.started)).toBe(false);
expect(Number.isNaN(result.finished)).toBe(false);
});
it("should handle invalid timestamps", async () => {
const mockOutput = `Status: success
Started: invalid-date
Finished: also-invalid`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
const result = await service.getPipeline("owner/repo", 123);
expect(isNaN(result.started as number)).toBe(true);
expect(isNaN(result.finished as number)).toBe(true);
});
});
describe("diagnoseFail", () => {
it("should diagnose lint failures", async () => {
const failureOutput = `Status: failure`;
const logsOutput = `Running eslint...
error: Missing semicolon at line 10
error: Unused variable 'x' at line 20`;
mockExecAsync
.mockResolvedValueOnce({ stdout: failureOutput, stderr: "" })
.mockResolvedValueOnce({ stdout: logsOutput, stderr: "" });
const result = await service.waitForPipeline("owner/repo", 123);
expect(result.diagnosis?.category).toBe(FailureCategory.LINT);
expect(result.diagnosis?.message).toBe("Linting errors detected");
expect(result.diagnosis?.suggestion).toContain("pnpm lint");
});
it("should diagnose type check failures", async () => {
const failureOutput = `Status: failure`;
const logsOutput = `Running typescript compiler...
Type error: Argument of type 'string' is not assignable to parameter of type 'number'
error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'
error TS2322: Type 'null' is not assignable to type 'string'`;
mockExecAsync
.mockResolvedValueOnce({ stdout: failureOutput, stderr: "" })
.mockResolvedValueOnce({ stdout: logsOutput, stderr: "" });
const result = await service.waitForPipeline("owner/repo", 123);
expect(result.diagnosis?.category).toBe(FailureCategory.TYPE_CHECK);
expect(result.diagnosis?.message).toBe("TypeScript type errors detected");
expect(result.diagnosis?.suggestion).toContain("pnpm typecheck");
expect(result.diagnosis?.details).toContain("error TS2345");
});
it("should diagnose test failures", async () => {
const failureOutput = `Status: failure`;
const logsOutput = `Running tests...
FAIL src/test.spec.ts
✓ test 1 passed
✗ test 2 failed
✗ test 3 failed`;
mockExecAsync
.mockResolvedValueOnce({ stdout: failureOutput, stderr: "" })
.mockResolvedValueOnce({ stdout: logsOutput, stderr: "" });
const result = await service.waitForPipeline("owner/repo", 123);
expect(result.diagnosis?.category).toBe(FailureCategory.TEST);
expect(result.diagnosis?.message).toBe("Test failures detected");
expect(result.diagnosis?.suggestion).toContain("pnpm test");
expect(result.diagnosis?.details).toContain("FAIL");
});
it("should diagnose build failures", async () => {
const failureOutput = `Status: failure`;
const logsOutput = `Building project...
error: Cannot find module 'missing-package'
build failed with errors`;
mockExecAsync
.mockResolvedValueOnce({ stdout: failureOutput, stderr: "" })
.mockResolvedValueOnce({ stdout: logsOutput, stderr: "" });
const result = await service.waitForPipeline("owner/repo", 123);
expect(result.diagnosis?.category).toBe(FailureCategory.BUILD);
expect(result.diagnosis?.message).toBe("Build errors detected");
expect(result.diagnosis?.suggestion).toContain("pnpm build");
});
it("should diagnose security failures", async () => {
const failureOutput = `Status: failure`;
const logsOutput = `Scanning for secrets...
Found hardcoded secret in file.ts
security check failed`;
mockExecAsync
.mockResolvedValueOnce({ stdout: failureOutput, stderr: "" })
.mockResolvedValueOnce({ stdout: logsOutput, stderr: "" });
const result = await service.waitForPipeline("owner/repo", 123);
expect(result.diagnosis?.category).toBe(FailureCategory.SECURITY);
expect(result.diagnosis?.message).toBe("Security check failed");
expect(result.diagnosis?.suggestion).toContain("remove any hardcoded secrets");
expect(result.diagnosis?.details).toContain("secret");
});
it("should handle unknown failure type", async () => {
const failureOutput = `Status: failure`;
const logsOutput = `Some random error occurred
No pattern match`;
mockExecAsync
.mockResolvedValueOnce({ stdout: failureOutput, stderr: "" })
.mockResolvedValueOnce({ stdout: logsOutput, stderr: "" });
const result = await service.waitForPipeline("owner/repo", 123);
expect(result.diagnosis?.category).toBe(FailureCategory.UNKNOWN);
expect(result.diagnosis?.message).toBe("Pipeline failed with unknown error");
expect(result.diagnosis?.suggestion).toContain("Review full pipeline logs");
});
it("should extract and limit error details", async () => {
const failureOutput = `Status: failure`;
// Create logs with many errors
const errors = Array.from({ length: 15 }, (_, i) => `error line ${i + 1}`).join("\n");
const logsOutput = `Lint errors:\n${errors}`;
mockExecAsync
.mockResolvedValueOnce({ stdout: failureOutput, stderr: "" })
.mockResolvedValueOnce({ stdout: logsOutput, stderr: "" });
const result = await service.waitForPipeline("owner/repo", 123);
expect(result.diagnosis?.category).toBe(FailureCategory.LINT);
// Should only include first 10 errors
const detailLines = result.diagnosis?.details?.split("\n") || [];
expect(detailLines.length).toBeLessThanOrEqual(10);
});
it("should handle empty error extraction", async () => {
const failureOutput = `Status: failure`;
const logsOutput = `Build stopped but no specific matches`;
mockExecAsync
.mockResolvedValueOnce({ stdout: failureOutput, stderr: "" })
.mockResolvedValueOnce({ stdout: logsOutput, stderr: "" });
const result = await service.waitForPipeline("owner/repo", 123);
expect(result.diagnosis?.category).toBe(FailureCategory.UNKNOWN);
expect(result.diagnosis?.details).toBeUndefined();
});
});
describe("execWoodpecker", () => {
it("should execute with correct environment variables", async () => {
const mockOutput = `Number | Status | Event | Branch | Commit`;
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
await service.getLatestPipeline("owner/repo");
expect(mockExecAsync).toHaveBeenCalledWith("woodpecker pipeline ls owner/repo --limit 1", {
env: expect.objectContaining({
WOODPECKER_SERVER: "https://ci.example.com",
WOODPECKER_TOKEN: "test-token",
}),
});
});
it("should include all process.env variables", async () => {
const mockOutput = `Number | Status | Event | Branch | Commit`;
process.env.CUSTOM_VAR = "test-value";
mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" });
await service.getLatestPipeline("owner/repo");
expect(mockExecAsync).toHaveBeenCalledWith("woodpecker pipeline ls owner/repo --limit 1", {
env: expect.objectContaining({
CUSTOM_VAR: "test-value",
}),
});
delete process.env.CUSTOM_VAR;
});
it("should handle stderr in exec errors", async () => {
const execError = Object.assign(new Error("Command failed"), {
stderr: "Error: Repository not found",
});
mockExecAsync.mockRejectedValue(execError);
await expect(service.getLatestPipeline("owner/repo")).rejects.toThrow();
});
it("should handle exec errors without stderr", async () => {
const execError = new Error("Command failed");
mockExecAsync.mockRejectedValue(execError);
await expect(service.getLatestPipeline("owner/repo")).rejects.toThrow();
});
});
});