This Azure DevOps pipeline integrates Virtuoso execution with an Azure DevOps CI/CD workflow. It can start either a Virtuoso plan or orchestration, monitor the execution until completion, collect journey-level results, convert those results to JUnit, publish them to the Azure DevOps Tests tab, and retain detailed JSON reports as pipeline artifacts.
Designed for: Azure DevOps YAML pipelines running on a Microsoft-hosted Ubuntu agent.
What the pipeline does
| Capability | Behavior |
|---|---|
| Execution selection | Uses the executionType parameter to run either a plan or an orchestration. |
| Plan monitoring | Starts a plan, collects all launched job IDs, and polls each job until it reaches a terminal status. |
| Orchestration monitoring | Tracks orchestration nodes and stages, respects configured wait nodes, and detects progress before applying inactivity timeouts. |
| Journey-level reporting | Fetches individual journey outcomes and expands data-driven executions into separate test result rows where supported. |
| Azure DevOps results | Creates JUnit XML and publishes one Azure DevOps test case per journey, with a job-level fallback if journey data is unavailable. |
| Artifacts and links | Publishes execution reports, journey results, and a pipeline summary containing links to Virtuoso executions. |
Execution modes
Plan mode
Plan mode starts the plan configured by PLAN_ID, waits for every generated job to finish, and fails the Azure DevOps job when a Virtuoso job reports a failed or error outcome. After each job completes, the pipeline requests the latest journey status and creates a separate result for each journey. For data-driven journeys, it also attempts to associate each sequence with its data-table values.
Orchestration mode
Orchestration mode starts the orchestration configured by ORCHESTRATION_ID, reads its node topology, and maps runtime node executions back to configured stages and wait nodes. Journey results are labeled with the stage in which they ran. The timeout strategy is progress-aware: active wait nodes can extend the permitted inactivity window, while an absolute runtime cap remains available as a final safeguard.
Note: Select the mode when manually running the Azure DevOps pipeline. The default value is plan.
Prerequisites
- An Azure DevOps project and repository that supports YAML pipelines.
- A valid Virtuoso API token.
- The Virtuoso plan or orchestration identifier to execute.
- Permission to run the selected Virtuoso plan or orchestration and read its execution results.
- An agent with Bash,
curl, andjq. The supplied pipeline usesubuntu-latest, where these tools are normally available.
Create the pipeline manually
Manual setup required
This article intentionally does not provide environment-specific domain buttons or automatic installation links. Copy the YAML from the Complete pipeline YAML section and create an azure-pipelines.yml file in your repository.
- Open the repository that should run the Virtuoso execution.
- Create a file named
azure-pipelines.ymlat the repository root, or in the path used by your Azure DevOps pipeline. - Copy the complete YAML from this article into that file.
- Update the configuration variables at the top of the file.
- Create the required secret variable in Azure DevOps.
- Create a new Azure Pipeline and select the existing YAML file.
- Run the pipeline and choose either
planororchestrationforexecutionType.
Configure the Virtuoso API token
In Azure DevOps, create a secret pipeline variable named VIRTUOSO_TOKEN. The YAML maps this secret to the script environment variable API_TOKEN before making Virtuoso API requests.
Security: Do not paste the token directly into the YAML file, commit it to source control, print it in logs, or store it as a non-secret pipeline variable.
Configuration reference
| Setting | Purpose | When required |
|---|---|---|
API_BASE_URL | Virtuoso API base URL used for all requests. | Both modes |
PLAN_ID | Identifier of the Virtuoso plan to execute. | Plan mode |
PLAN_NAME | Friendly name used in logs, JUnit test-suite output, and Azure DevOps display text. | Both modes in the supplied publishing steps |
ORCHESTRATION_ID | Identifier of the Virtuoso orchestration to execute. | Orchestration mode |
ORCHESTRATION_NAME | Friendly orchestration name used in execution logging and report context. | Orchestration mode |
ENVIRONMENT_ID | Optional Virtuoso environment override. Leave empty to use the configured default. | Optional |
RETRY_DELAY_TIME_SECONDS | Delay between polling attempts and retryable API requests. | Both modes |
VIRTUOSO_TOKEN | Secret Azure DevOps variable containing the Virtuoso bearer token. | Both modes |
Pipeline output
- Azure DevOps Tests tab: JUnit test cases representing individual journeys where journey data is available.
- Execution summary: Markdown links to the corresponding Virtuoso job executions.
- Plan execution report:
plan_execution_report.json. - Journey results:
plan_journeys.json. - JUnit report:
virtuoso-junit.xml, used byPublishTestResults@2.
Result and failure behavior
The execution script deliberately distinguishes between an infrastructure failure and a completed Virtuoso run that contains failed tests. Result-publishing steps use succeededOrFailed(), allowing Azure DevOps to publish JUnit output and artifacts even when the execution step makes the build red because tests failed.
Recommended validation: First test the pipeline with a small plan or orchestration that contains one known passing journey and one known failing journey. Confirm the build result, Tests tab entries, artifacts, and Virtuoso execution links.
Important limitations and operational considerations
- The pipeline depends on the Virtuoso API response structures used by the embedded
jqexpressions. Material API response changes can require updates to the parsing logic. - Journey-level reporting falls back to job-level reporting when journey results cannot be fetched or parsed.
- Plan mode waits for terminal job status and relies on the Azure DevOps job-level
timeoutInMinutesvalue as the backstop for a genuinely stuck run. - Orchestration timeout behavior depends on node progress and configured wait-node timing. Very long orchestrations may require adjustment of the pipeline job timeout and runtime variables present in the script.
- The generated deep links are derived from
API_BASE_URL. Validate the resulting application URL when using a custom, private, or differently named Virtuoso deployment. - Data-table lookup is best-effort. If table schema or values cannot be retrieved, the result can show only the data row or sequence number.
- The YAML writes execution data and generated reports to the build workspace. Review artifact-retention and data-handling policies before using production-like test data.
- The supplied trigger runs on changes to
main. Change or remove the trigger if execution should be manual, scheduled, or limited to another branch.
Complete pipeline YAML
Copy manually: Create an azure-pipelines.yml file and paste the complete content below. Then replace the example IDs and names with values from your Virtuoso project.
View and copy the complete azure-pipelines.yml
# Zendesk Ticket: #4924
# Created by: Suhas, YS
# Modified: 17/07/2026 05:29 PM IST
#
# Pipeline to run a Virtuoso plan or orchestration and push the results back
# to ADO (Tests tab + build artifacts). Use the executionType param to pick
# plan or orchestration.
#
# Need VIRTUOSO_TOKEN added as a secret pipeline variable before running this.
parameters:
- name: executionType
displayName: 'What to execute'
type: string
default: plan
values:
- plan
- orchestration
trigger:
- main
variables:
API_BASE_URL: 'https://api.virtuoso.qa/api/'
# plan mode
PLAN_ID: '2020'
PLAN_NAME: 'AzureDevOps-Testing'
# orchestration mode
ORCHESTRATION_ID: '36301930-f437-4a36-91c5-ed47d074acfc'
ORCHESTRATION_NAME: 'Orchestration'
ENVIRONMENT_ID: ''
RETRY_DELAY_TIME_SECONDS: 10
jobs:
- job: RunVirtuoso
displayName: 'Run Virtuoso Plan/Orchestration'
timeoutInMinutes: 180
pool:
vmImage: 'ubuntu-latest'
steps:
# ---------- PLAN MODE ----------
- ${{ if eq(parameters.executionType, 'plan') }}:
- bash: |
set -euo pipefail
echo "=== Stage: Execute Plan ==="
TOKEN="${API_TOKEN}"
RETRY_DELAY=${RETRY_DELAY_TIME_SECONDS}
if [[ -z "$TOKEN" || "$TOKEN" == "null" ]]; then
echo "Missing API token. Please check the variable API_TOKEN."
exit 1
fi
EXECUTE_URL="${API_BASE_URL}plans/executions/${PLAN_ID}/execute?envelope=false"
# only add environmentId to body if one is actually set
requestBody=""
if [[ -n "$ENVIRONMENT_ID" && "$ENVIRONMENT_ID" != "null" ]]; then
echo "Using environment: ${ENVIRONMENT_ID}"
requestBody="{\"environmentId\": ${ENVIRONMENT_ID}}"
fi
echo "Launching plan: '${PLAN_NAME}'"
echo "POST -> $EXECUTE_URL"
response=$(curl -s -w "%{http_code}" -o response.json \
-H "Authorization: Bearer $TOKEN" \
-H "X-Virtuoso-Client-Name: CICD" \
-H "Content-Type: application/json" \
-X POST \
-d "$requestBody" \
"$EXECUTE_URL")
status_code=$(tail -n1 <<< "$response")
if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then
echo "Failed to start plan (HTTP $status_code)"
cat response.json || true
exit 1
fi
planExecutionId=$(jq -r '.id' response.json)
if [[ -z "$planExecutionId" || "$planExecutionId" == "null" ]]; then
echo "Failed to extract plan execution ID"
exit 1
fi
echo "Plan execution started. ID: $planExecutionId"
jobCount=$(jq '.jobs | length' response.json)
if [[ "$jobCount" -eq 0 ]]; then
echo "No jobs found for the plan execution."
exit 1
fi
jobIds=$(jq -r '.jobs[]?.id // .jobs[].value.id' response.json | xargs)
echo "Launched jobs: ${jobIds}"
echo "--------"
allSuccess=true
# no bash-side timeout here, same reasoning as orchestration mode: a
# job can legitimately run long, so the loop waits for the job's own
# terminal status instead of a guessed cutoff. the pipeline job's
# timeoutInMinutes is the backstop against a genuinely hung run.
for jobId in ${jobIds}; do
echo "Polling job '${jobId}'..."
running=true
while $running; do
statusResp=$(curl -s --fail \
-H "Authorization: Bearer $TOKEN" \
-H "X-Virtuoso-Client-Name: CICD" \
"${API_BASE_URL}executions/${jobId}/status?envelope=false") || true
if [[ -z "$statusResp" ]]; then
echo "Request failed for job '${jobId}'. Retrying in ${RETRY_DELAY}s..."
sleep "$RETRY_DELAY"
continue
fi
jobStatus=$(echo "$statusResp" | jq -r '.status')
outcome=$(echo "$statusResp" | jq -r '.outcome')
echo "Job '${jobId}' status: $jobStatus (outcome: $outcome)"
if [[ "$jobStatus" == "FINISHED" || "$jobStatus" == "FAILED" || "$jobStatus" == "CANCELED" || "$jobStatus" == "ERROR" ]]; then
running=false
if [[ "$outcome" == "FAIL" || "$outcome" == "ERROR" ]]; then
allSuccess=false
fi
# job is done, now go grab the journey-level breakdown for it
echo "Fetching journey results for job '${jobId}'..."
journeysResp=$(curl -s --fail \
-H "Authorization: Bearer $TOKEN" \
-H "X-Virtuoso-Client-Name: CICD" \
"${API_BASE_URL}testsuites/latest_status?jobId=${jobId}&includeSequencesDetails=true&includeTrackingDetails=true&includeJobDefinition=true") || true
if [[ -n "$journeysResp" ]]; then
# projectId comes from the original launch response, sometimes
# it's flat, sometimes nested under value, so check both
jobProjectId=$(jq -r --arg id "$jobId" '
[.jobs[]? | select((.id // .value.id | tostring) == $id)][0]
| (.projectId // .value.projectId // "")
' response.json)
# if it's a data driven journey it can run multiple times (once
# per data row), so expand each row into its own record instead
# of clubbing it all as one result
echo "$journeysResp" | jq -c --arg jobId "$jobId" --arg projectId "$jobProjectId" '
(.map // {}) | to_entries | map(
(.value.lastExecution.sequencesDetails.totalCount // 1) as $total |
(.value.lastExecution.sequencesDetails.failedSequences // []) as $failed |
(.value.lastExecution.statistics.outcome // "UNKNOWN") as $overall |
(.value.journey.goalId // "") as $goalId |
(.value.journey.title // .value.journey.name // .value.journey.canonicalId // .key) as $name |
(.value.journey.dataTableId // null) as $dataTableId |
.key as $suiteId |
[range(1; $total + 1)] | map(. as $seq | {
jobId: $jobId,
projectId: $projectId,
suiteId: $suiteId,
dataTableId: $dataTableId,
goalId: $goalId,
journeyName: $name,
sequence: $seq,
totalSequences: $total,
# if we know exactly which rows failed use that, else just
# go with the overall outcome for the journey
outcome: (if ($failed | length) > 0
then (if ($failed | index($seq)) then $overall else "PASS" end)
else $overall end)
})
) | flatten
' >> journeys_raw.jsonl || echo "Could not parse journey results for job '${jobId}' — job-level result will be used instead."
else
echo "Could not fetch journey results for job '${jobId}' — job-level result will be used instead."
fi
else
sleep "$RETRY_DELAY"
fi
done
echo "--------"
done
# combine everything into one file, if nothing got fetched just write
# an empty array so the next step falls back to job level reporting
if [[ -f journeys_raw.jsonl ]]; then
# resolve each distinct data table referenced by a journey into a
# row -> {columnName: value} map, so a failing row can be tied back
# to the actual data it ran with, not just a bare row number. plan
# mode has no rowId (that's an orchestration-only field), so rows
# are keyed by sequence instead.
echo '{}' > datatable_lookup.json
distinctTableIds=$(jq -r '.[] | select(.dataTableId != null) | .dataTableId' journeys_raw.jsonl 2>/dev/null | sort -un)
for tableId in ${distinctTableIds}; do
schemaResp=$(curl -s --fail \
-H "Authorization: Bearer $TOKEN" \
-H "X-Virtuoso-Client-Name: CICD" \
"${API_BASE_URL}testdata/tables/${tableId}") || true
valuesResp=$(curl -s --fail \
-H "Authorization: Bearer $TOKEN" \
-H "X-Virtuoso-Client-Name: CICD" \
"${API_BASE_URL}testdata/tables/${tableId}/values") || true
if [[ -n "$schemaResp" && -n "$valuesResp" ]]; then
jq -n --argjson schema "$schemaResp" --argjson values "$valuesResp" --arg id "$tableId" --slurpfile existing datatable_lookup.json '
($schema.item.attributes // {}) as $attrs |
($values.map // {}) | to_entries | map(
.key as $rowKey | .value as $row |
{ key: $rowKey, value: ($row | to_entries | map(select(.value != "" and .value != null)) | map({(($attrs[.key].name // .key)): .value}) | add // {}) }
) | from_entries as $rows |
($existing[0] // {}) + { ($id): $rows }
' > datatable_lookup.json.tmp && mv datatable_lookup.json.tmp datatable_lookup.json
else
echo "Could not resolve data table ${tableId} — journey rows using it will show a bare row number only."
fi
done
jq -s --slurpfile datatables datatable_lookup.json '
(($datatables[0]) // {}) as $dt |
(add // []) | map(
. + { dataRow: (if .dataTableId then ($dt[(.dataTableId | tostring)][(.sequence | tostring)] // null) else null end) }
)
' journeys_raw.jsonl > plan_journeys.json
echo "Saved $(jq 'length' plan_journeys.json) journey result(s) to plan_journeys.json"
else
echo "[]" > plan_journeys.json
echo "No journey results were fetched for any job."
fi
echo "Exporting plan execution report..."
resultResp=$(curl -s --fail \
-H "Authorization: Bearer $TOKEN" \
-H "X-Virtuoso-Client-Name: CICD" \
"${API_BASE_URL}plans/executions/status/${planExecutionId}?envelope=false") || true
if [[ -z "$resultResp" ]]; then
echo "Failed to fetch final plan execution report."
exit 1
fi
echo "$resultResp" | jq '.' > plan_execution_report.json
echo "Saved report to plan_execution_report.json"
if [[ "$allSuccess" == "false" ]]; then
echo "One or more jobs failed or errored."
exit 2
fi
echo "All jobs finished successfully!"
displayName: "Execute Plan - $(PLAN_NAME)"
env:
API_TOKEN: $(VIRTUOSO_TOKEN)
# ---------- ORCHESTRATION MODE ----------
- ${{ if eq(parameters.executionType, 'orchestration') }}:
- bash: |
set -euo pipefail
echo "=== Stage: Execute Orchestration ==="
# write placeholders first so the publish steps down below don't break
# even if something fails midway
echo '{"jobs":[]}' > plan_execution_report.json
echo '[]' > plan_journeys.json
TOKEN="${API_TOKEN}"
RETRY_DELAY=${RETRY_DELAY_TIME_SECONDS}
if [[ -z "$TOKEN" || "$TOKEN" == "null" ]]; then
echo "Missing API token. Please check the variable API_TOKEN."
exit 1
fi
if [[ -z "$ORCHESTRATION_ID" || "$ORCHESTRATION_ID" == "null" ]]; then
echo "Missing ORCHESTRATION_ID."
exit 1
fi
EXECUTE_URL="${API_BASE_URL}orchestrations/${ORCHESTRATION_ID}/execute"
requestBody=""
if [[ -n "$ENVIRONMENT_ID" && "$ENVIRONMENT_ID" != "null" ]]; then
echo "Using environment: ${ENVIRONMENT_ID}"
requestBody="{\"environmentId\": ${ENVIRONMENT_ID}}"
fi
echo "Launching orchestration: '${ORCHESTRATION_NAME}'"
echo "POST -> $EXECUTE_URL"
response=$(curl -s -w "%{http_code}" -o response.json \
-H "Authorization: Bearer $TOKEN" \
-H "X-Virtuoso-Client-Name: CICD" \
-H "Content-Type: application/json" \
-X POST \
-d "$requestBody" \
"$EXECUTE_URL")
status_code=$(tail -n1 <<< "$response")
if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then
echo "Failed to start orchestration (HTTP $status_code)"
cat response.json || true
exit 1
fi
# response is wrapped, the execution id is under .item.id, this is
# NOT the same as the orchestration id we sent in the URL
executionId=$(jq -r '.item.id // .id' response.json)
if [[ -z "$executionId" || "$executionId" == "null" ]]; then
echo "Failed to extract orchestration execution ID"
cat response.json || true
exit 1
fi
echo "Orchestration execution started. ID: $executionId"
# keep hitting /details till the orchestration itself reports a
# terminal status. topology (which nodes exist) comes from
# orchestrationConfig.nodes, first node is always START so we skip it.
# runtime status of each node comes from nodeExecutions, once a node's
# status is not RUNNING/QUEUED anymore we count it as done (shown for
# visibility only — it doesn't decide when to stop, overallStatus does).
#
# no bash-side timeout here: orchestrations can have any number of
# stages and WAIT nodes of any length (WAIT can legit be 60+ mins), so
# any guessed cutoff either fires on a healthy run or just delays the
# same failure at a bigger number. the backstop against a genuinely
# hung run is the pipeline job's own timeoutInMinutes, not a number
# invented here.
DETAILS_URL="${API_BASE_URL}orchestrations/executions/${executionId}/details"
allSuccess=true
while :; do
detailsResp=$(curl -s --fail \
-H "Authorization: Bearer $TOKEN" \
-H "X-Virtuoso-Client-Name: CICD" \
"$DETAILS_URL") || true
if [[ -z "$detailsResp" ]]; then
echo "Details request failed for '${executionId}'. Retrying in ${RETRY_DELAY}s..."
sleep "$RETRY_DELAY"
continue
fi
expectedNodes=$(echo "$detailsResp" | jq '[.item.orchestrationConfig.nodes[]? | select(.type != "START")] | length')
doneNodes=$(echo "$detailsResp" | jq '[.item.nodeExecutions[]? | select(.status != "RUNNING" and .status != "QUEUED")] | length')
overallStatus=$(echo "$detailsResp" | jq -r '.item.status // "UNKNOWN"')
echo "Orchestration '${executionId}': status=${overallStatus}, work nodes done ${doneNodes}/${expectedNodes}"
# the orchestration's own status is the only thing that ends the
# loop, however long it takes to get there
if [[ "$overallStatus" != "RUNNING" && "$overallStatus" != "QUEUED" && "$overallStatus" != "UNKNOWN" ]]; then
echo "$detailsResp" > orchestration_details.json
break
fi
sleep "$RETRY_DELAY"
done
# match each node execution back to its config so we know the node
# type, name, goal, whether it's data driven, and its job ids (only
# STAGE nodes have job ids, WAIT nodes don't)
projectId=$(jq -r '.item.orchestrationConfig.projectId // ""' orchestration_details.json)
jq -c '
(.item.orchestrationConfig.nodes // []) as $nodes |
(.item.nodeExecutions // []) | map(
. as $ne |
($nodes[] | select(.id == $ne.nodeId)) as $cfg |
{
nodeId: $ne.nodeId,
nodeExecutionId: $ne.nodeExecutionId,
type: ($cfg.type // "UNKNOWN"),
name: ($cfg.data.name // $cfg.type // $ne.nodeId),
goalId: ($cfg.data.goalId // ""),
isDataDriven: ($cfg.data.isDataDriven // false),
status: ($ne.status // "UNKNOWN"),
jobIds: ($ne.jobIds // [])
}
)
' orchestration_details.json > orchestration_nodes.json
echo "Node / job-ID classification:"
jq -r '.[] | "- [\(.type)] \(.name) (goal \(.goalId), dataDriven=\(.isDataDriven)) — status=\(.status) — jobIds=[\(.jobIds | map(tostring) | join(", "))]"' orchestration_nodes.json
# if any node failed/errored, fail the whole build
if jq -e '[.[] | select(.status == "FAILED" or .status == "ERROR" or .status == "FAIL")] | length > 0' orchestration_nodes.json >/dev/null; then
allSuccess=false
fi
allJobIds=$(jq -r '[.[].jobIds[]?] | map(tostring) | join(" ")' orchestration_nodes.json)
echo "Stage job IDs: ${allJobIds:-<none>}"
# pull journey-level execution detail straight from the orchestration's
# own per-node endpoint. it already reports retryAttempt/rowId/
# firstAttemptJobId natively per journey execution, so there's no need
# to infer retries from jobId ordering or expand sequence ranges
# ourselves the way testsuites/latest_status forced us to - one call
# per STAGE node covers however many stages this orchestration has
jq -r '.[] | select((.jobIds | length) > 0) | "\(.nodeId)\t\(.name)"' orchestration_nodes.json > stage_nodes.tsv
while IFS=$'\t' read -r nodeId stageName; do
[[ -z "$nodeId" ]] && continue
echo "Fetching journey executions for stage '${stageName}' (node ${nodeId})..."
nodeJourneysResp=$(curl -s --fail \
-H "Authorization: Bearer $TOKEN" \
-H "X-Virtuoso-Client-Name: CICD" \
"${API_BASE_URL}orchestrations/executions/${executionId}/nodes/${nodeId}/journeys?showOnlyFailed=false&envelope=false") || true
if [[ -n "$nodeJourneysResp" ]]; then
echo "$nodeJourneysResp" | jq -c --arg stage "$stageName" --arg projectId "$projectId" '
(.orchestrationJourneyExecutions // []) | map(. + {
stageName: $stage,
projectId: $projectId,
isRetry: ((.retryAttempt // 0) > 0)
})
' >> journeys_raw.jsonl || echo "Could not parse journey executions for stage '${stageName}' — job-level result will be used instead."
else
echo "Could not fetch journey executions for stage '${stageName}' — job-level result will be used instead."
fi
done < stage_nodes.tsv
if [[ -s journeys_raw.jsonl ]]; then
# resolve each distinct data table referenced by a journey into a
# rowId -> {columnName: value} map, so a failing row can be tied
# back to the actual data it ran with, not just a bare row number.
# dataTableId is only present on journeys actually wired to a data
# table (confirmed empirically - it's per-journey, not per-stage)
echo '{}' > datatable_lookup.json
distinctTableIds=$(jq -r '.[] | select(.dataTableId != null) | .dataTableId' journeys_raw.jsonl 2>/dev/null | sort -un)
for tableId in ${distinctTableIds}; do
schemaResp=$(curl -s --fail \
-H "Authorization: Bearer $TOKEN" \
-H "X-Virtuoso-Client-Name: CICD" \
"${API_BASE_URL}testdata/tables/${tableId}") || true
valuesResp=$(curl -s --fail \
-H "Authorization: Bearer $TOKEN" \
-H "X-Virtuoso-Client-Name: CICD" \
"${API_BASE_URL}testdata/tables/${tableId}/values") || true
if [[ -n "$schemaResp" && -n "$valuesResp" ]]; then
jq -n --argjson schema "$schemaResp" --argjson values "$valuesResp" --arg id "$tableId" --slurpfile existing datatable_lookup.json '
($schema.item.attributes // {}) as $attrs |
($values.map // {}) | to_entries | map(
.key as $rowKey | .value as $row |
{ key: $rowKey, value: ($row | to_entries | map(select(.value != "" and .value != null)) | map({(($attrs[.key].name // .key)): .value}) | add // {}) }
) | from_entries as $rows |
($existing[0] // {}) + { ($id): $rows }
' > datatable_lookup.json.tmp && mv datatable_lookup.json.tmp datatable_lookup.json
else
echo "Could not resolve data table ${tableId} — journey rows using it will show rowId only."
fi
done
jq -s --slurpfile datatables datatable_lookup.json '
(($datatables[0]) // {}) as $dt |
(add // []) | map(
. + { dataRow: (if .dataTableId then ($dt[(.dataTableId | tostring)][(.rowId | tostring)] // null) else null end) }
)
' journeys_raw.jsonl > plan_journeys.json
echo "Saved $(jq 'length' plan_journeys.json) journey result(s) to plan_journeys.json"
else
echo "[]" > plan_journeys.json
echo "No journey results were fetched for any stage."
fi
# job level report as a fallback in case journey data comes back empty
jq -c --arg projectId "$projectId" --arg executionId "$executionId" '
def norm(s): if s == "FINISHED" then "PASS" elif s == "FAILED" then "FAIL" else s end;
{
executionId: $executionId,
jobs: [ .[] | select((.jobIds | length) > 0) | . as $n | $n.jobIds[] | {
id: .,
projectId: $projectId,
goalId: $n.goalId,
duration: 0,
outcome: norm($n.status),
journeyStatistics: { outcome: norm($n.status) }
} ]
}
' orchestration_nodes.json > plan_execution_report.json
echo "Saved report to plan_execution_report.json"
if [[ "$allSuccess" == "false" ]]; then
echo "One or more orchestration nodes failed or errored."
exit 2
fi
echo "Orchestration finished successfully!"
displayName: "Execute Orchestration - $(ORCHESTRATION_NAME)"
env:
API_TOKEN: $(VIRTUOSO_TOKEN)
# ---------- RESULTS PUBLISHING (same for both modes) ----------
# succeededOrFailed() matters here, the execute step exits 2 on test
# failures on purpose, but the report files are already written by then, so
# these steps need to run even on a red build.
- bash: |
# prefer journey level results if we have them, else fall back to job
# level (happens if every journey fetch failed for some reason)
if [[ -f plan_journeys.json ]] && [[ "$(jq 'length' plan_journeys.json)" -gt 0 ]]; then
SOURCE="plan_journeys.json"
MODE="journey"
elif [[ -f plan_execution_report.json ]]; then
SOURCE="plan_execution_report.json"
MODE="job"
else
echo "No report produced (plan never launched) — skipping conversion."
exit 0
fi
# api-app2.virtuoso.qa -> app2.virtuoso.qa, so the deep links work
APP_URL=$(echo "$API_BASE_URL" | sed -E 's|api-||; s|/api/?$||')
echo "Deep links will use: $APP_URL (mode: $MODE)"
if [[ "$MODE" == "journey" ]]; then
# orchestration mode gives stageName/journeyName/rowId/retryAttempt
# natively from the node-journeys endpoint; plan mode's flatter
# records simply won't have stageName/rowId/retryAttempt, so those
# pieces just fall away for that source rather than needing a
# separate branch
jq -r --arg app "$APP_URL" --arg plan "$PLAN_NAME" '
. as $j |
([$j[] | select(.outcome != "PASS")] | length) as $f |
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
"<testsuite name=\"Virtuoso — \($plan)\" tests=\"\($j | length)\" failures=\"\($f)\">",
($j[] |
(if .stageName then "\(.stageName) / " else "" end) as $stagePrefix |
(if .rowId then " [row \(.rowId)]"
elif (.totalSequences // 1) > 1 then " [row \(.sequence)/\(.totalSequences)]"
else "" end) as $rowLabel |
(if (.isRetry // false) then " [retry \(.retryAttempt)]" else "" end) as $retryLabel |
"\($stagePrefix)\(.journeyName)\($rowLabel)\($retryLabel) (job-\(.jobId))" as $name |
(if .suiteId then "virtuoso.goal-\(.goalId)-suite-\(.suiteId)" else "virtuoso.goal-\(.goalId)" end) as $classname |
(if .dataRow then (" — data: " + (([.dataRow | to_entries[] | "\(.key)=\(.value)"] | join(", ")) | gsub("\n"; " ") | @html)) else "" end) as $dataDetail |
if .outcome == "PASS"
then " <testcase classname=\"\($classname)\" name=\"\($name)\"/>"
else " <testcase classname=\"\($classname)\" name=\"\($name)\"><failure message=\"outcome: \(.outcome) — \($app)/#/project/\(.projectId)/execution/\(.jobId)\($dataDetail)\"/></testcase>"
end),
"</testsuite>"
' "$SOURCE" > virtuoso-junit.xml
else
# fallback, one testcase per job
jq -r --arg app "$APP_URL" --arg plan "$PLAN_NAME" '
[.jobs[]] as $j |
([$j[] | select((.outcome // .journeyStatistics.outcome // "UNKNOWN") != "PASS")] | length) as $f |
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
"<testsuite name=\"Virtuoso — \($plan)\" tests=\"\($j | length)\" failures=\"\($f)\">",
($j[] | (.outcome // .journeyStatistics.outcome // "UNKNOWN") as $o |
if $o == "PASS"
then " <testcase classname=\"virtuoso.goal-\(.goalId)\" name=\"job-\(.id)\" time=\"\((.duration // 0) / 1000)\"/>"
else " <testcase classname=\"virtuoso.goal-\(.goalId)\" name=\"job-\(.id)\" time=\"\((.duration // 0) / 1000)\"><failure message=\"outcome: \($o) — \($app)/#/project/\(.projectId)/execution/\(.id)\"/></testcase>"
end),
"</testsuite>"
' "$SOURCE" > virtuoso-junit.xml
fi
echo "JUnit results:"
cat virtuoso-junit.xml
displayName: "Convert report to JUnit"
condition: succeededOrFailed()
- task: PublishTestResults@2
displayName: "Publish Virtuoso test results"
condition: succeededOrFailed()
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: 'virtuoso-junit.xml'
testRunTitle: 'Virtuoso — $(PLAN_NAME)'
- bash: |
[[ -f plan_execution_report.json ]] || exit 0
APP_URL=$(echo "$API_BASE_URL" | sed -E 's|api-||; s|/api/?$||')
{
echo "## Virtuoso execution links"
echo ""
if [[ -f plan_journeys.json ]] && [[ "$(jq 'length' plan_journeys.json)" -gt 0 ]]; then
jq -r --arg app "$APP_URL" '.[] |
(if .stageName then "\(.stageName) / " else "" end) as $stagePrefix |
(if .rowId then " [row \(.rowId)]"
elif (.totalSequences // 1) > 1 then " [row \(.sequence)/\(.totalSequences)]"
else "" end) as $row |
(if (.isRetry // false) then " [retry \(.retryAttempt)]" else "" end) as $retry |
(if .dataRow then (" — data: " + (([.dataRow | to_entries[] | "\(.key)=\(.value)"] | join(", ")) | gsub("\n"; " "))) else "" end) as $dataDetail |
"- Job \(.jobId) — journey **\($stagePrefix)\(.journeyName)**\($row)\($retry) (goal \(.goalId)) — **\(.outcome)**\($dataDetail) — [open in Virtuoso](\($app)/#/project/\(.projectId)/execution/\(.jobId))"
' plan_journeys.json
else
jq -r --arg app "$APP_URL" '.jobs[] | "- Job \(.id) (goal \(.goalId)) — **\(.outcome // .journeyStatistics.outcome // "UNKNOWN")** — [open in Virtuoso](\($app)/#/project/\(.projectId)/execution/\(.id))"' plan_execution_report.json
fi
} > virtuoso-links.md
cat virtuoso-links.md
# this shows up as a clickable summary on the pipeline run page
echo "##vso[task.uploadsummary]$PWD/virtuoso-links.md"
displayName: "Publish Virtuoso execution links"
condition: succeededOrFailed()
- task: PublishBuildArtifacts@1
displayName: "Publish Plan Execution Report"
condition: succeededOrFailed()
inputs:
PathtoPublish: "plan_execution_report.json"
ArtifactName: "plan-execution-report"
- task: PublishBuildArtifacts@1
displayName: "Publish Journey Results"
condition: succeededOrFailed()
inputs:
PathtoPublish: "plan_journeys.json"
ArtifactName: "plan-journey-results"
Troubleshooting
The pipeline reports a missing API token
Confirm that the Azure DevOps secret variable is named exactly VIRTUOSO_TOKEN and is available to the pipeline. The script receives it as API_TOKEN.
The execution starts but no journey results appear
Review the Bash task logs for failures while calling testsuites/latest_status or while parsing the response. The pipeline should still publish a job-level fallback result when an execution report exists.
JUnit publishing cannot find the result file
Check whether the execution failed before creating any report. The conversion step skips output when no plan or orchestration report was produced, while PublishTestResults@2 still expects virtuoso-junit.xml. Adjust the publish condition if early launch failures are expected in your workflow.
The pipeline times out during a long execution
Increase timeoutInMinutes for the Azure DevOps job. For orchestration mode, also review the runtime and progress timeout values used in the orchestration section of the YAML.
The execution link opens the wrong Virtuoso application
Verify API_BASE_URL. The YAML derives the application URL by removing the API prefix and trailing /api segment.
Maintenance guidance
- Keep the YAML in source control and review changes through pull requests.
- Rotate the Virtuoso API token according to your security policy.
- Revalidate the pipeline after changing Virtuoso regions, projects, plan IDs, orchestration IDs, or data-table structures.
- Review Azure DevOps task versions and hosted-agent changes periodically.
- Preserve the result-publishing steps with
succeededOrFailed()when modifying execution failure handling.
Article source information
Zendesk ticket: #4924
Created by: Suhas, YS
Comments
0 comments
Please sign in to leave a comment.