test(#344): Add comprehensive tests for CI operations service
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
921
apps/orchestrator/src/ci/ci-operations.service.spec.ts
Normal file
921
apps/orchestrator/src/ci/ci-operations.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user