Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/tools/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ async function fetchCombinedResults(topSearchResults: SearchResult[], gitApi: IG
continue;
}

const versionDescriptor = changeId ? { version: changeId, versionType: 2, versionOptions: 0 } : undefined;
const versionDescriptor = { version: changeId, versionType: 2, versionOptions: 0 };

const item = await gitApi.getItem(
repositoryId,
Expand Down
10 changes: 10 additions & 0 deletions test/src/domains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,16 @@ describe("DomainsManager: backward compatibility and domain enabling", () => {
expect(result).toEqual(["all"]);
});

it("returns 'all' when a whitespace-only string is provided", () => {
const result = DomainsManager.parseDomainsInput(" \n\t ");
expect(result).toEqual(["all"]);
});

it("returns 'all' when an empty array is provided", () => {
const result = DomainsManager.parseDomainsInput([]);
expect(result).toEqual(["all"]);
});

it("parses comma-separated string input", () => {
const result = DomainsManager.parseDomainsInput("repositories,pipelines,core");
expect(result).toEqual(["repositories", "pipelines", "core"]);
Expand Down
201 changes: 201 additions & 0 deletions test/src/tools/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ describe("search tools content spotlighting", () => {
return handler;
}

function getInputSchema(toolName: string): any {
const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === toolName);
if (!call) throw new Error(`${toolName} tool not registered`);
const [, , schema] = call;
return schema;
}

function mockSuccessfulResponse(payload: string): jest.Mock {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
Expand All @@ -62,6 +69,16 @@ describe("search tools content spotlighting", () => {
return fetchMock;
}

function mockFailedResponse(status: number, statusText: string): jest.Mock {
const fetchMock = jest.fn().mockResolvedValue({
ok: false,
status,
statusText,
});
global.fetch = fetchMock;
return fetchMock;
}

it("spotlights code search results and fetched file data", async () => {
const gitItem = {
path: "/src/index.ts",
Expand Down Expand Up @@ -95,6 +112,123 @@ describe("search tools content spotlighting", () => {
expect(connectionProvider).toHaveBeenCalled();
});

it("sends code search filters and request metadata", async () => {
const fetchMock = mockSuccessfulResponse(JSON.stringify({ results: [] }));

await getHandler(SEARCH_TOOLS.search_code)({
searchText: "authentication",
project: ["project"],
repository: ["repository"],
path: ["/src"],
branch: ["main"],
includeFacets: true,
skip: 5,
top: 20,
});

expect(fetchMock).toHaveBeenCalledWith("https://almsearch.dev.azure.com/test-org/_apis/search/codesearchresults?api-version=7.2-preview.1", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer fake-token",
"User-Agent": "Jest",
},
body: JSON.stringify({
searchText: "authentication",
includeFacets: true,
$skip: 5,
$top: 20,
filters: {
Project: ["project"],
Repository: ["repository"],
Path: ["/src"],
Branch: ["main"],
},
}),
});
});

it("accepts a single project in the code search schema", () => {
const schema = getInputSchema(SEARCH_TOOLS.search_code);

expect(schema.project.parse("project")).toEqual(["project"]);
});

it("handles code search responses without results", async () => {
const payload = JSON.stringify({ count: 0 });
mockSuccessfulResponse(payload);

const result = await getHandler(SEARCH_TOOLS.search_code)({
searchText: "authentication",
includeFacets: false,
skip: 0,
top: 5,
});

expect(unwrapSpotlightedContent(result.content[0].text, "code search results")).toBe(`${payload}[]`);
expect(mockGitApi.getItem).not.toHaveBeenCalled();
});

it("reports malformed code search results without fetching files", async () => {
const results = [
{ repository: { id: "repository-id" }, path: "/src/index.ts", versions: [{ changeId: "abc123" }] },
{ project: { id: "project-id" }, path: "/src/index.ts", versions: [{ changeId: "abc123" }] },
{ project: { id: "project-id" }, repository: { id: "repository-id" }, versions: [{ changeId: "abc123" }] },
{ project: { id: "project-id" }, repository: { id: "repository-id" }, path: "/src/index.ts" },
{ project: { id: "project-id" }, repository: { id: "repository-id" }, path: "/src/index.ts", versions: [] },
{ project: { id: "project-id" }, repository: { id: "repository-id" }, path: "/src/index.ts", versions: [{}] },
];
const payload = JSON.stringify({ results });
mockSuccessfulResponse(payload);

const result = await getHandler(SEARCH_TOOLS.search_code)({
searchText: "authentication",
includeFacets: false,
skip: 0,
top: 5,
});

const content = unwrapSpotlightedContent(result.content[0].text, "code search results");
const combinedResults = JSON.parse(content.slice(payload.length));
expect(combinedResults).toHaveLength(results.length);
expect(combinedResults).toEqual(
expect.arrayContaining([
expect.objectContaining({
error: expect.stringContaining("Missing projectId, repositoryId, filePath, or changeId"),
}),
])
);
expect(mockGitApi.getItem).not.toHaveBeenCalled();
});

it("includes Git API failures in code search results", async () => {
const searchResult = {
project: { id: "project-id" },
repository: { id: "repository-id" },
path: "/src/index.ts",
versions: [{ changeId: "abc123" }],
};
const payload = JSON.stringify({ results: [searchResult, searchResult] });
mockGitApi.getItem.mockRejectedValueOnce(new Error("Git API failed")).mockRejectedValueOnce("unknown failure");
mockSuccessfulResponse(payload);

const result = await getHandler(SEARCH_TOOLS.search_code)({
searchText: "authentication",
includeFacets: false,
skip: 0,
top: 5,
});

const content = unwrapSpotlightedContent(result.content[0].text, "code search results");
expect(JSON.parse(content.slice(payload.length))).toEqual([{ error: "Git API failed" }, { error: "unknown failure" }]);
});

it("throws when code search fails", async () => {
mockFailedResponse(400, "Bad Request");

await expect(getHandler(SEARCH_TOOLS.search_code)({ searchText: "authentication", includeFacets: false, skip: 0, top: 5 })).rejects.toThrow("Azure DevOps Code Search API error: 400 Bad Request");
});

it("spotlights wiki search results", async () => {
const payload = JSON.stringify({
count: 1,
Expand All @@ -114,6 +248,34 @@ describe("search tools content spotlighting", () => {
expect(result.content[0].text).not.toBe(payload);
});

it("sends wiki search filters", async () => {
const fetchMock = mockSuccessfulResponse(JSON.stringify({ results: [] }));

await getHandler(SEARCH_TOOLS.search_wiki)({
searchText: "instructions",
project: ["project"],
wiki: ["wiki"],
includeFacets: true,
skip: 10,
top: 25,
});

const request = fetchMock.mock.calls[0][1];
expect(JSON.parse(request.body)).toEqual({
searchText: "instructions",
includeFacets: true,
$skip: 10,
$top: 25,
filters: { Project: ["project"], Wiki: ["wiki"] },
});
});

it("throws when wiki search fails", async () => {
mockFailedResponse(403, "Forbidden");

await expect(getHandler(SEARCH_TOOLS.search_wiki)({ searchText: "instructions", includeFacets: false, skip: 0, top: 10 })).rejects.toThrow("Azure DevOps Wiki Search API error: 403 Forbidden");
});

it("spotlights work item search results", async () => {
const payload = JSON.stringify({
count: 1,
Expand All @@ -132,4 +294,43 @@ describe("search tools content spotlighting", () => {
expect(unwrapSpotlightedContent(result.content[0].text, "work item search results")).toBe(payload);
expect(result.content[0].text).not.toBe(payload);
});

it("sends work item search filters", async () => {
const fetchMock = mockSuccessfulResponse(JSON.stringify({ results: [] }));

await getHandler(SEARCH_TOOLS.search_workitem)({
searchText: "security",
project: ["project"],
areaPath: ["project\\area"],
workItemType: ["Bug"],
state: ["Active"],
assignedTo: ["user@example.com"],
includeFacets: true,
skip: 15,
top: 30,
});

const request = fetchMock.mock.calls[0][1];
expect(JSON.parse(request.body)).toEqual({
searchText: "security",
includeFacets: true,
$skip: 15,
$top: 30,
filters: {
"System.TeamProject": ["project"],
"System.AreaPath": ["project\\area"],
"System.WorkItemType": ["Bug"],
"System.State": ["Active"],
"System.AssignedTo": ["user@example.com"],
},
});
});

it("throws when work item search fails", async () => {
mockFailedResponse(500, "Internal Server Error");

await expect(getHandler(SEARCH_TOOLS.search_workitem)({ searchText: "security", includeFacets: false, skip: 0, top: 10 })).rejects.toThrow(
"Azure DevOps Work Item Search API error: 500 Internal Server Error"
);
});
});
15 changes: 15 additions & 0 deletions test/src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ describe("utils", () => {
it("returns an empty array when only the runtime and script are present", () => {
expect(getCliArgs(["node.exe", "dist/index.js"])).toEqual([]);
});

it("uses process.argv when no argument is provided", () => {
const originalArgv = process.argv;
process.argv = ["node", "dist/index.js", "my_org-name", "--authentication", "azcli"];

try {
expect(getCliArgs()).toEqual(["my_org-name", "--authentication", "azcli"]);
} finally {
process.argv = originalArgv;
}
});
});

describe("createEnumMapping", () => {
Expand Down Expand Up @@ -578,4 +589,8 @@ describe("getOrgFromUrl", () => {
expect(getOrgFromUrl("https://dev.azure.com/")).toBeNull();
expect(getOrgFromUrl("https://dev.azure.com")).toBeNull();
});

it("returns null when the legacy host has no organization subdomain", () => {
expect(getOrgFromUrl("https://visualstudio.com")).toBeNull();
});
});
Loading