Files
simstudioai--sim/apps/sim/tools/azure_devops/azure-devops.test.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

789 lines
26 KiB
TypeScript

/**
* @vitest-environment node
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isAzureDevOpsEventMatch } from '@/triggers/azure_devops/utils'
import { tools } from '../registry'
import type { ToolConfig } from '../types'
import { addCommentTool } from './add_comment'
import { createWorkItemTool } from './create_work_item'
import { getBuildLogTool } from './get_build_log'
import { getBuildTimelineTool } from './get_build_timeline'
import { getCommentsTool } from './get_comments'
import { getPipelineTool } from './get_pipeline'
import { getPipelineRunTool } from './get_pipeline_run'
import { getWorkItemTool } from './get_work_item'
import { getWorkItemsBatchTool } from './get_work_items_batch'
import { getWorkItemsBetweenBuildsTool } from './get_work_items_between_builds'
import { listBuildLogsTool } from './list_build_logs'
import { listBuildsTool } from './list_builds'
import { listPipelineRunsTool } from './list_pipeline_runs'
import { listPipelinesTool } from './list_pipelines'
import { queryWorkItemsTool } from './query_work_items'
import type {
AddCommentParams,
CreateWorkItemParams,
GetBuildLogParams,
GetCommentsParams,
GetPipelineParams,
GetPipelineRunParams,
GetWorkItemParams,
GetWorkItemsBatchParams,
GetWorkItemsBetweenBuildsParams,
ListBuildLogsParams,
ListBuildsParams,
ListPipelineRunsParams,
ListPipelinesParams,
QueryWorkItemsParams,
UpdateWorkItemParams,
} from './types'
import { updateWorkItemTool } from './update_work_item'
const baseParams = {
organization: 'contoso',
project: 'Fabrikam',
accessToken: 'pat-token',
}
const authHeader = `Basic ${Buffer.from(':pat-token').toString('base64')}`
const allTools = [
addCommentTool,
createWorkItemTool,
getBuildLogTool,
getCommentsTool,
getPipelineTool,
getPipelineRunTool,
getWorkItemTool,
getWorkItemsBatchTool,
getWorkItemsBetweenBuildsTool,
listBuildLogsTool,
listBuildsTool,
listPipelineRunsTool,
listPipelinesTool,
queryWorkItemsTool,
updateWorkItemTool,
] as const
function buildUrl<P, R>(tool: ToolConfig<P, R>, params: P): string {
return typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url
}
function buildHeaders<P, R>(tool: ToolConfig<P, R>, params: P): Record<string, string> {
return tool.request.headers(params)
}
function buildBody<P, R>(tool: ToolConfig<P, R>, params: P): unknown {
return tool.request.body?.(params)
}
function responseJson(body: unknown): Response {
return new Response(JSON.stringify(body))
}
const rawWorkItem = {
id: 101,
url: 'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workItems/101',
fields: {
'System.Title': 'SimIntegrationTest Issue',
'System.State': 'Doing',
'System.WorkItemType': 'Issue',
'System.AssignedTo': { displayName: 'Ada Lovelace' },
'System.AreaPath': 'Fabrikam\\Platform',
},
}
const rawComment = {
workItemId: 101,
commentId: 9,
version: 1,
text: 'SimIntegrationTest comment',
renderedText: '<p>SimIntegrationTest comment</p>',
createdBy: { displayName: 'Ada Lovelace' },
createdDate: '2026-05-15T10:00:00Z',
modifiedBy: { displayName: 'Ada Lovelace' },
modifiedDate: '2026-05-15T10:00:00Z',
isDeleted: false,
url: 'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems/101/comments/9',
id: 9,
}
describe('Azure DevOps tool contracts', () => {
it('exports and registers the full planned tool surface', () => {
const expectedIds = [
'azure_devops_add_comment',
'azure_devops_create_work_item',
'azure_devops_get_build_log',
'azure_devops_get_comments',
'azure_devops_get_pipeline',
'azure_devops_get_pipeline_run',
'azure_devops_get_work_item',
'azure_devops_get_work_items_batch',
'azure_devops_get_work_items_between_builds',
'azure_devops_list_build_logs',
'azure_devops_list_builds',
'azure_devops_list_pipeline_runs',
'azure_devops_list_pipelines',
'azure_devops_query_work_items',
'azure_devops_update_work_item',
]
expect(allTools.map((tool) => tool.id).sort()).toEqual(expectedIds)
for (const id of expectedIds) {
expect(tools[id]?.id).toBe(id)
}
})
it('sets Basic PAT auth on every tool', () => {
for (const tool of allTools) {
expect(
buildHeaders(tool, {
...baseParams,
pipelineId: 1,
runId: 2,
buildId: 3,
logId: 4,
fromBuildId: 5,
toBuildId: 6,
workItemId: 7,
ids: '7',
wiqlQuery: 'SELECT [System.Id] FROM workitems',
workItemType: 'Issue',
title: 'Issue title',
text: 'Comment text',
}).Authorization
).toBe(authHeader)
}
})
})
describe('Azure DevOps request builders', () => {
it('builds pipeline URLs and optional params', () => {
expect(buildUrl(listPipelinesTool, baseParams)).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/pipelines?api-version=7.2-preview.1'
)
expect(
buildUrl(listPipelinesTool, {
...baseParams,
orderBy: 'name',
top: 10,
continuationToken: 'next-page',
} satisfies ListPipelinesParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/pipelines?api-version=7.2-preview.1&orderBy=name&%24top=10&continuationToken=next-page'
)
expect(
buildUrl(getPipelineTool, {
...baseParams,
pipelineId: 42,
pipelineVersion: 3,
} satisfies GetPipelineParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/pipelines/42?api-version=7.2-preview.1&pipelineVersion=3'
)
expect(
buildUrl(listPipelineRunsTool, {
...baseParams,
pipelineId: 42,
} satisfies ListPipelineRunsParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/pipelines/42/runs?api-version=7.2-preview.1'
)
expect(
buildUrl(getPipelineRunTool, {
...baseParams,
pipelineId: 42,
runId: 99,
} satisfies GetPipelineRunParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/pipelines/42/runs/99?api-version=7.2-preview.1'
)
})
it('builds build URLs and optional filters', () => {
expect(
buildUrl(listBuildsTool, {
...baseParams,
definitionIds: '1,2',
top: 20,
statusFilter: 'completed',
resultFilter: 'failed',
branchName: 'refs/heads/main',
} satisfies ListBuildsParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/build/builds?api-version=7.2-preview.8&definitions=1%2C2&%24top=20&statusFilter=completed&resultFilter=failed&branchName=refs%2Fheads%2Fmain'
)
expect(
buildUrl(listBuildLogsTool, {
...baseParams,
buildId: 101,
} satisfies ListBuildLogsParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/build/builds/101/logs?api-version=7.2-preview.2'
)
expect(
buildUrl(getBuildLogTool, {
...baseParams,
buildId: 101,
logId: 3,
startLine: 5,
endLine: 15,
} satisfies GetBuildLogParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/build/builds/101/logs/3?api-version=7.2-preview.2&startLine=5&endLine=15'
)
expect(buildHeaders(getBuildLogTool, { ...baseParams, buildId: 101, logId: 3 }).Accept).toBe(
'text/plain'
)
})
it('uses the documented work-items-between-builds endpoint shape', () => {
expect(
buildUrl(getWorkItemsBetweenBuildsTool, {
...baseParams,
fromBuildId: 11,
toBuildId: 12,
} satisfies GetWorkItemsBetweenBuildsParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/build/workitems?fromBuildId=11&toBuildId=12&api-version=7.2-preview.2'
)
})
it('builds work item URLs and bodies', () => {
expect(buildUrl(queryWorkItemsTool, baseParams)).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/wit/wiql?api-version=7.2-preview.2'
)
expect(
buildBody(queryWorkItemsTool, {
...baseParams,
wiqlQuery: 'SELECT [System.Id] FROM workitems',
} satisfies QueryWorkItemsParams)
).toEqual({ query: 'SELECT [System.Id] FROM workitems' })
expect(
buildUrl(getWorkItemTool, {
...baseParams,
workItemId: 101,
} satisfies GetWorkItemParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems/101?%24expand=all&api-version=7.2-preview.3'
)
expect(
buildUrl(getWorkItemsBatchTool, {
...baseParams,
ids: '101,102',
} satisfies GetWorkItemsBatchParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems?ids=101%2C102&%24expand=all&api-version=7.2-preview.3'
)
})
it('builds JSON Patch work item write requests', () => {
const createParams = {
...baseParams,
workItemType: 'Issue',
title: 'Pipeline failure',
description: '<p>Failure details</p>',
assignedTo: 'ada@example.com',
areaPath: 'Fabrikam\\Platform',
} satisfies CreateWorkItemParams
expect(buildUrl(createWorkItemTool, createParams)).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems/$Issue?api-version=7.2-preview.3'
)
expect(buildHeaders(createWorkItemTool, createParams)['Content-Type']).toBe(
'application/json-patch+json'
)
expect(buildBody(createWorkItemTool, createParams)).toEqual([
{ op: 'add', path: '/fields/System.Title', value: 'Pipeline failure' },
{ op: 'add', path: '/fields/System.Description', value: '<p>Failure details</p>' },
{ op: 'add', path: '/fields/System.AssignedTo', value: 'ada@example.com' },
{ op: 'add', path: '/fields/System.AreaPath', value: 'Fabrikam\\Platform' },
])
const updateParams = {
...baseParams,
workItemId: 101,
title: 'Updated pipeline failure',
state: 'Doing',
effort: 5,
} satisfies UpdateWorkItemParams
expect(buildUrl(updateWorkItemTool, updateParams)).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems/101?api-version=7.2-preview.3'
)
expect(buildHeaders(updateWorkItemTool, updateParams)['Content-Type']).toBe(
'application/json-patch+json'
)
expect(buildBody(updateWorkItemTool, updateParams)).toEqual([
{ op: 'replace', path: '/fields/System.Title', value: 'Updated pipeline failure' },
{ op: 'replace', path: '/fields/System.State', value: 'Doing' },
{ op: 'replace', path: '/fields/Microsoft.VSTS.Scheduling.Effort', value: 5 },
])
expect(() => buildBody(updateWorkItemTool, { ...baseParams, workItemId: 101 })).toThrow(
/requires at least one field/
)
const createWithEffortParams = {
...createParams,
effort: 3,
} satisfies CreateWorkItemParams
expect(buildBody(createWorkItemTool, createWithEffortParams)).toContainEqual({
op: 'add',
path: '/fields/Microsoft.VSTS.Scheduling.Effort',
value: 3,
})
})
it('emits Epic-only scheduling patch ops on create', () => {
const epicParams = {
...baseParams,
workItemType: 'Epic',
title: 'Q3 platform epic',
startDate: '2026-06-01T00:00:00Z',
targetDate: '2026-09-30T00:00:00Z',
} satisfies CreateWorkItemParams
const body = buildBody(createWorkItemTool, epicParams)
expect(body).toContainEqual({
op: 'add',
path: '/fields/Microsoft.VSTS.Scheduling.StartDate',
value: '2026-06-01T00:00:00Z',
})
expect(body).toContainEqual({
op: 'add',
path: '/fields/Microsoft.VSTS.Scheduling.TargetDate',
value: '2026-09-30T00:00:00Z',
})
})
it('emits Task-only activity/work patch ops on create', () => {
const taskParams = {
...baseParams,
workItemType: 'Task',
title: 'Wire up retries',
activity: 'Development',
remainingWork: 4,
completedWork: 1,
} satisfies CreateWorkItemParams
const body = buildBody(createWorkItemTool, taskParams)
expect(body).toContainEqual({
op: 'add',
path: '/fields/Microsoft.VSTS.Common.Activity',
value: 'Development',
})
expect(body).toContainEqual({
op: 'add',
path: '/fields/Microsoft.VSTS.Scheduling.RemainingWork',
value: 4,
})
expect(body).toContainEqual({
op: 'add',
path: '/fields/Microsoft.VSTS.Scheduling.CompletedWork',
value: 1,
})
})
it('emits per-type replace ops on update when fields are provided', () => {
const updateAll = {
...baseParams,
workItemId: 101,
startDate: '2026-06-01T00:00:00Z',
activity: 'Testing',
remainingWork: 2,
} satisfies UpdateWorkItemParams
const body = buildBody(updateWorkItemTool, updateAll)
expect(body).toContainEqual({
op: 'replace',
path: '/fields/Microsoft.VSTS.Scheduling.StartDate',
value: '2026-06-01T00:00:00Z',
})
expect(body).toContainEqual({
op: 'replace',
path: '/fields/Microsoft.VSTS.Common.Activity',
value: 'Testing',
})
expect(body).toContainEqual({
op: 'replace',
path: '/fields/Microsoft.VSTS.Scheduling.RemainingWork',
value: 2,
})
})
it('builds comment URLs and bodies with comment API pinning', () => {
const addParams = {
...baseParams,
workItemId: 101,
text: 'SimIntegrationTest markdown comment',
} satisfies AddCommentParams
expect(buildUrl(addCommentTool, addParams)).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems/101/comments?api-version=7.0-preview.3'
)
expect(buildBody(addCommentTool, addParams)).toEqual({
text: 'SimIntegrationTest markdown comment',
})
expect(
buildUrl(getCommentsTool, {
...baseParams,
workItemId: 101,
top: 2,
continuationToken: 'next',
includeDeleted: true,
expand: 'renderedText',
order: 'desc',
} satisfies GetCommentsParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems/101/comments?api-version=7.2-preview.4&%24top=2&continuationToken=next&includeDeleted=true&%24expand=renderedText&order=desc'
)
})
})
describe('Azure DevOps response transforms', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
it('transforms list pipelines responses and empty results', async () => {
await expect(
listPipelinesTool.transformResponse!(responseJson({ count: 0, value: [] }))
).resolves.toEqual({
success: true,
output: { content: 'No pipelines found.', metadata: { count: 0, pipelines: [] } },
})
const result = await listPipelinesTool.transformResponse!(
responseJson({
value: [{ id: 1, name: 'CI', revision: 2, url: 'https://example/p/1' }],
})
)
expect(result.output.metadata).toEqual({
count: 1,
pipelines: [{ id: 1, name: 'CI', folder: '\\', revision: 2, url: 'https://example/p/1' }],
})
})
it('transforms pipeline detail and run responses with missing optional links', async () => {
const pipeline = await getPipelineTool.transformResponse!(
responseJson({
id: 42,
name: 'CI',
revision: 3,
url: 'https://example/p/42',
configuration: { type: 'yaml', path: '/azure-pipelines.yml' },
})
)
expect(pipeline.output.metadata.pipeline.links.web).toBe('')
expect(pipeline.output.metadata.pipeline.configuration.repository).toBeUndefined()
const runs = await listPipelineRunsTool.transformResponse!(responseJson({ value: [] }))
expect(runs.output).toEqual({
content: 'No pipeline runs found.',
metadata: { count: 0, runs: [] },
})
const run = await getPipelineRunTool.transformResponse!(
responseJson({
id: 99,
name: '20260515.1',
state: 'completed',
result: 'failed',
createdDate: '2026-05-15T10:00:00Z',
finishedDate: '2026-05-15T10:05:00Z',
url: 'https://example/r/99',
pipeline: { id: 42, name: 'CI', revision: 3, url: 'https://example/p/42' },
})
)
expect(run.output.metadata.run.pipeline.folder).toBe('\\')
expect(run.output.metadata.run.result).toBe('failed')
})
it('transforms build and log responses', async () => {
const builds = await listBuildsTool.transformResponse!(
responseJson({
value: [
{
id: 201,
buildNumber: '20260515.1',
status: 'completed',
result: 'failed',
queueTime: '2026-05-15T10:00:00Z',
sourceBranch: 'refs/heads/main',
sourceVersion: 'abc123',
},
],
})
)
expect(builds.output.metadata.builds[0].definition).toEqual({ id: 0, name: '' })
const logs = await listBuildLogsTool.transformResponse!(
responseJson({
count: 1,
value: [
{
id: 3,
type: 'Container',
url: 'https://example/log/3',
lineCount: 25,
createdOn: '2026-05-15T10:00:00Z',
},
],
})
)
expect(logs.output.metadata.logs[0].lineCount).toBe(25)
const log = await getBuildLogTool.transformResponse!(
new Response('line one\nline two\nline three\n')
)
expect(log.output.metadata.lineCount).toBe(3)
await expect(getBuildLogTool.transformResponse!(new Response(' '))).resolves.toEqual({
success: true,
output: { content: 'Log is empty.', metadata: { lineCount: 0 } },
})
})
it('transforms work item references and hydrated work items', async () => {
const betweenBuilds = await getWorkItemsBetweenBuildsTool.transformResponse!(
responseJson({ value: [{ id: 101, url: 'https://example/workitems/101' }] })
)
expect(betweenBuilds.output.metadata.workItems).toEqual([
{ id: '101', url: 'https://example/workitems/101' },
])
const getWorkItem = await getWorkItemTool.transformResponse!(responseJson(rawWorkItem))
expect(getWorkItem.output.metadata.workItem).toEqual({
id: 101,
title: 'SimIntegrationTest Issue',
state: 'Doing',
workItemType: 'Issue',
assignedTo: 'Ada Lovelace',
areaPath: 'Fabrikam\\Platform',
url: 'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workItems/101',
})
const batch = await getWorkItemsBatchTool.transformResponse!(
responseJson({ value: [rawWorkItem] }),
{ ...baseParams, ids: '101' } satisfies GetWorkItemsBatchParams
)
expect(batch.output.metadata.count).toBe(1)
expect(batch.output.metadata.totalRequested).toBe(1)
})
it('hydrates WIQL query results in chunks of 200 IDs', async () => {
const fetchMock = vi
.fn()
.mockImplementation(() => Promise.resolve(responseJson({ value: [rawWorkItem] })))
globalThis.fetch = fetchMock as unknown as typeof fetch
const workItems = Array.from({ length: 201 }, (_, index) => ({
id: index + 1,
url: `https://example/workitems/${index + 1}`,
}))
const result = await queryWorkItemsTool.transformResponse!(responseJson({ workItems }), {
...baseParams,
wiqlQuery: 'SELECT [System.Id] FROM workitems',
} satisfies QueryWorkItemsParams)
expect(fetchMock).toHaveBeenCalledTimes(2)
const firstChunk = new URL(String(fetchMock.mock.calls[0][0]))
const secondChunk = new URL(String(fetchMock.mock.calls[1][0]))
expect(firstChunk.searchParams.get('ids')?.split(',')).toHaveLength(200)
expect(secondChunk.searchParams.get('ids')?.split(',')).toHaveLength(1)
expect(result.output.metadata.totalMatched).toBe(201)
expect(result.output.metadata.workItems).toHaveLength(2)
})
it('throws when Get Work Items Batch is invoked with no valid IDs', () => {
expect(() =>
buildUrl(getWorkItemsBatchTool, {
...baseParams,
ids: ' , , ',
} satisfies GetWorkItemsBatchParams)
).toThrow(/requires at least one work item ID/)
})
it('chunks Get Work Items Batch requests larger than 200 IDs', async () => {
const fetchMock = vi
.fn()
.mockImplementation(() => Promise.resolve(responseJson({ value: [rawWorkItem] })))
globalThis.fetch = fetchMock as unknown as typeof fetch
const ids = Array.from({ length: 350 }, (_, i) => String(i + 1)).join(',')
const result = await getWorkItemsBatchTool.transformResponse!(
responseJson({ value: [rawWorkItem] }),
{ ...baseParams, ids } satisfies GetWorkItemsBatchParams
)
expect(fetchMock).toHaveBeenCalledTimes(1)
const followupChunk = new URL(String(fetchMock.mock.calls[0][0]))
expect(followupChunk.searchParams.get('ids')?.split(',')).toHaveLength(150)
expect(result.output.metadata.totalRequested).toBe(350)
expect(result.output.metadata.workItems).toHaveLength(2)
})
it('throws when WIQL hydration fetch returns a non-OK status', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response('forbidden', { status: 403, statusText: 'Forbidden' }))
globalThis.fetch = fetchMock as unknown as typeof fetch
await expect(
queryWorkItemsTool.transformResponse!(responseJson({ workItems: [{ id: 1, url: 'x' }] }), {
...baseParams,
wiqlQuery: 'SELECT [System.Id] FROM workitems',
} satisfies QueryWorkItemsParams)
).rejects.toThrow(/Failed to hydrate work item details/)
})
it('does not hydrate WIQL empty results', async () => {
const fetchMock = vi.fn()
globalThis.fetch = fetchMock as unknown as typeof fetch
const result = await queryWorkItemsTool.transformResponse!(responseJson({ workItems: [] }), {
...baseParams,
wiqlQuery: 'SELECT [System.Id] FROM workitems WHERE [System.Id] = 0',
} satisfies QueryWorkItemsParams)
expect(fetchMock).not.toHaveBeenCalled()
expect(result.output.metadata).toEqual({ count: 0, workItems: [] })
})
it('transforms create and update work item responses', async () => {
const created = await createWorkItemTool.transformResponse!(responseJson(rawWorkItem))
expect(created.output.content).toContain('Created work item #101')
const updated = await updateWorkItemTool.transformResponse!(responseJson(rawWorkItem))
expect(updated.output.content).toContain('Updated work item #101')
})
it('transforms comment responses and empty comment lists', async () => {
const added = await addCommentTool.transformResponse!(responseJson(rawComment))
expect(added.output.metadata.comment).toEqual({
workItemId: 101,
commentId: 9,
version: 1,
text: 'SimIntegrationTest comment',
renderedText: '<p>SimIntegrationTest comment</p>',
createdBy: 'Ada Lovelace',
createdDate: '2026-05-15T10:00:00Z',
modifiedBy: 'Ada Lovelace',
modifiedDate: '2026-05-15T10:00:00Z',
isDeleted: false,
url: 'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems/101/comments/9',
})
const comments = await getCommentsTool.transformResponse!(
responseJson({
count: 1,
totalCount: 2,
comments: [rawComment],
continuationToken: 'next',
nextPage: 'https://example/next',
})
)
expect(comments.output.metadata.count).toBe(1)
expect(comments.output.metadata.continuationToken).toBe('next')
const empty = await getCommentsTool.transformResponse!(responseJson({ comments: [] }))
expect(empty.output).toEqual({
content: 'No comments found for this work item.',
metadata: { count: 0, totalCount: 0, comments: [] },
})
})
})
describe('Azure DevOps trigger event matching', () => {
const baseBuild = { eventType: 'build.complete' }
const baseWorkItem = { eventType: 'workitem.created' }
it('matches build.complete results case-insensitively including stopped/Failed/Canceled', () => {
for (const result of [
'failed',
'Failed',
'FAILED',
'canceled',
'Canceled',
'cancelled',
'Cancelled',
'stopped',
'Stopped',
'partiallySucceeded',
'PartiallySucceeded',
]) {
expect(
isAzureDevOpsEventMatch('azure_devops_build_failed', {
...baseBuild,
resource: { result },
})
).toBe(true)
}
})
it('does not match successful build.complete payloads', () => {
for (const result of ['succeeded', 'Succeeded', 'inProgress']) {
expect(
isAzureDevOpsEventMatch('azure_devops_build_failed', {
...baseBuild,
resource: { result },
})
).toBe(false)
}
})
it('ignores non-build event types when expecting build.complete', () => {
expect(
isAzureDevOpsEventMatch('azure_devops_build_failed', {
eventType: 'workitem.created',
resource: { result: 'failed' },
})
).toBe(false)
})
it('build timeline includes partiallySucceeded and succeededWithIssues in failedRecords', async () => {
const records = [
{ id: 'a', name: 'Step A', type: 'Task', result: 'succeeded', log: { id: 1 } },
{ id: 'b', name: 'Step B', type: 'Task', result: 'failed', log: { id: 2 } },
{ id: 'c', name: 'Step C', type: 'Task', result: 'partiallySucceeded', log: { id: 3 } },
{ id: 'd', name: 'Step D', type: 'Task', result: 'succeededWithIssues', log: { id: 4 } },
{ id: 'e', name: 'Step E', type: 'Task', result: 'skipped', log: null },
]
const result = await getBuildTimelineTool.transformResponse!(
new Response(JSON.stringify({ records }))
)
const failedIds = result.output.metadata.failedRecords.map((r) => r.id)
expect(failedIds).toEqual(['b', 'c', 'd'])
expect(result.output.metadata.failedCount).toBe(3)
})
it('matches workitem.created and passes through generic webhook', () => {
expect(isAzureDevOpsEventMatch('azure_devops_work_item_created', baseWorkItem)).toBe(true)
expect(isAzureDevOpsEventMatch('azure_devops_work_item_created', baseBuild)).toBe(false)
expect(isAzureDevOpsEventMatch('azure_devops_webhook', { eventType: 'anything' })).toBe(true)
})
it('extractIdempotencyId returns null when subscriptionId or notificationId is missing', async () => {
const { azureDevOpsHandler } = await import('@/lib/webhooks/providers/azure-devops')
expect(azureDevOpsHandler.extractIdempotencyId!({})).toBeNull()
expect(azureDevOpsHandler.extractIdempotencyId!({ subscriptionId: 'sub-1' })).toBeNull()
expect(azureDevOpsHandler.extractIdempotencyId!({ notificationId: 42 })).toBeNull()
expect(
azureDevOpsHandler.extractIdempotencyId!({ subscriptionId: 'sub-1', notificationId: 42 })
).toBe('azure_devops:sub-1:42')
expect(azureDevOpsHandler.extractIdempotencyId!(null)).toBeNull()
})
})