import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleJobTool } from "@/tools/jobs.js";
import { BoundGoCDClient } from "@/client/gocd-client.js";
import { GocdApiError } from "@/utils/responses.js";
describe("Job Tools", () => {
let mockBoundClient: BoundGoCDClient;
beforeEach(() => {
mockBoundClient = {
getJobHistory: vi.fn(),
getJobInstance: vi.fn(),
listJobArtifacts: vi.fn(),
parseJUnitXml: vi.fn(),
getJobConsoleLog: vi.fn(),
} as unknown as BoundGoCDClient;
});
describe("get_job_history", () => {
it("should get job history with required parameters", async () => {
const mockHistory = {
jobs: [],
pagination: {
offset: 0,
total: 0,
pageSize: 10,
},
};
vi.mocked(mockBoundClient.getJobHistory).mockResolvedValue(mockHistory);
const result = await handleJobTool(mockBoundClient, "get_job_history", {
pipelineName: "build-pipeline",
stageName: "build",
jobName: "test-job",
});
expect(result.isError).toBeUndefined();
expect(result.content[0].text).toBe(JSON.stringify(mockHistory, null, 2));
expect(mockBoundClient.getJobHistory).toHaveBeenCalledWith(
"build-pipeline",
"build",
"test-job",
undefined,
);
});
it("should get job history with pageSize parameter", async () => {
const mockHistory = {
jobs: [],
pagination: {
offset: 0,
total: 0,
pageSize: 20,
},
};
vi.mocked(mockBoundClient.getJobHistory).mockResolvedValue(mockHistory);
const result = await handleJobTool(mockBoundClient, "get_job_history", {
pipelineName: "build-pipeline",
stageName: "build",
jobName: "test-job",
pageSize: 20,
});
expect(result.isError).toBeUndefined();
expect(mockBoundClient.getJobHistory).toHaveBeenCalledWith("build-pipeline", "build", "test-job", 20);
});
it("should reject when required parameters are missing", async () => {
const result = await handleJobTool(mockBoundClient, "get_job_history", {
pipelineName: "build-pipeline",
stageName: "build",
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("jobName");
});
it("should handle errors from client", async () => {
vi.mocked(mockBoundClient.getJobHistory).mockRejectedValue(
new GocdApiError(404, "Not Found", "jobs/build-pipeline/build/test-job/history", "Job not found"),
);
const result = await handleJobTool(mockBoundClient, "get_job_history", {
pipelineName: "build-pipeline",
stageName: "build",
jobName: "test-job",
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("NOT_FOUND");
});
});
describe("get_job_instance", () => {
it("should get job instance with all required parameters", async () => {
const mockJob = {
name: "test-job",
state: "Completed",
result: "Passed",
scheduledDate: 1640000000000,
agentUuid: "agent-123",
originalJobId: null,
rerun: false,
};
vi.mocked(mockBoundClient.getJobInstance).mockResolvedValue(mockJob);
const result = await handleJobTool(mockBoundClient, "get_job_instance", {
pipelineName: "build-pipeline",
pipelineCounter: 10,
stageName: "build",
stageCounter: 1,
jobName: "test-job",
});
expect(result.isError).toBeUndefined();
expect(result.content[0].text).toBe(JSON.stringify(mockJob, null, 2));
expect(mockBoundClient.getJobInstance).toHaveBeenCalledWith("build-pipeline", 10, "build", 1, "test-job");
});
it("should reject when required parameters are missing", async () => {
const result = await handleJobTool(mockBoundClient, "get_job_instance", {
pipelineName: "build-pipeline",
pipelineCounter: 10,
stageName: "build",
stageCounter: 1,
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("jobName");
});
it("should handle authorization errors", async () => {
vi.mocked(mockBoundClient.getJobInstance).mockRejectedValue(
new GocdApiError(401, "Unauthorized", "jobs/build-pipeline/10/build/1/test-job", "Not authorized"),
);
const result = await handleJobTool(mockBoundClient, "get_job_instance", {
pipelineName: "build-pipeline",
pipelineCounter: 10,
stageName: "build",
stageCounter: 1,
jobName: "test-job",
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("UNAUTHORIZED");
});
});
describe("parse_gocd_url", () => {
it("should parse job detail URL successfully", async () => {
const result = await handleJobTool(mockBoundClient, "parse_gocd_url", {
url: "https://gocd.example.com/go/tab/build/detail/MyPipeline/123/BuildStage/1/UnitTests",
});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toEqual({
pipelineName: "MyPipeline",
pipelineCounter: 123,
stageName: "BuildStage",
stageCounter: 1,
jobName: "UnitTests",
});
});
it("should parse stage URL successfully", async () => {
const result = await handleJobTool(mockBoundClient, "parse_gocd_url", {
url: "https://gocd.example.com/go/pipelines/MyPipeline/123/BuildStage/1",
});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toEqual({
pipelineName: "MyPipeline",
pipelineCounter: 123,
stageName: "BuildStage",
stageCounter: 1,
});
});
it("should parse pipeline URL successfully", async () => {
const result = await handleJobTool(mockBoundClient, "parse_gocd_url", {
url: "https://gocd.example.com/go/pipelines/MyPipeline/123",
});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toEqual({
pipelineName: "MyPipeline",
pipelineCounter: 123,
});
});
it("should handle invalid URL", async () => {
const result = await handleJobTool(mockBoundClient, "parse_gocd_url", {
url: "not-a-valid-url",
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Invalid URL format");
});
it("should handle unrecognized GoCD URL format", async () => {
const result = await handleJobTool(mockBoundClient, "parse_gocd_url", {
url: "https://gocd.example.com/go/admin/pipelines",
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Unrecognized GoCD URL format");
});
it("should reject when URL parameter is missing", async () => {
const result = await handleJobTool(mockBoundClient, "parse_gocd_url", {});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("url");
});
});
describe("analyze_job_failures", () => {
it("should analyze job with test failures and console errors", async () => {
const mockJUnitResults = {
suites: [
{
name: "AuthTests",
tests: 10,
failures: 2,
errors: 0,
skipped: 1,
time: 5.2,
testCases: [
{
name: "test_login",
classname: "AuthTests",
time: 0.5,
status: "failed" as const,
failure: {
message: "Expected 200 but got 401",
type: "AssertionError",
content: "Stack trace...",
},
},
],
},
],
summary: {
totalTests: 10,
totalFailures: 2,
totalErrors: 0,
totalSkipped: 1,
totalTime: 5.2,
},
failedTests: [
{
suiteName: "AuthTests",
testName: "test_login",
className: "AuthTests",
message: "Expected 200 but got 401",
type: "AssertionError",
details: "Stack trace...",
},
],
};
const mockConsoleLog = "Error: Build failed\nStack trace...";
vi.mocked(mockBoundClient.parseJUnitXml).mockResolvedValue(mockJUnitResults);
vi.mocked(mockBoundClient.getJobConsoleLog).mockResolvedValue(mockConsoleLog);
const result = await handleJobTool(mockBoundClient, "analyze_job_failures", {
pipelineName: "build-pipeline",
pipelineCounter: 10,
stageName: "build",
stageCounter: 1,
jobName: "test-job",
});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.testFailures).toEqual(mockJUnitResults);
expect(parsed.consoleErrors).toBe(mockConsoleLog);
expect(parsed.summary).toContain("test failures");
expect(parsed.summary).toContain("Console log");
});
it("should handle job with no test failures or console errors", async () => {
vi.mocked(mockBoundClient.parseJUnitXml).mockRejectedValue(new Error("No JUnit files found"));
vi.mocked(mockBoundClient.getJobConsoleLog).mockRejectedValue(new Error("Console not available"));
const result = await handleJobTool(mockBoundClient, "analyze_job_failures", {
pipelineName: "build-pipeline",
pipelineCounter: 10,
stageName: "build",
stageCounter: 1,
jobName: "test-job",
});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.testFailures).toBeUndefined();
expect(parsed.consoleErrors).toBeUndefined();
expect(parsed.summary).toContain("No test reports or logs found");
});
it("should handle job with only test failures", async () => {
const mockJUnitResults = {
suites: [
{
name: "TestSuite",
tests: 5,
failures: 1,
errors: 0,
skipped: 0,
time: 2.5,
testCases: [],
},
],
summary: {
totalTests: 5,
totalFailures: 1,
totalErrors: 0,
totalSkipped: 0,
totalTime: 2.5,
},
failedTests: [],
};
vi.mocked(mockBoundClient.parseJUnitXml).mockResolvedValue(mockJUnitResults);
vi.mocked(mockBoundClient.getJobConsoleLog).mockRejectedValue(new Error("Console not available"));
const result = await handleJobTool(mockBoundClient, "analyze_job_failures", {
pipelineName: "build-pipeline",
pipelineCounter: 10,
stageName: "build",
stageCounter: 1,
jobName: "test-job",
});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.testFailures).toEqual(mockJUnitResults);
expect(parsed.consoleErrors).toBeUndefined();
expect(parsed.summary).toContain("test failures");
});
it("should handle job with only console errors", async () => {
const mockConsoleLog = "Error: Compilation failed";
vi.mocked(mockBoundClient.parseJUnitXml).mockRejectedValue(new Error("No JUnit files"));
vi.mocked(mockBoundClient.getJobConsoleLog).mockResolvedValue(mockConsoleLog);
const result = await handleJobTool(mockBoundClient, "analyze_job_failures", {
pipelineName: "build-pipeline",
pipelineCounter: 10,
stageName: "build",
stageCounter: 1,
jobName: "test-job",
});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.testFailures).toBeUndefined();
expect(parsed.consoleErrors).toBe(mockConsoleLog);
expect(parsed.summary).toContain("Console log");
});
it("should reject when required parameters are missing", async () => {
const result = await handleJobTool(mockBoundClient, "analyze_job_failures", {
pipelineName: "build-pipeline",
pipelineCounter: 10,
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("stageName");
});
it("should try multiple JUnit patterns in order until one succeeds", async () => {
const mockJUnitResults = {
suites: [],
summary: {
totalTests: 0,
totalFailures: 0,
totalErrors: 0,
totalSkipped: 0,
totalTime: 0,
},
failedTests: [],
};
// First two patterns fail, third one succeeds
vi.mocked(mockBoundClient.parseJUnitXml)
.mockRejectedValueOnce(new Error("Not found"))
.mockRejectedValueOnce(new Error("Not found"))
.mockResolvedValueOnce(mockJUnitResults);
vi.mocked(mockBoundClient.getJobConsoleLog).mockResolvedValue("");
const result = await handleJobTool(mockBoundClient, "analyze_job_failures", {
pipelineName: "build-pipeline",
pipelineCounter: 10,
stageName: "build",
stageCounter: 1,
jobName: "test-job",
});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.testFailures).toEqual(mockJUnitResults);
// Should have tried 3 patterns before succeeding
expect(mockBoundClient.parseJUnitXml).toHaveBeenCalledTimes(3);
});
it("should try testoutput/junit.xml pattern for Xola-specific structure", async () => {
const mockJUnitResults = {
suites: [],
summary: {
totalTests: 0,
totalFailures: 0,
totalErrors: 0,
totalSkipped: 0,
totalTime: 0,
},
failedTests: [],
};
vi.mocked(mockBoundClient.parseJUnitXml).mockResolvedValue(mockJUnitResults);
vi.mocked(mockBoundClient.getJobConsoleLog).mockResolvedValue("");
const result = await handleJobTool(mockBoundClient, "analyze_job_failures", {
pipelineName: "build-pipeline",
pipelineCounter: 10,
stageName: "build",
stageCounter: 1,
jobName: "test-job",
});
expect(result.isError).toBeUndefined();
// First pattern tried should be testoutput/junit.xml
expect(mockBoundClient.parseJUnitXml).toHaveBeenCalledWith(
"build-pipeline",
10,
"build",
1,
"test-job",
"testoutput/junit.xml",
);
});
it("should fall back to generic patterns when specific paths fail", async () => {
const mockJUnitResults = {
suites: [],
summary: {
totalTests: 0,
totalFailures: 0,
totalErrors: 0,
totalSkipped: 0,
totalTime: 0,
},
failedTests: [],
};
// Specific patterns count: testoutput, test-results (2), reports (2), target, build = 7 total
// Generic patterns: **/junit.xml, **/*junit*.xml, **/TEST-*.xml
// We want to fail all specific ones and succeed on the first generic one (index 7)
let callCount = 0;
vi.mocked(mockBoundClient.parseJUnitXml).mockImplementation(async (...args) => {
callCount++;
if (callCount <= 7) {
// First 7 calls (specific patterns) should fail
throw new Error("Not found");
}
// 8th call onwards (generic patterns) should succeed
return mockJUnitResults;
});
vi.mocked(mockBoundClient.getJobConsoleLog).mockResolvedValue("");
const result = await handleJobTool(mockBoundClient, "analyze_job_failures", {
pipelineName: "build-pipeline",
pipelineCounter: 10,
stageName: "build",
stageCounter: 1,
jobName: "test-job",
});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.testFailures).toEqual(mockJUnitResults);
// Should have tried 8 patterns total (7 specific + 1 generic that succeeded)
expect(mockBoundClient.parseJUnitXml).toHaveBeenCalledTimes(8);
// The 8th call should be the first generic pattern
const eighthCall = vi.mocked(mockBoundClient.parseJUnitXml).mock.calls[7];
expect(eighthCall[5]).toBe("**/junit.xml");
});
});
describe("unknown tool", () => {
it("should return error for unknown tool name", async () => {
const result = await handleJobTool(mockBoundClient, "unknown_tool", {});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Unknown job tool");
});
});
});