diff --git a/apps/orchestrator/src/ci/ci-operations.service.spec.ts b/apps/orchestrator/src/ci/ci-operations.service.spec.ts new file mode 100644 index 0000000..2d0966f --- /dev/null +++ b/apps/orchestrator/src/ci/ci-operations.service.spec.ts @@ -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(); + }); + }); +});