diff --git a/examples/behave/tests/features/steps/attachment_steps.py b/examples/behave/tests/features/steps/attachment_steps.py index e137079c..f31acea5 100644 --- a/examples/behave/tests/features/steps/attachment_steps.py +++ b/examples/behave/tests/features/steps/attachment_steps.py @@ -22,7 +22,7 @@ def step_impl(context): @when('I attach content as text') def step_impl(context): # Attach text content directly - qase.attach(content="This is some text content", file_name="text_content.txt") + qase.attach_to_step(content="This is some text content", file_name="text_content.txt") @when('I attach JSON data') diff --git a/qase-behave/changelog.md b/qase-behave/changelog.md index 74223f4f..60b0a90a 100644 --- a/qase-behave/changelog.md +++ b/qase-behave/changelog.md @@ -1,3 +1,9 @@ +# qase-behave 2.0.6 + +## What's new + +- Supported attachments in step level. + # qase-behave 2.0.5 ## What's new diff --git a/qase-behave/docs/usage.md b/qase-behave/docs/usage.md index a3f8edc7..4c254ed8 100644 --- a/qase-behave/docs/usage.md +++ b/qase-behave/docs/usage.md @@ -94,3 +94,98 @@ Feature: Example tests When I run it Then it should pass ``` + +--- + +## Adding Attachments to Tests + +Qase Behave supports attaching files and content to test results. You can attach files or content either to the test case (scenario level) or to a specific test step. + +### Attach to Test Case + +Use `qase.attach()` to attach files or content to the test case. This is useful for screenshots, logs, or data files that are relevant to the entire test scenario. + +### Example: + +```gherkin +Feature: Example tests + + @qase.id:1 + Scenario: Example test with attachments + Given I have a test with a file + When I attach a screenshot + Then the attachment should be in the test case +``` + +```python +from behave import * +from qase.behave import qase + +@given('I have a test with a file') +def step_impl(context): + # Attach an existing file to the test case + qase.attach(file_path="/path/to/your/file.txt") + +@when('I attach a screenshot') +def step_impl(context): + # Attach binary data (e.g., screenshot) to the test case + screenshot_data = b"binary_screenshot_data" + qase.attach( + content=screenshot_data, + file_name="screenshot.png", + mime_type="image/png" + ) +``` + +### Attach to Test Step + +Use `qase.attach_to_step()` to attach files or content directly to a specific test step. This is useful when you want to associate attachments with a particular step execution. + +### Example: + +```gherkin +Feature: Example tests + + @qase.id:1 + Scenario: Example test with step attachments + Given I have a test + When I attach a screenshot to this step + Then the attachment should be in the step +``` + +```python +from behave import * +from qase.behave import qase + +@when('I attach a screenshot to this step') +def step_impl(context): + # Attach binary data to the current step + screenshot_data = b"binary_screenshot_data" + qase.attach_to_step( + content=screenshot_data, + file_name="step_screenshot.png", + mime_type="image/png" + ) + + # Attach text content to the current step + qase.attach_to_step( + content="Step execution log", + file_name="step_log.txt" + ) +``` + +### Method Parameters + +Both `qase.attach()` and `qase.attach_to_step()` accept the same parameters: + +- `file_path`: Path to the file to attach (mutually exclusive with `content`) +- `content`: Content to attach as string or bytes (mutually exclusive with `file_path`) +- `file_name`: Name for the attachment (auto-detected from `file_path` if not provided) +- `mime_type`: MIME type of the attachment (auto-detected if not provided) + +**Notes:** + +- Either `file_path` or `content` must be provided, but not both +- If `file_name` is not provided, it will be derived from `file_path` or default to "attachment.txt" +- If `mime_type` is not provided, it will be auto-detected from the file extension or default to "text/plain" +- Attachments are automatically included in the test result when the scenario completes diff --git a/qase-behave/pyproject.toml b/qase-behave/pyproject.toml index 3ebf4fd0..d8b81b94 100644 --- a/qase-behave/pyproject.toml +++ b/qase-behave/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "qase-behave" -version = "2.0.5" +version = "2.0.6" description = "Qase Behave Plugin for Qase TestOps and Qase Report" readme = "README.md" keywords = ["qase", "behave", "plugin", "testops", "report", "qase reporting", "test observability"] diff --git a/qase-behave/src/qase/behave/formatter.py b/qase-behave/src/qase/behave/formatter.py index e67b3ead..c449f9ca 100644 --- a/qase-behave/src/qase/behave/formatter.py +++ b/qase-behave/src/qase/behave/formatter.py @@ -41,10 +41,15 @@ def scenario(self, scenario: Scenario): self.__current_scenario = parse_scenario(scenario) # Update global qase object with current scenario qase._set_current_scenario(self.__current_scenario) + # Clear current step when starting new scenario + qase._set_current_step(None) pass def result(self, result: Step): step = parse_step(result) + # Set current step to allow future attachments and apply pending ones + qase._set_current_step(step) + if step.execution.status != 'passed': # Check if it's an assertion error or other error is_assertion_error = False @@ -64,6 +69,8 @@ def result(self, result: Step): if result.error_message: self.__current_scenario.execution.stacktrace = result.error_message self.__current_scenario.steps.append(step) + # Clear current step after adding to scenario + qase._set_current_step(None) pass def eof(self): diff --git a/qase-behave/src/qase/behave/qase_global.py b/qase-behave/src/qase/behave/qase_global.py index e3611dfb..5c6c1e37 100644 --- a/qase-behave/src/qase/behave/qase_global.py +++ b/qase-behave/src/qase/behave/qase_global.py @@ -1,7 +1,7 @@ import os import mimetypes import logging -from typing import Optional, Union +from typing import Optional, Union, Dict from qase.commons.models import Attachment logger = logging.getLogger(__name__) @@ -15,29 +15,51 @@ class QaseGlobal: def __init__(self): self._current_scenario = None + self._current_step = None + self._pending_step_attachments: Dict[str, list] = {} def _set_current_scenario(self, scenario): """Set the current scenario for attachment tracking""" self._current_scenario = scenario + # Clear pending attachments when scenario changes + self._pending_step_attachments.clear() - def attach(self, - file_path: Optional[str] = None, - content: Optional[Union[str, bytes]] = None, - file_name: Optional[str] = None, - mime_type: Optional[str] = None) -> None: + def _set_current_step(self, step): + """Set the current step for attachment tracking""" + self._current_step = step + # If step is set, apply any pending attachments + if step is not None: + step_id = step.id + if step_id in self._pending_step_attachments: + for attachment in self._pending_step_attachments[step_id]: + step.add_attachment(attachment) + del self._pending_step_attachments[step_id] + # Also apply attachments stored for the next step + if '_next_step' in self._pending_step_attachments: + for attachment in self._pending_step_attachments['_next_step']: + step.add_attachment(attachment) + del self._pending_step_attachments['_next_step'] + + def _create_attachment(self, + file_path: Optional[str] = None, + content: Optional[Union[str, bytes]] = None, + file_name: Optional[str] = None, + mime_type: Optional[str] = None) -> Attachment: """ - Attach a file or content to the current test scenario. + Create an Attachment object from the provided parameters. Args: file_path: Path to the file to attach content: Content to attach (string or bytes) file_name: Name for the attachment (if not provided, will be derived from file_path) mime_type: MIME type of the attachment (if not provided, will be auto-detected) + + Returns: + Attachment object + + Raises: + ValueError: If both file_path and content are provided, or if neither is provided """ - - if self._current_scenario is None: - raise RuntimeError("No active scenario. Cannot attach file.") - if file_path and content: raise ValueError("Either file_path or content must be provided, not both.") @@ -67,17 +89,37 @@ def attach(self, # Create attachment if file_path: - attachment = Attachment( + return Attachment( file_name=file_name, mime_type=mime_type, file_path=file_path ) else: - attachment = Attachment( + return Attachment( file_name=file_name, mime_type=mime_type, content=content ) + + def attach(self, + file_path: Optional[str] = None, + content: Optional[Union[str, bytes]] = None, + file_name: Optional[str] = None, + mime_type: Optional[str] = None) -> None: + """ + Attach a file or content to the current test scenario. + + Args: + file_path: Path to the file to attach + content: Content to attach (string or bytes) + file_name: Name for the attachment (if not provided, will be derived from file_path) + mime_type: MIME type of the attachment (if not provided, will be auto-detected) + """ + + if self._current_scenario is None: + raise RuntimeError("No active scenario. Cannot attach file.") + + attachment = self._create_attachment(file_path, content, file_name, mime_type) # Add attachment to current scenario if not hasattr(self._current_scenario, 'attachments'): @@ -85,6 +127,43 @@ def attach(self, self._current_scenario.attachments.append(attachment) + def attach_to_step(self, + file_path: Optional[str] = None, + content: Optional[Union[str, bytes]] = None, + file_name: Optional[str] = None, + mime_type: Optional[str] = None) -> None: + """ + Attach a file or content to the current test step. + If the step is not yet available, the attachment will be stored + and applied when the step is created. + + Args: + file_path: Path to the file to attach + content: Content to attach (string or bytes) + file_name: Name for the attachment (if not provided, will be derived from file_path) + mime_type: MIME type of the attachment (if not provided, will be auto-detected) + + Raises: + RuntimeError: If no active scenario is available + """ + + if self._current_scenario is None: + raise RuntimeError("No active scenario. Cannot attach file to step.") + + attachment = self._create_attachment(file_path, content, file_name, mime_type) + + # Add attachment to current step if available, otherwise store for later + if self._current_step is not None: + self._current_step.add_attachment(attachment) + else: + # Store attachment to be applied when step is created + # Use a temporary key that will be matched when step is set + # We'll use the step's line number or name as identifier + # For now, use a special key that will be applied to the next step + if '_next_step' not in self._pending_step_attachments: + self._pending_step_attachments['_next_step'] = [] + self._pending_step_attachments['_next_step'].append(attachment) + def comment(self, message: str) -> None: """