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(); }); }); });