Skip to content
Open
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: 2 additions & 0 deletions docs/test-run-tags-and-links.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Python adapters pass them via config so a CI job URL is visible while the run is
| Existing run (`adapterMode=0` / `1`) | Early merge in `get_test_run_id` (startup), not at session end |

Merge keeps existing UI/API tags and links; adds new ones; skips duplicate tag names and link URLs.
Existing links/attachments are read via `GET /api/v2/testRuns/{id}` (TMS 5.8 workaround:
`GET /adapters/testRuns/{id}` returns empty `links`/`attachments`). Tags still come from adapters GET.

## Example

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,55 @@ def get_test_run(self, test_run_id: str) -> TestRunApiResult:

test_run = self.__test_run_api.adapters_test_runs_id_get(id=test_run_id)
if test_run is not None:
# Workaround TMS 5.8: adapters GET returns empty links/attachments.
self.__hydrate_test_run_links_and_attachments_from_v2(test_run)
logging.debug(f"Got testrun: {test_run}")
return test_run

logging.error(f"Test run by id {test_run_id} not found!")
raise Exception(f"Test run by id {test_run_id} not found!")

def __get_test_run_v2_json(self, test_run_id: str) -> dict:
"""Read full test run via GET /api/v2/testRuns/{id} (links/attachments)."""
import json
import ssl
import urllib.request

base = (self.__config.get_url() or "").rstrip("/")
url = f"{base}/api/v2/testRuns/{test_run_id}"
try:
request = urllib.request.Request(
url,
headers={
"Accept": "application/json",
"Authorization": f"PrivateToken {self.__config.get_private_token()}",
},
method="GET",
)
context = None
if not self.__config.get_cert_validation():
context = ssl._create_unverified_context()
with urllib.request.urlopen(request, timeout=10, context=context) as response:
return json.loads(response.read().decode("utf-8")) or {}
except Exception as exc:
logging.debug("v2 getTestRun %s failed: %s", test_run_id, exc)
return {}

def __hydrate_test_run_links_and_attachments_from_v2(self, test_run: TestRunApiResult) -> None:
payload = self.__get_test_run_v2_json(str(test_run.id))
if not payload:
return
try:
test_run.links = Converter.v2_dicts_to_link_api_results(payload.get("links") or [])
test_run.attachments = Converter.v2_dicts_to_attachment_stubs(
payload.get("attachments") or [])
except Exception as exc:
logging.debug(
"Cannot hydrate test run %s links/attachments from v2: %s",
getattr(test_run, "id", None),
exc,
)

@retry
def update_test_run(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,40 @@ def build_create_link_api_model(link: LinkApiResult) -> CreateLinkApiModel:
def build_assign_attachment_api_model(attachment: AttachmentApiResult) -> AssignAttachmentApiModel:
return AssignAttachmentApiModel(id=attachment.id)

@staticmethod
def v2_dicts_to_link_api_results(items: list) -> List[LinkApiResult]:
"""Map GET /api/v2/testRuns links payloads to LinkApiResult."""
result = []
for item in items or []:
if not isinstance(item, dict):
continue
url = item.get("url")
if not url:
continue
link_type = item.get("type") or "Related"
result.append(LinkApiResult(
url=url,
type=LinkType(link_type),
id=item.get("id"),
title=item.get("title"),
description=item.get("description"),
))
return result

@staticmethod
def v2_dicts_to_attachment_stubs(items: list) -> list:
"""Map v2 attachments to objects with .id (enough for AssignAttachmentApiModel)."""
from types import SimpleNamespace

result = []
for item in items or []:
if not isinstance(item, dict):
continue
attachment_id = item.get("id")
if attachment_id:
result.append(SimpleNamespace(id=attachment_id))
return result

@staticmethod
@adapter_logger
def project_id_and_external_id_to_auto_tests_search_post_request(
Expand Down
107 changes: 107 additions & 0 deletions testit-python-commons/tests/client/test_test_run_v2_hydrate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
from types import SimpleNamespace

from adapters_api.model.link_type import LinkType as ApiLinkType

from testit_python_commons.client.api_client import ApiClientWorker
from testit_python_commons.client.converter import Converter
from testit_python_commons.models.link import Link
from testit_python_commons.models.link_type import LinkType


def test_v2_dicts_to_link_api_results():
links = Converter.v2_dicts_to_link_api_results([
{
"id": "l1",
"url": "https://existing",
"title": "Existing",
"type": "Related",
"description": None,
},
{"title": "no-url"},
])
assert len(links) == 1
assert links[0].id == "l1"
assert links[0].url == "https://existing"
assert links[0].type == ApiLinkType("Related")


def test_v2_dicts_to_attachment_stubs():
stubs = Converter.v2_dicts_to_attachment_stubs([
{"id": "att-1", "name": "a.txt"},
{"name": "no-id"},
])
assert len(stubs) == 1
assert stubs[0].id == "att-1"


def test_build_update_keeps_v2_links_and_attachments_when_merging(mocker):
existing_link = Converter.v2_dicts_to_link_api_results([
{"id": "l1", "url": "https://existing", "title": "Existing", "type": "Related"},
])[0]
test_run = mocker.Mock()
test_run.id = "run-id"
test_run.name = "name"
test_run.description = None
test_run.launch_source = None
test_run.tags = ["ui"]
test_run.links = [existing_link]
test_run.attachments = [SimpleNamespace(id="att-1")]

new_link = Link().set_url("https://ci.example/job/2").set_link_type(LinkType.RELATED)
model = Converter.build_update_empty_request(
test_run,
tags=["smoke"],
links=[new_link],
)

assert [link.url for link in model.links] == ["https://existing", "https://ci.example/job/2"]
assert [a.id for a in model.attachments] == ["att-1"]
assert model.tags == ["ui", "smoke"]


class TestGetTestRunHydrate:
def test_get_test_run_overlays_links_and_attachments_from_v2(self, mocker):
mocker.patch.object(
ApiClientWorker,
"_ApiClientWorker__get_status_codes",
return_value=["PASSED", "FAILED"],
)
mocker.patch.object(ApiClientWorker, "_ApiClientWorker__get_api_client_configuration")
mocker.patch.object(ApiClientWorker, "_ApiClientWorker__get_api_client")
mocker.patch("testit_python_commons.client.api_client.TestRunsApi")
mocker.patch("testit_python_commons.client.api_client.AutoTestsApi")
mocker.patch("testit_python_commons.client.api_client.AttachmentsApi")
mocker.patch("testit_python_commons.client.api_client.TestResultsApi")
mocker.patch("testit_python_commons.client.api_client.WorkItemsApi")
mocker.patch("testit_python_commons.client.api_client.ProjectsApi")
mocker.patch("testit_python_commons.client.api_client.WorkflowsApi")

config = mocker.Mock()
config.get_url.return_value = "https://tms.example"
config.get_private_token.return_value = "token"
config.get_cert_validation.return_value = True
config.get_project_id.return_value = "proj"
config.get_test_run_id.return_value = "run-1"
config.get_configuration_id.return_value = "cfg-1"
config.get_proxy.return_value = None

worker = ApiClientWorker(config)
adapters_run = mocker.Mock(id="run-1", name="run", tags=["ui"], links=[], attachments=[])
worker._ApiClientWorker__test_run_api.adapters_test_runs_id_get.return_value = adapters_run
mocker.patch.object(
worker,
"_ApiClientWorker__get_test_run_v2_json",
return_value={
"links": [
{"id": "l1", "url": "https://link-1", "title": "L1", "type": "Related"},
],
"attachments": [{"id": "att-1"}],
},
)

result = worker.get_test_run("run-1")

assert result is adapters_run
assert len(result.links) == 1
assert result.links[0].url == "https://link-1"
assert [a.id for a in result.attachments] == ["att-1"]
2 changes: 1 addition & 1 deletion update_versions.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/bin/bash

NEW_VERSION="5.2.2"
NEW_VERSION="5.2.3"

echo "Updating all adapters to version: $NEW_VERSION"

Expand Down
Loading