@thecollege/azure-test-track
Version:
Azure DevOps utilities for test plan and test run management
636 lines (514 loc) • 23 kB
JavaScript
const axios = require('axios');
const logger = require('./logger');
const organization = process.env.ADO_ORGANIZATION
const project = process.env.ADO_PROJECT
const personalAccessToken = process.env.ADO_PERSONAL_ACCESS_TOKEN
const companyEmail = process.env.ADO_COMPANY_EMAIL
const headers = {
'Authorization': `Basic ${Buffer.from(':' + personalAccessToken).toString('base64')}`
};
const getPlanIdByName = async (planName) => {
logger.info(`Fetching plan ID for plan with name: ${planName}`);
let continuationToken = null;
const planUrlBase = `https://dev.azure.com/${organization}/${project}/_apis/testplan/plans?api-version=7.1`;
try {
while (true) {
const url = continuationToken ? `${planUrlBase}&continuationToken=${continuationToken}` : planUrlBase;
const response = await axios.get(url, { headers });
const plan = response.data.value.find(p => p.name === planName);
if (plan) {
logger.debug(`Plan found: ${planName} with ID ${plan.id}`);
return plan.id;
}
continuationToken = response.headers['x-ms-continuationtoken'];
if (!continuationToken) {
logger.error(`Plan with name ${planName} not found.`);
return null;
}
}
} catch (error) {
logger.error("Error fetching plan ID:", error.response?.data || error.message);
logger.debug("Error Status:", error.response?.status);
logger.debug("Error Headers:", error.response?.headers);
logger.debug("Full Error:", JSON.stringify(error, null, 2));
logger.debug("URL attempted:", planUrlBase);
logger.debug("Organization:", organization);
logger.debug("Project:", project);
throw new Error("Failed to fetch plan ID");
}
};
const getSuitesByPlanId = async (planId) => {
const suitesUrl = `https://dev.azure.com/${organization}/${project}/_apis/testplan/Plans/${planId}/suites?api-version=7.1`;
try {
const response = await axios.get(suitesUrl, { headers });
logger.debug(`Total suites found in plan with ID ${planId}: ${response.data.value.length}`);
return response.data.value;
} catch (error) {
logger.error("Error fetching suites for plan:", error.response?.data || error.message);
return [];
}
};
const getTestPointByTestCaseId = async (planId, suiteId, testCaseId) => {
const pointsUrl = `https://dev.azure.com/${organization}/${project}/_apis/test/Plans/${planId}/Suites/${suiteId}/points?testCaseId=${testCaseId}&api-version=7.1`;
try {
const response = await axios.get(pointsUrl, { headers });
return response.data.value[0] || null;
} catch (error) {
logger.error("Error fetching Test Point ID:", error.response?.data || error.message);
return null;
}
};
const getAllTestPointsByPlanAndSuite = async (planId, suiteId) => {
const url = `https://dev.azure.com/${organization}/${project}/_apis/test/Plans/${planId}/Suites/${suiteId}/points?api-version=7.1`;
try {
const response = await axios.get(url, { headers });
const testPoints = response.data.value;
return testPoints.map(point => ({
id: point.id,
testCase: {
id: point.testCase.id,
name: point.testCase.name
},
suite: { id: suiteId },
configuration: point.configuration || null
}));
} catch (error) {
logger.error(`Error to get Test Points from suite ${suiteId}:`, error.response?.data || error.message);
return [];
}
};
const getTestPointsData = async (planId, resultData, configurationName = null) => {
if (!planId) return [];
const suites = await getSuitesByPlanId(planId);
const allTestPointsPromises = suites.map(async (suite) => {
return await getAllTestPointsByPlanAndSuite(planId, suite.id);
});
const allTestPoints = (await Promise.all(allTestPointsPromises)).flat();
const testCaseIdsSet = new Set(resultData.map(testCase => testCase.testCaseId.toString()));
let filteredTestPointsData = allTestPoints
.filter(testPoint => testCaseIdsSet.has(testPoint.testCase.id.toString()));
logger.debug(`Total Filtered Test Points retrieved for plan ID ${planId}`, filteredTestPointsData.map(tp => ({ testPointId: tp.id, testCaseId: tp.testCase.id })));
if (configurationName) {
const beforeFilterCount = filteredTestPointsData.length;
const configNames = Array.isArray(configurationName) ? configurationName : [configurationName];
filteredTestPointsData = filteredTestPointsData.filter(testPoint =>
testPoint.configuration && configNames.includes(testPoint.configuration.name)
);
const afterFilterCount = filteredTestPointsData.length;
const configNamesStr = configNames.length > 1 ? `"${configNames.join('", "')}"` : `"${configNames[0]}"`;
logger.info(`Filtering by configuration ${configNamesStr}: ${beforeFilterCount} test points found → ${afterFilterCount} matched`);
logger.debug(`Configuration filter details: BEFORE=${beforeFilterCount} | AFTER=${afterFilterCount} | Filtered out=${beforeFilterCount - afterFilterCount}`);
}
filteredTestPointsData = filteredTestPointsData.map(testPoint => ({
testPointId: testPoint.id,
testCaseId: testPoint.testCase.id,
testCaseTitle: testPoint.testCase.name,
outcome: testPoint.outcome,
suiteId: testPoint.suite.id,
configuration: testPoint.configuration || null
}));
logger.debug(`Filtered ${filteredTestPointsData.length} test points from ${allTestPoints.length} total test points`);
logger.debug("Test points details:", JSON.stringify(filteredTestPointsData, null, 2));
return filteredTestPointsData;
};
const getTestPointIdsFromTestCases = async (planName, testCaseIds) => {
const planId = await getPlanIdByName(planName);
if (!planId) return [];
const url = `https://dev.azure.com/${organization}/${project}/_apis/test/plans/${planId}/testpoints?api-version=7.1`;
try {
const response = await axios.get(url, { headers });
const testPoints = response.data.value;
return testPoints.filter(point => testCaseIds.includes(point.testCase.id));
} catch (error) {
logger.error("Error fetching Test Points:", error.response?.data || error.message);
return [];
}
};
const getAllTestPointsByPlanName = async (planName) => {
const planId = await getPlanIdByName(planName);
if (!planId) return [];
const suites = await getSuitesByPlanId(planId);
const allTestPoints = [];
for (const suite of suites) {
const pointsUrl = `https://dev.azure.com/${organization}/${project}/_apis/test/Plans/${planId}/Suites/${suite.id}/points?api-version=7.1`;
let continuationToken = null;
try {
while (true) {
const url = continuationToken ? `${pointsUrl}&continuationToken=${continuationToken}` : pointsUrl;
const response = await axios.get(url, { headers });
allTestPoints.push(...response.data.value.map(point => ({
testPointId: point.id,
testCaseId: point.testCase.id,
testCaseTitle: point.testCase.name,
outcome: point.outcome,
configuration: point.configuration || null,
suiteId: suite.id
})));
continuationToken = response.headers['x-ms-continuationtoken'];
if (!continuationToken) break; // No more pages
}
} catch (error) {
logger.error(`Error fetching Test Points for Suite ID ${suiteId}:`, error.response?.data || error.message);
}
}
return allTestPoints;
};
const createTestRun = async (runSettings) => {
if (runSettings.testPointsData.length === 0) {
logger.warn("Test Run cannot be created without valid Test Point IDs.");
return;
}
const url = `https://dev.azure.com/${organization}/${project}/_apis/test/runs?api-version=7.1`;
const data = {
name: runSettings.testRunName,
plan: { id: runSettings.planId },
build: { id: runSettings.buildId},
pointIds: runSettings.testPointsData.map(point => point.testPointId),
automated: true,
state: "InProgress"
};
try {
const response = await axios.post(url, data, { headers });
logger.debug("Test Run created successfully:", response.data);
return response.data.id;
} catch (error) {
logger.error("Error creating Test Run:", error.response?.data || error.message);
return null;
}
};
const createTestRunWithoutTests = async () => {
const url = `https://dev.azure.com/${organization}/${project}/_apis/test/runs?api-version=7.1`;
const data = {
name: "PW Automated Test Run",
automated: true,
state: "NotStarted"
};
try {
const response = await axios.post(url, data, { headers });
logger.debug("Test Run created successfully:", response.data);
return response.data.id;
} catch (error) {
logger.error("Error creating Test Run:", error.response?.data || error.message);
return null;
}
};
const getTestResultsFromTestRun = async (runId) => {
const url = `https://dev.azure.com/${organization}/${project}/_apis/test/Runs/${runId}/results?api-version=7.1`;
try {
const response = await axios.get(url, { headers });
const testResults = response.data.value;
logger.debug("Fetched test results:", testResults.length);
return testResults;
} catch (error) {
logger.error("Error fetching test results:", error.response?.data || error.message);
return [];
}
};
const updateTestRunResults = async (runId, newResultsData) => {
const existingResults = await getTestResultsFromTestRun(runId);
logger.debug(`Existing results for Test Run ${runId}:`, existingResults.map(r => ({ testCaseId: r.testCase.id, testPointId: r.testPoint.id })));
// Map by testCaseId to allow multiple test points per test case
const resultsByTestCaseId = {};
existingResults.forEach(result => {
const testCaseId = result.testCase.id;
if (!resultsByTestCaseId[testCaseId]) {
resultsByTestCaseId[testCaseId] = [];
}
resultsByTestCaseId[testCaseId].push({
resultId: result.id,
testCaseRevision: result.testCaseRevision,
testCaseId: result.testCase.id,
testPointId: result.testPoint.id,
testCaseTitle: result.testCaseTitle,
});
});
logger.debug(`Test cases in Test Run ${runId}:`, Object.keys(resultsByTestCaseId).map(tcId => ({
testCaseId: tcId,
testPointsCount: resultsByTestCaseId[tcId].length,
testPointIds: resultsByTestCaseId[tcId].map(r => r.testPointId)
})));
const notFoundTestCaseIds = [];
const resultsPayload = [];
newResultsData.forEach(result => {
const matchedResults = resultsByTestCaseId[result.testCaseId];
if (!matchedResults || matchedResults.length === 0) {
notFoundTestCaseIds.push(result.testCaseId);
return;
}
// Create a result update for EACH test point of this test case
matchedResults.forEach(matchedResult => {
const payloadItem = {
id: matchedResult.resultId,
testCase: { id: matchedResult.testCaseId },
testPoint: { id: matchedResult.testPointId },
outcome: result.outcome,
state: 'Completed',
testCaseRevision: matchedResult.testCaseRevision,
testCaseTitle: matchedResult.testCaseTitle,
};
// Add execution time if available (Azure DevOps expects milliseconds)
if (result.executionTime !== undefined && result.executionTime !== null) {
payloadItem.durationInMs = result.executionTime;
}
resultsPayload.push(payloadItem);
});
});
if (notFoundTestCaseIds.length > 0) {
logger.warn(`Warning: The following test case IDs were not found in Test Run and will be skipped: ${notFoundTestCaseIds.join(', ')}`);
}
if (resultsPayload.length === 0) {
logger.warn("No matching test results to update.");
return;
}
const url = `https://dev.azure.com/${organization}/${project}/_apis/test/Runs/${runId}/results?api-version=7.1`;
logger.debug(`Attempting to update ${resultsPayload.length} test result(s):`);
resultsPayload.forEach(r => {
const timeInfo = r.durationInMs !== undefined ? ` | Duration: ${r.durationInMs}ms` : '';
logger.debug(` → TestCaseId: ${r.testCase.id} | TestPointId: ${r.testPoint.id} | Outcome: ${r.outcome}${timeInfo}`);
});
try {
const response = await axios.patch(url, resultsPayload, { headers });
logger.info(`Test Run results updated successfully! Updated ${resultsPayload.length} test result(s).`);
} catch (error) {
logger.error("Error updating Test Run results:", error.response?.data || error.message);
logger.debug("Payload sent:", JSON.stringify(resultsPayload, null, 2));
}
};
const addTestResults = async (runId, resultData) => {
const url = `https://dev.azure.com/${organization}/${project}/_apis/test/runs/${runId}/results?api-version=7.1`;
const data = resultData.map(result => ({
testCase: { id: result.testCaseId },
outcome: result.outcome,
state: "Completed",
automatedTestName: `Automated Test ${result.testCaseId}`,
TestCaseTitle: `Automated Test ${result.testCaseId}`
}));
try {
const response = await axios.post(url, data, { headers });
logger.debug("Test Results added successfully:", response.data);
} catch (error) {
logger.error("Error adding Test Results:", error.response?.data || error.message);
}
};
const completeTestRun = async (runId, totalDurationMs = 0) => {
const url = `https://dev.azure.com/${organization}/${project}/_apis/test/runs/${runId}?api-version=7.1`;
// Calculate dates based on actual test duration
const completedDate = new Date();
const startedDate = new Date(completedDate.getTime() - totalDurationMs);
const data = {
state: "Completed",
startedDate: startedDate.toISOString(),
completedDate: completedDate.toISOString()
};
try {
await axios.patch(url, data, { headers });
logger.info("Test Run completed successfully.");
} catch (error) {
logger.error("Error completing Test Run:", error.response?.data || error.message);
}
};
const resetAllTestPointsToActiveByPlanId = async (planId, testPoints) => {
for (const testPoint of testPoints) {
const suiteId = testPoint.suiteId;
const testPointId = testPoint.testPointId;
const updateUrl = `https://dev.azure.com/${organization}/${project}/_apis/test/Plans/${planId}/Suites/${suiteId}/points/${testPointId}?api-version=7.1`;
try {
await axios.patch(
updateUrl,
{ resetToActive: true},
{ headers }
);
logger.debug(`Test point ${testPointId} in suite ${suiteId} reset to Active.`);
} catch (error) {
logger.error(`Error resetting test point ${testPointId} in suite ${suiteId}:`, error.response?.data || error.message);
}
}
};
const getTestRunsByBuildId = async (buildId) => {
const minLastUpdatedDate = '2024-11-13T00:00:00Z';
const maxLastUpdatedDate = '2024-11-14T23:59:59Z';
const url = `https://dev.azure.com/${organization}/${project}/_apis/test/runs?minLastUpdatedDate=${minLastUpdatedDate}&maxLastUpdatedDate=${maxLastUpdatedDate}&buildIds=${buildId}&includeRunDetails=true&api-version=7.1`;
try {
const response = await axios.get(url, { headers });
return response.data.value;
} catch (error) {
logger.error("Error fetching Test Runs:", error.response?.data || error.message);
}
};
const getWorkItemById = async (workItemId) => {
const url = `https://dev.azure.com/${organization}/${project}/_apis/wit/workitems?ids=${workItemId}&$expand=all&api-version=7.1`;
try {
const response = await axios.get(url, { headers });
logger.debug(`WorkItem ${workItemId} get successfully.`);
return response.data;
} catch (error) {
logger.error(`Failed to get Work Item ${workItemId}`, error.response?.data || error.message);
throw new Error(`Error getting Work Item: ${error.message}`);
}
};
const associtedTestCaseToAutomation = async (testCaseId, automatedTestCaseName, automationTestType) => {
const url = `https://dev.azure.com/${organization}/${project}/_apis/wit/workitems/${testCaseId}?api-version=7.1`;
const headers = {
Authorization: `Basic ${Buffer.from(`${companyEmail}:` + personalAccessToken).toString('base64')}`,
'Content-Type': 'application/json-patch+json',
};
// The Automation Status is automatically updated to "Automated" once the test case is associated with automation.
// This occurs after the relevant fields (below) in the Associated Automation tab are populated.
const body = [
{
op: "add",
path: "/fields/Microsoft.VSTS.TCM.AutomatedTestId",
value: testCaseId
},
{
op: "add",
path: "/fields/Microsoft.VSTS.TCM.AutomatedTestName",
value: automatedTestCaseName
},
{
op: "add",
path: "/fields/Microsoft.VSTS.TCM.AutomatedTestType",
value: automationTestType
}
];
try {
logger.debug(`Updating field Associated Automation of test case ID ${testCaseId}...`);
const response = await axios.patch(url, body, { headers });
logger.debug(`Associated Automation Fields updated successfully.`);
return response.data;
} catch (error) {
logger.error(`Failed to update Associated Automation for test case ID ${testCaseId}:`, error.response?.data || error.message);
throw new Error(`Error updating Associated Automation: ${error.message}`);
}
};
const updateWorkItemField = async (workItemId, pathToField, valueToUpdate) => {
const url = `https://dev.azure.com/${organization}/${project}/_apis/wit/workitems/${workItemId}?api-version=7.1`;
const headers = {
Authorization: `Basic ${Buffer.from(`${companyEmail}:` + personalAccessToken).toString('base64')}`,
'Content-Type': 'application/json-patch+json',
};
const body = [
{
op: "replace",
path: pathToField,
value: valueToUpdate
}
];
try {
logger.debug(`Updating ${pathToField} for Work Item Id ${workItemId}...`);
const response = await axios.patch(url, body, { headers });
logger.debug(`${pathToField} updated successfully.`);
return response.data;
} catch (error) {
logger.error(`Failed to update ${pathToField} for Work Item Id ${workItemId}:`, error.response?.data || error.message);
throw new Error(`Error updating ${pathToField}: ${error.message}`);
}
};
const createSuiteInPlan = async (planId, suiteName) => {
logger.info(`Creating or fetching suite '${suiteName}' in plan ID: ${planId}`);
const suitesUrl = `https://dev.azure.com/${organization}/${project}/_apis/testplan/plans/${planId}/suites?api-version=7.1`;
try {
const suitesResponse = await axios.get(suitesUrl, { headers });
const existingSuite = suitesResponse.data.value.find(suite => suite.name === suiteName);
if (existingSuite) {
logger.debug(`Suite '${suiteName}' already exists with ID: ${existingSuite.id}`);
return existingSuite.id;
}
logger.debug(`Suite '${suiteName}' not found, proceeding to create it.`);
const rootSuiteId = suitesResponse.data.value[0]?.id;
if (!rootSuiteId) {
throw new Error("Root suite not found for the specified plan.");
}
logger.debug(`Root Suite ID for plan ${planId}: ${rootSuiteId}`);
const createSuiteUrl = `https://dev.azure.com/${organization}/${project}/_apis/testplan/plans/${planId}/suites?api-version=7.1`;
const payload = {
name: suiteName,
parentSuite: { id: rootSuiteId },
suiteType: "staticTestSuite"
};
const createResponse = await axios.post(createSuiteUrl, payload, { headers });
const newSuiteId = createResponse.data.id;
logger.info(`Suite '${suiteName}' created successfully with ID: ${newSuiteId}`);
return newSuiteId;
} catch (error) {
logger.error("Error creating or fetching suite:", error.response?.data || error.message);
throw new Error("Failed to create or fetch suite");
}
};
const createTestCase = async (testName) => {
const url = `https://dev.azure.com/${organization}/${project}/_apis/wit/workitems/$Test%20Case?api-version=7.1`;
const payload = [
{ op: "add", path: "/fields/System.Title", value: testName },
{ op: "add", path: "/fields/Microsoft.VSTS.Common.Priority", value: 2 }
];
const response = await axios.post(url, payload, {
headers: {
...headers,
"Content-Type": "application/json-patch+json"
}
});
logger.debug(`Test case '${testName}' created with ID: ${response.data.id}`);
return response.data.id;
};
const createTestCases = async (testNames) => {
if (!Array.isArray(testNames)) {
throw new Error("O parâmetro 'testNames' precisa ser um array.");
}
const testCaseIds = [];
for (const testName of testNames) {
const testCaseId = await createTestCase(testName);
testCaseIds.push(testCaseId);
}
return testCaseIds;
};
const addTestCasesToSuite = async (planId, suiteId, testCaseIds) => {
const url = `https://dev.azure.com/${organization}/${project}/_apis/testplan/Plans/${planId}/suites/${suiteId}/testcase?api-version=7.1`;
if (!Array.isArray(testCaseIds)) {
throw new Error("The parameter 'testCaseIds' must be an array.");
}
const payload = testCaseIds.map(id => ({
workItem: { id }
}));
try {
const response = await axios.post(url, payload , { headers });
logger.debug(`Test cases added to suite ID ${suiteId}:`, response.data);
return response.data;
} catch (error) {
logger.error("Error adding test cases to suite:", error.response?.data || error.message);
throw error;
}
};
const createTestCasesInSuite = async (planId, suiteId, testNames) => {
if (!Array.isArray(testNames)) {
throw new Error("The parameter 'testNames' must be an array.");
}
const testCaseIds = await createTestCases(testNames);
await addTestCasesToSuite(planId, suiteId, testCaseIds);
logger.info("All test cases created and added to suite successfully.");
return testCaseIds;
};
module.exports = {
getPlanIdByName,
getSuitesByPlanId,
getTestPointByTestCaseId,
getTestPointsData,
getTestPointIdsFromTestCases,
getAllTestPointsByPlanAndSuite,
getAllTestPointsByPlanName,
createTestRun,
createTestRunWithoutTests,
getTestResultsFromTestRun,
updateTestRunResults,
addTestResults,
completeTestRun,
resetAllTestPointsToActiveByPlanId,
getTestRunsByBuildId,
associtedTestCaseToAutomation,
getWorkItemById,
createSuiteInPlan,
createTestCase,
createTestCases,
addTestCasesToSuite,
createTestCasesInSuite,
updateWorkItemField
};