From 7e95d7a84b2689899808c71b422ef8b2a47e3aa8 Mon Sep 17 00:00:00 2001 From: StraKoka Date: Fri, 17 Apr 2026 14:10:30 +0200 Subject: [PATCH 1/4] [NOJIRA] - Adding AI Skill for implementing tests and code review, Updating readme on how to use it --- .../skills/functional-tests-skill/SKILL.md | 1258 +++++++++++++++++ .../functional-tests-skill/scripts/README.md | 277 ++++ .../scripts/openapi_helper.py | 394 ++++++ readme.md | 68 + 4 files changed, 1997 insertions(+) create mode 100644 .github/skills/functional-tests-skill/SKILL.md create mode 100644 .github/skills/functional-tests-skill/scripts/README.md create mode 100755 .github/skills/functional-tests-skill/scripts/openapi_helper.py diff --git a/.github/skills/functional-tests-skill/SKILL.md b/.github/skills/functional-tests-skill/SKILL.md new file mode 100644 index 0000000..aef8160 --- /dev/null +++ b/.github/skills/functional-tests-skill/SKILL.md @@ -0,0 +1,1258 @@ +--- +name: functional-tests-skill +description: Expert testing skill for the Levi9 functional-tests project — a Java 23 / Maven / Cucumber BDD framework using REST Assured for API tests and Selenium 4 for UI tests with Spring DI. Use this skill whenever the user wants to write, create, add, extend, debug, review, or refactor any kind of test in this project. Trigger when the user mentions writing tests, adding test coverage, creating feature files, Gherkin scenarios, step definitions, stepdefs, REST API tests, UI tests, Selenium, page objects, debugging failing tests, fixing test failures, test architecture improvements, generating test data, reviewing test code, or anything related to testing in this Cucumber/REST Assured/Selenium codebase. Even if the user just says "write a test" or "add coverage for X" or "this test is failing", this skill should activate immediately. +--- + +# Functional Tests Skill + +This skill guides you in working with the **Levi9 functional-tests** project — a Java 23 / Maven / Cucumber BDD framework that tests two systems: +- **Pet Store API** (REST API tests only) +- **Restful Booker Platform** (REST API + UI tests) + +## Core Principles + +1. **Follow existing patterns exactly** — Study similar tests in the codebase before creating new ones. Match naming conventions, package structure, coding style, and architectural patterns. + +2. **Run tests after implementation** — After creating or modifying tests, ALWAYS run them to verify they work. If they fail, investigate and fix the root cause. + +3. **Comprehensive coverage** — Create at least one happy path scenario, one rainy day scenario, and relevant edge cases that make sense for the feature. + +4. **Readable and reusable** — Write clean, self-documenting code with minimal effort needed for maintenance. Use descriptive names, clear Gherkin, and well-structured step definitions. + +5. **Consistency is key** — The code you generate should be indistinguishable from existing code in terms of style, patterns, and practices. + +## Project Architecture + +### Layer Structure + +``` +Feature Files (.feature) + ↓ +Step Definitions (@Given/@When/@Then) + ↓ +Service Layer (Business logic) + ↓ +REST Clients (BaseRestClient + specific) | UI Pages (BasePage + specific) + ↓ +DSOs (Data Service Objects) | Storage (Entity classes) +``` + +### Key Packages + +``` +src/main/java/com/levi9/functionaltests/ +├── exceptions/ # FunctionalTestsException +├── rest/ +│ ├── client/ # BaseRestClient, PetStoreRestClient, RestfulBookerRestClient +│ ├── data/ # DSO classes (request/response DTOs) +│ └── service/ # Service layer (@Component with business logic) +├── storage/ # Storage.java + Entity classes +│ ├── domain/ +│ │ ├── petstore/ # PetEntity, OrderEntity +│ │ └── restfulbooker/ # RoomEntity +│ └── ScenarioEntity # Test scenario metadata +├── ui/ +│ ├── base/ # BaseDriver, BasePage, Browser enum +│ ├── helpers/ # WaitHelper, ActionsHelper, UploadHelper +│ └── pages/ # Page Object classes +└── util/ # FakeUtil, FileUtil + +src/test/java/com/levi9/functionaltests/ +├── config/ # SpringConfig (@PropertySource, @ComponentScan) +├── hooks/ # Hooks.java (@Before/@After with ordering) +├── runners/ # DryRunRunnerIT (validation) +├── stepdefs/ # Step definitions by domain +│ ├── petstore/ # PetStepdef, StoreStepdef +│ └── restfulbooker/ # LoginStepdef, RoomManagementStepdef, etc. +└── typeregistry/ # Custom Cucumber parameter types + +src/test/resources/features/ +├── pet-store/ # Pet Store features +└── restful-booker-platform/ # Restful Booker features + ├── admin-panel/ + └── front-page/ +``` + +## Test Types + +### REST API Tests (`@api`) + +API tests use **REST Assured** via service layer pattern. Services are Spring `@Component` beans with `@Scope("cucumber-glue")`. + +**Tags**: `@api`, plus domain tags like `@pet`, `@store`, `@room`, `@booking`, etc. + +### UI Tests (`@ui`) + +UI tests use **Selenium 4** via Page Object pattern. All pages extend `BasePage` which provides wait-based interactions. + +**Tags**: `@ui`, plus domain tags like `@login`, `@management`, `@booking`, `@contact`, etc. + +### Tags Reference + +| Tag | Purpose | +|-----|---------| +| `@api` | REST API test | +| `@ui` | UI/Selenium test | +| `@pet`, `@store`, `@login`, `@management`, `@room-management`, `@booking`, `@contact` | Domain/feature tags | +| `@blocker` | Highest severity — core functionality | +| `@critical` | High severity — important flows | +| `@normal` | Standard severity — expected behavior | +| `@minor` | Low severity — edge cases | +| `@sanity` | Sanity suite — quick smoke tests | +| `@bug` | Known bug — excluded from default runs | +| `@pdf`, `@html`, `@image` | Embedding type tags (for report artifacts) | + +### Hooks (Lifecycle) + +Hooks in `src/test/java/com/levi9/functionaltests/hooks/Hooks.java` manage test lifecycle: + +``` +@Before(order = 0) → Scenario start (all tests) — sets scenario metadata +@Before(value = "@ui", order = 1) → WebDriver setup (UI only) — initialize, set timeouts, maximize +@After(value = "@ui", order = 3) → Screenshot on failure (UI only) +@After(order = 2) → Cleanup — deletes created rooms via API +@After(value = "@ui", order = 1) → WebDriver teardown (UI only) — close browser +@After(order = 0) → Scenario end logging (all tests) +``` + +**Key**: Cleanup hooks delete test data (rooms) automatically. No manual cleanup needed in tests. + +## Common Patterns + +### 1. Service Layer Pattern (REST API Tests) + +Services encapsulate business logic, make REST calls, validate responses, and update Storage. + +**Template**: +```java +@Slf4j +@Component +@Scope("cucumber-glue") +public class {Domain}Service { + + @Autowired + private {System}RestClient client; + + @Autowired + private Storage storage; + + public void performAction(String param) { + log.info("Performing action with param: {}", param); + + // 1. Build request DSO + {Action}{Resource}DSO requestBody = {Action}{Resource}DSO.builder() + .field(param) + .build(); + + // 2. Call REST client + Response response = client.post(requestBody, null, "/path/to/endpoint"); + + // 3. Validate response + assertThat(response.statusCode()) + .as("Status code should be 200") + .isEqualTo(200); + + // 4. Extract response + {Resource}DSO responseBody = response.as({Resource}DSO.class); + + // 5. Update storage + {Resource}Entity entity = {Resource}Entity.builder() + .id(responseBody.getId()) + .field(responseBody.getField()) + .build(); + + storage.get{Resource}s().add(entity); + + log.info("{Resource} created with ID: {}", entity.getId()); + } +} +``` + +**Key points**: +- Always use `@Slf4j` for logging +- Use `@Autowired` for dependency injection +- Use `@Scope("cucumber-glue")` for scenario-scoped lifecycle +- Validate responses with AssertJ assertions using `.as()` for descriptive messages +- Update Storage with entities after successful operations +- Log important actions and results + +### 2. Page Object Pattern (UI Tests) + +Page objects extend `BasePage` and use wait-based Selenium interactions. + +**Template**: +```java +@Slf4j +@Component +@Scope("cucumber-glue") +public class {Page}Page extends BasePage<{Page}Page> { + + // Page locator for load/isLoaded checks + private final By page = By.xpath("//*[@data-testid='{page}-header']"); + + // Locators as private final fields + private final By fieldInput = By.id("fieldId"); + private final By submitButton = By.cssSelector(".submit-btn"); + private final By successMessage = By.xpath("//div[@class='success']"); + private final By errorMessages = By.cssSelector("div.alert.alert-danger"); + + protected {Page}Page(final BaseDriver baseDriver) { + super(baseDriver); + } + + /** + * Checks if page is loaded. + * + * @return true if yes, otherwise false + */ + public boolean isLoaded() { + return isElementVisible(page, 5); + } + + /** + * Load Page. + */ + public void load() { + openPage(getRestfulBookerPlatformUrl() + "#/{path}", page); + } + + /** + * Fills the form with provided data. Null parameters skip the field (for negative tests). + * + * @param field field value, nullable for validation tests + */ + public void fillForm(@Nullable final String field) { + if (null != field) { + waitAndSendKeys(fieldInput, field); + } + } + + /** + * Clicks submit button + */ + public void clickSubmit() { + waitAndClick(submitButton); + log.info("Clicked submit button"); + } + + /** + * Checks if success message is displayed + */ + public boolean isSuccessMessageDisplayed() { + return isElementVisible(successMessage, 10); + } + + /** + * Get list of error messages displayed on the page. + * + * @return list of error message strings + */ + public List getErrorMessages() { + return waitAndGetWebElement(errorMessages).findElements(By.cssSelector("p")).stream().map(WebElement::getText).toList(); + } +} +``` + +**Key points**: +- Extend `BasePage` where `T` is the page class itself +- Constructor takes `BaseDriver`, calls `super(baseDriver)` — `@Autowired` on constructors is optional (Spring infers single-constructor injection) +- **Every page MUST have**: `isLoaded()` (calls `isElementVisible(pageLocator, 5)`) and `load()` (calls `openPage(url, pageLocator)`) +- Use `getRestfulBookerPlatformUrl()` for base URL (provided by BasePage via `@Value`) +- Use descriptive locator names as `private final By` fields +- Use wait-based methods from BasePage: `waitAndClick()`, `waitAndSendKeys()`, `waitAndSelectByValue()`, `waitAndGetText()`, `waitAndGetAttribute()`, etc. +- **Return type**: Use `void` for action methods (this project's convention, not fluent API) +- Use `@Nullable` on parameters for methods used in negative/validation tests (skip interaction when null) +- Error messages: retrieve via `findElements(By.cssSelector("p")).stream().map(WebElement::getText).toList()` +- Log actions for debugging + +### 3. DSO (Data Service Object) Pattern + +DSOs are request/response DTOs using Lombok. + +**Template**: +```java +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class {Action}{Resource}DSO { + + @JsonProperty("field_name") + private String fieldName; + + private Integer count; + + private List items; +} +``` + +**Key points**: +- Use Lombok annotations for boilerplate reduction +- Use `@JsonProperty` when JSON field names differ from Java conventions +- Use `@Builder(toBuilder = true)` for immutability patterns + +### 4. Entity Pattern + +Entities represent test-side domain objects stored in Storage. + +**Template**: +```java +@Getter +@Setter +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class {Resource}Entity { + + private Integer id; + private String name; + private {Status}Enum status; // Use enums for status fields + + @Builder.Default + private boolean deleted = false; +} +``` + +**Key points**: +- Use `@Builder.Default` for default values +- Use enums for status/type fields (not Strings) +- Include a `deleted` flag if the resource can be deleted + +### 5. Storage Pattern + +`Storage` is a Spring `@Component` with `@Scope("cucumber-glue")` that maintains test state. It uses Lombok `@Getter` to auto-generate getters — there are no explicit getter or add methods. + +**Adding a new entity to Storage**: + +1. Add a field: `private final List<{Resource}Entity> {resource}s = new ArrayList<>();` +2. Lombok `@Getter` auto-generates `get{Resource}s()` — do NOT add explicit getters. +3. Add convenience method: +```java +public {Resource}Entity getLast{Resource}() { + return {resource}s.stream().reduce((first, last) -> last) + .orElseThrow(() -> new FunctionalTestsException("Last {Resource} not found!")); +} +``` + +**How callers add entities** (no dedicated add methods exist): +```java +storage.get{Resource}s().add(entity); +``` + +### 6. Step Definitions + +Step definitions are in `src/test/java/com/levi9/functionaltests/stepdefs/{system}/`. + +This project uses **two step annotation styles**: + +#### Style A: Cucumber Expressions (Restful Booker tests) + +Used with `{string}`, `{int}`, and custom types like `{roomType}`, `{accessible}`. Gherkin uses **single quotes** for `{string}` values. + +```java +@Slf4j +public class {Domain}Stepdef { + + @Autowired + private Storage storage; + + @Autowired + private {Domain}Service service; + + @Given("User has created {roomType} type {accessible} room {string} priced at {int} GBP with {string}") + public void userCreatedRoom(final RoomType roomType, final boolean accessible, final String roomName, final int roomPrice, final String features) { + log.info("Step: user created room '{}'", roomName); + service.createRoom(roomName, roomType, accessible, Integer.toString(roomPrice), new RoomAmenities(features)); + } +} +``` + +Matching Gherkin (single quotes): +```gherkin +Given User has created Single type Accessible room '1408' priced at 50 GBP with 'WiFi, TV and Safe' +``` + +#### Style B: Regex patterns (Pet Store tests) + +Used with `^...$` anchors, `"([^"]*)"` or `(.*)` for captures. Gherkin uses **double quotes** for string values. + +```java +@Slf4j +public class {Domain}Stepdef { + + @Autowired + private Storage storage; + + @Autowired + private {Domain}Service service; + + @Given("^[Uu]ser add(?:s|ed) pet \"(.*)\" to the pet store$") + public void addPet(final String petName) { + log.info("Step: user adds pet '{}'", petName); + service.addPetToStore(petName); + } + + @Then("^[Ii]t (?:will be|is)? possible to sell it$") + public void validatePossibleToSell() { + log.info("Step: validate possible to sell"); + final PetEntity expectedPet = storage.getLastPet(); + final PetDSO actualPet = petService.getPet(expectedPet); + assertThat(actualPet.getStatus()).as("Pet is not available!").isEqualTo(AVAILABLE.getValue()); + } +} +``` + +Matching Gherkin (double quotes): +```gherkin +Given User added pet "Beagle" to the pet store +``` + +**Both styles are equally valid.** Match the style of the system you're extending. + +### 7. Custom Parameter Types + +Define custom parameter types in `src/test/java/com/levi9/functionaltests/typeregistry/ParameterTypes.java`. + +**Template**: +```java +@ParameterType("Value1|Value2|Value3") +public {Type} {type}(final String value) { + return {Type}.getEnum(value); +} +``` + +**Note**: The method parameter is always `String` — the conversion to the target type happens inside the method body. + +This allows Gherkin steps like: `When user creates a Single type room` where `Single` is auto-converted to `RoomType.SINGLE`. + +### 8. FunctionalTestsException + +The project's custom exception uses SLF4J-style `{}` placeholder formatting: + +```java +throw new FunctionalTestsException("Order with ID {} not found!", orderId); +throw new FunctionalTestsException("Expected status {} but got {}", expectedStatus, actualStatus); +``` + +### 9. Soft Assertions + +Use `assertSoftly` for multi-field validations — all assertions run even if early ones fail: + +```java +assertSoftly(softly -> { + softly.assertThat(actualRoom.getName()).as("Room Name is wrong!").isEqualTo(expectedName); + softly.assertThat(actualRoom.getType()).as("Room Type is wrong!").isEqualTo(expectedType); + softly.assertThat(actualRoom.getPrice()).as("Room Price is wrong!").isEqualTo(expectedPrice); +}); +``` + +### 10. BaseRestClient Methods + +All REST clients extend `BaseRestClient` which provides: + +```java +Response post(Object requestBody, CookieFilter auth, String path) +Response put(Object requestBody, CookieFilter auth, String path) +Response get(CookieFilter auth, String path) +Response delete(CookieFilter auth, String path) +Response uploadFile(String filePath, CookieFilter auth, String path) +``` + +Pass `null` for `auth` when authentication is not needed. + +### 11. Cucumber Alternative Text + +Gherkin supports `validation/mandatory` syntax to match either word: + +```gherkin +Then User will get validation/mandatory error message: 'Room name must be set' +``` + +This matches step definition: `@Then("User will get validation/mandatory error message: {string}")` + +## Naming Conventions + +| Artifact | Pattern | Example | +|----------|---------|---------| +| **Feature files** | `kebab-case.feature` | `room-management.feature` | +| **Step defs** | `{Domain}Stepdef` | `RoomManagementStepdef` | +| **Services** | `{Domain}Service` | `RoomService`, `PetService` | +| **REST clients** | `{System}RestClient` | `RestfulBookerRestClient` | +| **DSOs** | `{Action}{Resource}DSO` or `{Resource}DSO` | `CreateRoomDSO`, `PetDSO` | +| **Entities** | `{Resource}Entity` | `PetEntity`, `RoomEntity` | +| **Pages** | `{Page}Page` | `RoomsPage`, `AdminPage` | +| **Enums** | `{Type}` or `{Type}Enum` | `RoomType`, `PetStatus` | + +## Workflows + +### Creating a New REST API Test + +**Step 1: Create or extend feature file** + +Location: `src/test/resources/features/{system}/{domain}.feature` + +```gherkin +@api @{domain} +Feature: {Domain} Management + + {Description of what the feature covers.} + + Background: {Descriptive background title} + # Common setup if needed + + @blocker @sanity + Scenario: User can create a {resource} + Given user creates a {resource} with name "Test {Resource}" + When user retrieves the {resource} + Then the {resource} should have status "ACTIVE" + + @critical + Scenario Outline: User can create multiple {resource}s + Given user creates a {resource} with name "" + Then the {resource} should be created successfully + + Examples: + | name | + | Resource1 | + | Resource2 | +``` + +**Note**: Background sections should always have descriptive text (e.g., `Background: User has a pet in the store`). + +**Step 2: Create step definition** + +Location: `src/test/java/com/levi9/functionaltests/stepdefs/{system}/{Domain}Stepdef.java` + +```java +@Slf4j +public class {Domain}Stepdef { + + @Autowired + private Storage storage; + + @Autowired + private {Domain}Service service; + + @Given("^user creates a {resource} with name \"([^\"]*)\"$") + public void userCreates{Resource}(String name) { + log.info("Step: user creates a {resource} with name '{}'", name); + service.create{Resource}(name); + } + + @When("^user retrieves the {resource}$") + public void userRetrievesThe{Resource}() { + log.info("Step: user retrieves the {resource}"); + {Resource}Entity entity = storage.getLast{Resource}(); + service.retrieve{Resource}(entity.getId()); + } + + @Then("^the {resource} should have status \"([^\"]*)\"$") + public void the{Resource}ShouldHaveStatus(String status) { + log.info("Step: the {resource} should have status '{}'", status); + {Resource}Entity entity = storage.getLast{Resource}(); + + assertThat(entity.getStatus().getValue()) + .as("{Resource} status should be {}", status) + .isEqualTo(status); + } + + @Then("^the {resource} should be created successfully$") + public void the{Resource}ShouldBeCreatedSuccessfully() { + log.info("Step: the {resource} should be created successfully"); + {Resource}Entity entity = storage.getLast{Resource}(); + + assertThat(entity.getId()) + .as("{Resource} should have an ID") + .isNotNull(); + } +} +``` + +**Step 3: Create service** + +Location: `src/main/java/com/levi9/functionaltests/rest/service/{system}/{Domain}Service.java` + +```java +@Slf4j +@Component +@Scope("cucumber-glue") +public class {Domain}Service { + + private static final String {RESOURCE}_PATH = "/api/{resources}"; + + @Autowired + private {System}RestClient client; + + @Autowired + private Storage storage; + + public void create{Resource}(String name) { + log.info("Creating {resource} with name: {}", name); + + Create{Resource}DSO requestBody = Create{Resource}DSO.builder() + .name(name) + .status({Status}.PENDING.getValue()) + .build(); + + Response response = client.post(requestBody, null, {RESOURCE}_PATH); + + assertThat(response.statusCode()) + .as("Status code should be 201") + .isEqualTo(201); + + {Resource}DSO responseBody = response.as({Resource}DSO.class); + + {Resource}Entity entity = {Resource}Entity.builder() + .id(responseBody.getId()) + .name(responseBody.getName()) + .status({Status}.getEnum(responseBody.getStatus())) + .build(); + + storage.get{Resource}s().add(entity); + + log.info("{Resource} created successfully with ID: {}", entity.getId()); + } + + public void retrieve{Resource}(Integer id) { + log.info("Retrieving {resource} with ID: {}", id); + + Response response = client.get(null, {RESOURCE}_PATH + "/" + id); + + assertThat(response.statusCode()) + .as("Status code should be 200") + .isEqualTo(200); + + {Resource}DSO responseBody = response.as({Resource}DSO.class); + + // Update storage with retrieved data + {Resource}Entity entity = storage.getLast{Resource}(); + entity.setName(responseBody.getName()); + entity.setStatus({Status}.getEnum(responseBody.getStatus())); + + log.info("Retrieved {resource}: {}", responseBody); + } +} +``` + +**Step 4: Create DSOs** + +Location: `src/main/java/com/levi9/functionaltests/rest/data/{system}/` + +**Tip**: If the API has an OpenAPI spec, use the helper script to generate DSO templates: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate-dso \ + src/main/resources/openapi/{system}/{domain}-open-api.json \ + {SchemaName} \ + com.levi9.functionaltests.rest.data.{system} +``` + +```java +// Create{Resource}DSO.java +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class Create{Resource}DSO { + private String name; + private String status; +} + +// {Resource}DSO.java +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class {Resource}DSO { + private Integer id; + private String name; + private String status; +} +``` + +**Step 5: Create entity and add to Storage** + +Location: `src/main/java/com/levi9/functionaltests/storage/domain/{system}/{Resource}Entity.java` + +```java +@Getter +@Setter +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class {Resource}Entity { + private Integer id; + private String name; + private {Status} status; + + @Builder.Default + private boolean deleted = false; +} +``` + +Then update `Storage.java`: +```java +private final List<{Resource}Entity> {resource}s = new ArrayList<>(); + +// @Getter generates get{Resource}s() automatically — do NOT add explicit getter + +public {Resource}Entity getLast{Resource}() { + return {resource}s.stream().reduce((first, last) -> last) + .orElseThrow(() -> new FunctionalTestsException("Last {Resource} not found!")); +} +``` + +Callers add entities via: +```java +storage.get{Resource}s().add(entity); +``` + +**Step 6: Verify step definitions are properly linked (dry-run)** + +```bash +mvn test -Dtest=DryRunRunnerIT +``` + +This validates that all Gherkin steps have matching step definitions with correct regex patterns. Fix any "Undefined step" errors. + +**Step 7: Run the test** + +```bash +mvn clean verify -Dtags='@{domain}' +``` + +If the test fails, investigate the failure, fix the issue, and run again. + +### Creating a New UI Test + +**Step 1: Create feature file** + +Location: `src/test/resources/features/{system}/{domain}.feature` + +```gherkin +@ui @{domain} +Feature: {Domain} UI + + Background: User is on the {Page} Page + Given user is on the {Page} Page + + @blocker @sanity + Scenario: User can perform action via UI + When user fills form with 'Test Data' + And user clicks submit + Then success message should be displayed +``` + +**Note**: UI feature files use Cucumber Expressions with **single quotes** for `{string}` values. Background sections should have descriptive text. + +**Step 2: Create page object** + +Location: `src/main/java/com/levi9/functionaltests/ui/pages/{system}/{Page}Page.java` + +```java +@Slf4j +@Component +@Scope("cucumber-glue") +public class {Page}Page extends BasePage<{Page}Page> { + + private final By page = By.xpath("//*[@data-testid='{page}-header']"); + private final By formInput = By.id("input-id"); + private final By submitButton = By.cssSelector(".submit-btn"); + private final By successMessage = By.xpath("//div[@class='success']"); + + protected {Page}Page(final BaseDriver baseDriver) { + super(baseDriver); + } + + /** + * Checks if page is loaded. + * + * @return true if yes, otherwise false + */ + public boolean isLoaded() { + return isElementVisible(page, 5); + } + + /** + * Load Page. + */ + public void load() { + openPage(getRestfulBookerPlatformUrl() + "#/{path}", page); + } + + /** + * Fills the form with provided data + */ + public void fillForm(final String data) { + log.info("Filling form with: {}", data); + waitAndSendKeys(formInput, data); + } + + /** + * Clicks the submit button + */ + public void clickSubmit() { + log.info("Clicking submit button"); + waitAndClick(submitButton); + } + + /** + * Checks if success message is displayed + */ + public boolean isSuccessMessageDisplayed() { + return isElementVisible(successMessage, 10); + } +} +``` + +**Step 3: Create step definitions** + +Location: `src/test/java/com/levi9/functionaltests/stepdefs/{system}/{Domain}Stepdef.java` + +```java +@Slf4j +public class {Domain}Stepdef { + + @Autowired + private {Page}Page page; + + @Autowired + private BannerPage bannerPage; + + @Given("User is on the {Page} Page") + public void userIsOnPage() { + page.load(); + bannerPage.closeBanner(); + assertThat(page.isLoaded()).as("User is not on the {Page} Page!").isTrue(); + log.info("User is on {Page} Page"); + } + + @When("User fills form with {string}") + public void userFillsForm(final String data) { + log.info("Step: user fills form with '{}'", data); + page.fillForm(data); + } + + @When("User clicks submit") + public void userClicksSubmit() { + page.clickSubmit(); + } + + @Then("Success message should be displayed") + public void successMessageShouldBeDisplayed() { + assertThat(page.isSuccessMessageDisplayed()).as("Success message is not displayed!").isTrue(); + log.info("Success message is displayed"); + } +} +``` + +**Important**: Always inject `BannerPage` and call `bannerPage.closeBanner()` after loading any Restful Booker page — the welcome banner blocks interactions. + +**Step 4: Verify step definitions (dry-run)** + +```bash +mvn test -Dtest=DryRunRunnerIT +``` + +**Step 5: Run the test** + +```bash +mvn clean verify -Dtags='@{domain} and @ui' +``` + +### Testing Error Handling and Validation + +When creating tests for error scenarios and validation: + +**Negative API Tests**: +```gherkin +@api @validation @{domain} +Feature: {Domain} Validation + + @critical + Scenario Outline: API rejects invalid {resource} data + When user attempts to create a {resource} with "" + Then the API should return status code + And the error message should contain "" + + Examples: + | field | value | statusCode | errorText | + | empty name | | 400 | name is required | + | invalid status | INVALID_STATUS | 400 | invalid status | + | null ID | null | 400 | ID cannot be null| +``` + +**Service layer error handling**: +```java +public void createResourceWithInvalidData(String field, String value) { + log.info("Attempting to create resource with invalid {}: {}", field, value); + + // Build invalid request + RequestDSO request = buildInvalidRequest(field, value); + + try { + Response response = client.post(request, null, RESOURCE_PATH); + + // For negative tests, expect 400 + if (response.statusCode() == HttpStatus.SC_BAD_REQUEST) { + log.info("Received expected 400 error: {}", response.body().asString()); + storage.setLastErrorResponse(response); // Store for assertion + } else { + throw new FunctionalTestsException( + "Expected 400 Bad Request but got {}", response.statusCode()); + } + } catch (Exception e) { + log.error("Error during invalid request: {}", e.getMessage()); + throw new FunctionalTestsException("Failed to handle invalid request: {}", e.getMessage()); + } +} +``` + +**Step definition for error cases**: +```java +@When("^user attempts to create a {resource} with (.*) \"([^\"]*)\"$") +public void userAttemptsInvalidCreate(String field, String value) { + log.info("Step: user attempts to create {resource} with {} '{}'", field, value); + service.createResourceWithInvalidData(field, value); +} + +@Then("^the API should return status code (\\d+)$") +public void apiShouldReturnStatusCode(int expectedStatus) { + log.info("Step: verifying API returned status code {}", expectedStatus); + Response errorResponse = storage.getLastErrorResponse(); + + assertThat(errorResponse.statusCode()) + .as("API should return status code {}", expectedStatus) + .isEqualTo(expectedStatus); +} + +@Then("^the error message should contain \"([^\"]*)\"$") +public void errorMessageShouldContain(String expectedText) { + log.info("Step: verifying error message contains '{}'", expectedText); + Response errorResponse = storage.getLastErrorResponse(); + String errorBody = errorResponse.body().asString(); + + assertThat(errorBody.toLowerCase()) + .as("Error message should contain '{}'", expectedText) + .contains(expectedText.toLowerCase()); +} +``` + +**UI validation testing**: +```gherkin +@ui @validation @{domain} +Feature: {Page} Form Validation + + Background: + Given user is on {page} page + + @critical + Scenario: Required fields show validation errors + When user clicks submit without filling required fields + Then validation error "Name is required" should be displayed + And validation error "Email is required" should be displayed + And the form should not be submitted +``` + +**Page object validation methods**: +```java +public void clickSubmitWithoutFilling() { + log.info("Clicking submit without filling required fields"); + waitAndClick(submitButton); +} + +public boolean isValidationErrorDisplayed(String errorMessage) { + log.info("Checking if validation error '{}' is displayed", errorMessage); + By errorLocator = By.xpath(String.format("//span[contains(text(), '%s')]", errorMessage)); + return isElementDisplayed(errorLocator); +} +``` + +### Debugging Failing Tests + +When a test fails, follow this systematic approach: + +1. **Read the error message carefully** — Look at the stack trace, assertion failure message, or Selenium error. + +2. **Check logs** — Examine `logs/functional-tests-{user}.{date}.log` for detailed logging. + +3. **Review screenshots** — For UI tests, check `target/test-artifacts/{ScenarioName}/screenshots/` for failure screenshots. + +4. **Run dry-run** — Verify step definitions are properly linked: + ```bash + mvn test -Dtest=DryRunRunnerIT + ``` + +5. **Common issues**: + - **Undefined steps**: Step definition regex doesn't match Gherkin + - **NullPointerException**: Storage entity not created or autowiring failed + - **Assertion failure**: Response/UI state doesn't match expectation + - **Timeout**: Element not found (UI) or slow response (API) + - **404/500 errors**: Wrong endpoint path or server issue + +6. **Fix and verify** — After fixing, run the specific test again: + ```bash + mvn clean verify -Dtags='@{specific-tag}' + ``` + +### Code Review Checklist + +When reviewing test code, verify: + +**Feature Files**: +- [ ] Scenarios have appropriate tags (`@api`/`@ui`, severity `@blocker/@critical/@normal/@minor`, domain) +- [ ] Background is used for common setup with descriptive text +- [ ] Scenario Outlines are used for data-driven tests +- [ ] Gherkin is readable and follows Given/When/Then structure +- [ ] No implementation details in Gherkin (keep it business-focused) +- [ ] String quoting matches step style: single quotes for Cucumber Expressions, double for regex + +**Step Definitions**: +- [ ] Uses `@Slf4j` and logs each step +- [ ] Annotation style matches the system (Cucumber Expressions for Restful Booker, regex for Pet Store) +- [ ] Delegates to services (API) or pages (UI) — stepdefs should be thin orchestrators +- [ ] Assertions use AssertJ with `.as()` descriptions; `assertSoftly` for multi-field checks +- [ ] Parameters are properly captured and typed + +**Services** (API): +- [ ] Annotated with `@Component` and `@Scope("cucumber-glue")` +- [ ] Uses `@Autowired` for dependencies +- [ ] Validates response status codes +- [ ] Updates Storage after operations +- [ ] Logs important actions and results +- [ ] Constants for endpoint paths + +**Page Objects** (UI): +- [ ] Extends `BasePage` with self-type +- [ ] Constructor takes `BaseDriver`, calls `super(baseDriver)` +- [ ] Has `load()` and `isLoaded()` methods +- [ ] Locators are `private final By` fields with descriptive names +- [ ] Uses wait-based methods (`waitAndClick`, `waitAndSendKeys`, etc.) +- [ ] Action methods return `void` (project convention — not fluent API) +- [ ] Verification methods return `boolean` or specific types +- [ ] Uses `@Nullable` on parameters for negative/validation test methods +- [ ] Assertions done in step definitions, not page objects + +**DSOs**: +- [ ] Uses Lombok annotations correctly +- [ ] `@JsonProperty` for non-standard field names +- [ ] `@Builder(toBuilder = true)` for immutability + +**Entities**: +- [ ] Uses enums for status/type fields (not Strings) +- [ ] Includes `deleted` flag if applicable +- [ ] Uses `@Builder.Default` for defaults + +**General**: +- [ ] Consistent naming conventions +- [ ] No code duplication +- [ ] Proper package organization +- [ ] No hardcoded values (use properties or constants) +- [ ] Error handling where appropriate + +## Running Tests + +### Basic Execution + +```bash +# Run all sanity tests (default) +mvn clean verify + +# Run specific tags +mvn clean verify -Dtags='@api' +mvn clean verify -Dtags='@ui' +mvn clean verify -Dtags='@pet' + +# Complex tag expressions +mvn clean verify -Dtags='(@ui or @api) and (not @skip and not @bug)' +mvn clean verify -Dtags='@management and not @room-management' +``` + +### Parallelization + +```bash +# Run with 5 scenarios in parallel +mvn clean verify -DparallelCount=5 + +# Run with 10 scenarios in parallel +mvn clean verify -DparallelCount=10 +``` + +### Environment Selection + +```bash +# Use development environment +mvn clean verify -Denv=development + +# Use staging environment +mvn clean verify -Denv=staging +``` + +### Browser Configuration (UI tests) + +```bash +# Run with Chrome (default) +mvn clean verify -Dbrowser=chrome + +# Run with Firefox +mvn clean verify -Dbrowser=firefox + +# Run in headless mode +mvn clean verify -Dheadless + +# Run on Selenium Grid +mvn clean verify -Dremote=true -DremoteUrl=http://localhost:4444/wd/hub +``` + +### Combined Example + +```bash +mvn clean verify \ + -Dtags='(@api or @ui) and @sanity and not @bug' \ + -DparallelCount=5 \ + -Denv=development \ + -Dbrowser=chrome \ + -Dheadless \ + -Dremote=false +``` + +### Viewing Reports + +After test execution, view reports: +- **Cluecumber**: `target/cucumber/cluecumber-report/index.html` +- **Cucumber HTML**: `target/cucumber/cucumber-html-reports/overview-features.html` +- **Allure**: Run `allure serve target/cucumber/allure-results` + +## OpenAPI Model Generation + +This project uses OpenAPI Generator to create model classes from OpenAPI specifications. + +### OpenAPI Helper Script + +The skill includes a Python script (`scripts/openapi_helper.py`) to help work with OpenAPI specifications during test development. + +**List all available endpoints:** +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py list-endpoints \ + src/main/resources/openapi/restfulbooker/room-open-api.json +``` +This displays a table of all API endpoints with their methods, paths, operation IDs, and tags. + +**Get endpoint details:** +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py endpoint-details \ + src/main/resources/openapi/restfulbooker/room-open-api.json \ + /room/{id} +``` +This shows detailed information about a specific endpoint: parameters, request body schemas, response codes, and descriptions. + +**Generate DSO template:** +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate-dso \ + src/main/resources/openapi/restfulbooker/room-open-api.json \ + Room \ + com.levi9.functionaltests.rest.data.restfulbooker +``` +This generates a complete Java DSO class with: +- Lombok annotations (`@Getter`, `@Setter`, `@Builder`, `@NoArgsConstructor`, `@AllArgsConstructor`) +- Correct Java types based on OpenAPI schema (String, Integer, List, etc.) +- `@JsonProperty` annotations for non-standard field names +- JavaDoc comments from OpenAPI descriptions + +Use this when creating new DSOs — it ensures consistency with the OpenAPI spec and saves time. + +### Adding New OpenAPI Specs + +1. **Place spec file** in `src/main/resources/openapi/{system}/{domain}-open-api.json` + +2. **Add execution** in `pom.xml`: +```xml + + generate-{domain}-client + + generate + + + ${project.basedir}/src/main/resources/openapi/{system}/{domain}-open-api.json + java + ${project.build.directory}/generated-sources/generated + {system}.model.{domain} + false + true + + . + rest-assured + true + @lombok.experimental.SuperBuilder + + + +``` + +3. **Generate models**: +```bash +mvn clean compile +``` + +4. **Mark as sources** in IntelliJ: +- Right-click `target/generated-sources/generated` +- Select "Mark Directory as" → "Generated Sources Root" + +## Best Practices Summary + +1. **Always run tests after creation/modification** — Verify they work, fix if they fail. + +2. **Study existing code first** — Find similar tests and match their patterns exactly. + +3. **Use descriptive names** — Make code self-documenting. + +4. **Log everything** — Use `@Slf4j` and log steps, actions, and important data. + +5. **Assertions with descriptions** — Use `.as("description")` in AssertJ assertions. Use `assertSoftly` for multi-field checks. + +6. **Update Storage consistently** — Every create/update operation should call `storage.get{Resource}s().add(entity)`. + +7. **Wait-based UI interactions** — Always use `waitAndClick()`, `waitAndSendKeys()`, etc. for stability. Never use `Thread.sleep()`. + +8. **Page Object conventions** — Every page needs `load()` and `isLoaded()`. Use `void` for action methods, `boolean`/specific types for verification methods. Use `@Nullable` for validation test parameters. + +9. **Close the banner** — Always call `bannerPage.closeBanner()` after loading a Restful Booker page. + +10. **Verify with dry-run** — Always run `mvn test -Dtest=DryRunRunnerIT` to validate step definitions before running full tests. + +11. **Comprehensive coverage** — Happy path + rainy day + edge cases. + +12. **Clean Gherkin** — Business-focused, no implementation details. Use descriptive Background text. + +13. **Match step annotation style** — Use Cucumber Expressions (with single-quoted strings) for Restful Booker, regex (with double-quoted strings) for Pet Store. + +14. **Minimal effort maintenance** — Write code that's easy to understand and modify. + +## When to Use This Skill + +Use this skill whenever working with tests in this project: + +- Writing new Cucumber feature files +- Creating Gherkin scenarios +- Writing step definitions +- Building REST API tests (services, REST clients, DSOs) +- Building UI tests (page objects, Selenium interactions) +- Debugging failing tests +- Fixing test failures +- Refactoring test code +- Reviewing test code for quality +- Adding test coverage +- Generating test data +- Creating entities and updating Storage +- Adding custom parameter types +- Extending REST clients +- Working with OpenAPI models +- Improving test architecture + +Even if the user just mentions "write a test", "add coverage", "this test is failing", or "review this test", trigger this skill immediately. + +## Final Note + +The goal is to create tests that are **indistinguishable from existing code** in terms of quality, style, and patterns. Always prioritize consistency, readability, and reusability. When in doubt, look at existing similar tests and follow their exact approach. diff --git a/.github/skills/functional-tests-skill/scripts/README.md b/.github/skills/functional-tests-skill/scripts/README.md new file mode 100644 index 0000000..af37650 --- /dev/null +++ b/.github/skills/functional-tests-skill/scripts/README.md @@ -0,0 +1,277 @@ +# OpenAPI Helper Script + +This Python script helps developers work with OpenAPI specifications during test development. + +## Overview + +The `openapi_helper.py` script provides three main capabilities: +1. **List Endpoints** - Display all API endpoints from an OpenAPI spec +2. **Endpoint Details** - Show detailed information about a specific endpoint +3. **Generate DSO** - Generate Java DSO class templates from OpenAPI schemas + +## Features + +✅ **Multiple Sources**: Supports local files and remote URLs +✅ **Format Support**: Handles both JSON and YAML OpenAPI specifications +✅ **No Dependencies**: Uses Python standard library (PyYAML optional for YAML support) + +## Requirements + +- Python 3.x +- PyYAML (optional, only needed for YAML format support): `pip install pyyaml` + +## Usage + +### 1. List All Endpoints + +Display a table of all available API endpoints: + +```bash +python3 openapi_helper.py list-endpoints +``` + +**Examples:** + +Local JSON file: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py list-endpoints \ + src/main/resources/openapi/restfulbooker/room-open-api.json +``` + +Remote URL: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py list-endpoints \ + https://petstore.swagger.io/v2/swagger.json +``` + +Local YAML file: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py list-endpoints \ + specs/api-spec.yaml +``` + +**Output:** +``` +==================================================================================================== +Method Path Operation ID Tags +==================================================================================================== +GET /{id} getRoom room-controller +PUT /{id} updateRoom room-controller +DELETE /{id} deleteRoom room-controller +GET / getRooms room-controller +POST / createRoom room-controller +==================================================================================================== + +Total endpoints: 5 +``` + +### 2. Get Endpoint Details + +Show detailed information about a specific endpoint: + +```bash +python3 openapi_helper.py endpoint-details +``` + +**Examples:** + +Local file: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py endpoint-details \ + src/main/resources/openapi/restfulbooker/room-open-api.json \ + /room/{id} +``` + +Remote URL: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py endpoint-details \ + https://petstore.swagger.io/v2/swagger.json \ + /pet/{petId} +``` + +**Output includes:** +- HTTP methods supported +- Operation ID and summary +- Request parameters (path, query, header) +- Request body schema +- Response codes and descriptions + +### 3. Generate DSO Class Template + +Generate a complete Java DSO class from an OpenAPI schema: + +```bash +python3 openapi_helper.py generate-dso [package-name] +``` + +**Examples:** + +Local file: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate-dso \ + src/main/resources/openapi/restfulbooker/room-open-api.json \ + Room \ + com.levi9.functionaltests.rest.data.restfulbooker +``` + +Remote URL: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate-dso \ + https://petstore.swagger.io/v2/swagger.json \ + Pet \ + com.levi9.functionaltests.rest.data.petstore +``` + +**Output:** +```java +package com.levi9.functionaltests.rest.data.restfulbooker; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * Data Service Object for Room + * Generated from OpenAPI specification + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class RoomDSO { + + private Integer roomid; + + @JsonProperty("roomName") + private String roomName; + + private String type; + + // ... other fields +} +``` + +The generated DSO includes: +- ✅ Lombok annotations (`@Getter`, `@Setter`, `@Builder`, `@NoArgsConstructor`, `@AllArgsConstructor`) +- ✅ Correct Java types based on OpenAPI schema +- ✅ `@JsonProperty` annotations for non-standard field names +- ✅ JavaDoc comments from OpenAPI descriptions +- ✅ Proper imports for List, LocalDate, LocalDateTime + +## Type Mapping + +The script automatically maps OpenAPI types to Java types: + +| OpenAPI Type | OpenAPI Format | Java Type | +|--------------|----------------|-----------| +| string | - | String | +| string | date | LocalDate | +| string | date-time | LocalDateTime | +| integer | int32 | Integer | +| integer | int64 | Long | +| number | float | Float | +| number | double | Double | +| boolean | - | Boolean | +| array | - | List | +| object | - | Object or referenced type | + +## When to Use + +Use this script when: +- **Starting a new REST API test** - List endpoints to understand what's available +- **Creating DSO classes** - Generate templates that match the OpenAPI spec exactly +- **API documentation is unclear** - View endpoint details with parameters and schemas +- **API spec changes** - Regenerate DSOs to ensure consistency +- **Working with third-party APIs** - Fetch specs directly from remote URLs (e.g., Swagger UI endpoints) +- **Evaluating external APIs** - Quickly explore API structure without downloading files + +## Supported Formats + +| Source Type | Format | Example | +|-------------|--------|---------| +| Local file | JSON | `src/main/resources/openapi/room-open-api.json` | +| Local file | YAML | `specs/api-spec.yaml` | +| Remote URL | JSON | `https://petstore.swagger.io/v2/swagger.json` | +| Remote URL | YAML | `https://example.com/api/openapi.yaml` | +| Swagger UI | JSON | `https://api.example.com/swagger/v1/swagger.json` | + +## Integration with AI Skill + +This script is referenced in the `functional-tests-skill` SKILL.md: +- In the "Creating a New REST API Test" workflow (Step 4: Create DSOs) +- In the "OpenAPI Model Generation" section + +The AI assistant can invoke this script to help generate accurate test code that matches the OpenAPI specification. + +## Examples + +### Complete Workflow Example + +When creating a new REST API test for booking: + +1. **Discover available endpoints:** +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py list-endpoints \ + src/main/resources/openapi/restfulbooker/booking-open-api.json +``` + +2. **Get details about the POST endpoint:** +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py endpoint-details \ + src/main/resources/openapi/restfulbooker/booking-open-api.json \ + /booking +``` + +3. **Generate DSO for the Booking schema:** +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate-dso \ + src/main/resources/openapi/restfulbooker/booking-open-api.json \ + Booking \ + com.levi9.functionaltests.rest.data.restfulbooker > src/main/java/com/levi9/functionaltests/rest/data/restfulbooker/BookingDSO.java +``` + +4. **Review and customize** the generated DSO if needed + +### Working with Third-Party APIs + +When testing external APIs like Petstore: + +1. **List endpoints from remote spec:** +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py list-endpoints \ + https://petstore.swagger.io/v2/swagger.json +``` + +2. **Explore specific endpoint:** +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py endpoint-details \ + https://petstore.swagger.io/v2/swagger.json \ + /pet/{petId} +``` + +3. **Generate Pet DSO:** +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate-dso \ + https://petstore.swagger.io/v2/swagger.json \ + Pet \ + com.levi9.functionaltests.rest.data.petstore +``` + +## Notes + +- The script reads OpenAPI 3.0 and Swagger 2.0 specifications +- Supports both JSON and YAML formats +- For YAML files, PyYAML must be installed: `pip install pyyaml` +- URLs are fetched with a 30-second timeout +- If `package-name` is not provided, it defaults to `com.levi9.functionaltests.rest.data` +- Generated DSOs follow project conventions (Lombok usage, builder pattern, etc.) +- The script is read-only — it never modifies the OpenAPI spec files or remote sources +- User-Agent header is set to `OpenAPI-Helper/1.0` for URL requests diff --git a/.github/skills/functional-tests-skill/scripts/openapi_helper.py b/.github/skills/functional-tests-skill/scripts/openapi_helper.py new file mode 100755 index 0000000..90ceb91 --- /dev/null +++ b/.github/skills/functional-tests-skill/scripts/openapi_helper.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +""" +OpenAPI Helper Script for Functional Tests + +This script reads OpenAPI specifications and provides information to help with test implementation: +- List all available API endpoints +- Show endpoint details (methods, parameters, schemas) +- Generate DSO class templates from OpenAPI models + +Supports: +- Local JSON files +- Local YAML files +- Remote URLs (JSON/YAML) + +Usage: + python openapi_helper.py list-endpoints + python openapi_helper.py endpoint-details + python openapi_helper.py generate-dso + +Examples: + python openapi_helper.py list-endpoints src/main/resources/openapi/room-open-api.json + python openapi_helper.py list-endpoints https://petstore.swagger.io/v2/swagger.json + python openapi_helper.py generate-dso https://petstore.swagger.io/v2/swagger.json Pet +""" + +import json +import sys +from pathlib import Path +from typing import Dict, List, Any, Optional +from urllib.request import urlopen, Request +from urllib.error import URLError, HTTPError + + +def load_openapi_spec(spec_path: str) -> Dict[str, Any]: + """Load OpenAPI specification from JSON/YAML file or URL.""" + + # Check if it's a URL + if spec_path.startswith('http://') or spec_path.startswith('https://'): + try: + print(f"Fetching OpenAPI spec from URL: {spec_path}") + req = Request(spec_path, headers={'User-Agent': 'OpenAPI-Helper/1.0'}) + with urlopen(req, timeout=30) as response: + content = response.read().decode('utf-8') + + # Try to parse as JSON first + try: + return json.loads(content) + except json.JSONDecodeError: + # Try YAML if JSON fails + return _parse_yaml(content, spec_path) + except HTTPError as e: + print(f"HTTP Error {e.code}: {e.reason}") + print(f"Failed to fetch from URL: {spec_path}") + sys.exit(1) + except URLError as e: + print(f"URL Error: {e.reason}") + print(f"Failed to fetch from URL: {spec_path}") + sys.exit(1) + except Exception as e: + print(f"Error fetching URL: {e}") + sys.exit(1) + + # Local file + spec_file = Path(spec_path) + if not spec_file.exists(): + print(f"Error: File not found: {spec_path}") + sys.exit(1) + + with open(spec_file, 'r') as f: + content = f.read() + + # Determine format by extension or content + if spec_path.endswith('.json'): + return json.loads(content) + elif spec_path.endswith('.yaml') or spec_path.endswith('.yml'): + return _parse_yaml(content, spec_path) + else: + # Try JSON first, then YAML + try: + return json.loads(content) + except json.JSONDecodeError: + return _parse_yaml(content, spec_path) + + +def _parse_yaml(content: str, source: str) -> Dict[str, Any]: + """Parse YAML content, with fallback error if PyYAML not available.""" + try: + import yaml # type: ignore # PyYAML is optional dependency + return yaml.safe_load(content) + except ImportError: + print("Error: PyYAML is not installed.") + print("To parse YAML files, install it with: pip install pyyaml") + print(f"Alternatively, convert the spec to JSON format.") + sys.exit(1) + except Exception as e: + print(f"Error parsing YAML from {source}: {e}") + sys.exit(1) + + +def list_endpoints(spec: Dict[str, Any]) -> List[Dict[str, Any]]: + """Extract all endpoints from OpenAPI spec.""" + endpoints = [] + paths = spec.get('paths', {}) + + for path, methods in paths.items(): + for method, details in methods.items(): + if method.upper() in ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']: + endpoints.append({ + 'path': path, + 'method': method.upper(), + 'operationId': details.get('operationId', ''), + 'summary': details.get('summary', ''), + 'tags': details.get('tags', []) + }) + + return endpoints + + +def print_endpoints_table(endpoints: List[Dict[str, Any]]): + """Print endpoints in a formatted table.""" + print("\n" + "="*100) + print(f"{'Method':<8} {'Path':<40} {'Operation ID':<30} {'Tags':<20}") + print("="*100) + + for endpoint in endpoints: + tags = ', '.join(endpoint['tags']) if endpoint['tags'] else 'N/A' + print(f"{endpoint['method']:<8} {endpoint['path']:<40} {endpoint['operationId']:<30} {tags:<20}") + + print("="*100) + print(f"\nTotal endpoints: {len(endpoints)}\n") + + +def get_endpoint_details(spec: Dict[str, Any], path: str) -> Optional[Dict[str, Any]]: + """Get detailed information about a specific endpoint.""" + paths = spec.get('paths', {}) + + if path not in paths: + return None + + endpoint_data = paths[path] + details = {'path': path, 'methods': {}} + + for method, info in endpoint_data.items(): + if method.upper() in ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']: + method_details = { + 'operationId': info.get('operationId', ''), + 'summary': info.get('summary', ''), + 'description': info.get('description', ''), + 'parameters': info.get('parameters', []), + 'requestBody': info.get('requestBody', {}), + 'responses': info.get('responses', {}) + } + details['methods'][method.upper()] = method_details + + return details + + +def print_endpoint_details(details: Dict[str, Any]): + """Print detailed endpoint information.""" + print(f"\n{'='*80}") + print(f"Endpoint: {details['path']}") + print(f"{'='*80}\n") + + for method, info in details['methods'].items(): + print(f"{method} - {info['operationId']}") + print(f"Summary: {info['summary']}") + + if info['description']: + print(f"Description: {info['description']}") + + # Parameters + if info['parameters']: + print("\nParameters:") + for param in info['parameters']: + required = " (required)" if param.get('required', False) else "" + print(f" - {param['name']} ({param['in']}){required}: {param.get('description', '')}") + + # Request Body + if info['requestBody']: + print("\nRequest Body:") + content = info['requestBody'].get('content', {}) + for content_type, schema_info in content.items(): + print(f" Content-Type: {content_type}") + if 'schema' in schema_info: + print(f" Schema: {schema_info['schema']}") + + # Responses + if info['responses']: + print("\nResponses:") + for status_code, response_info in info['responses'].items(): + description = response_info.get('description', '') + print(f" {status_code}: {description}") + + print("\n" + "-"*80 + "\n") + + +def get_schema_definition(spec: Dict[str, Any], schema_name: str) -> Optional[Dict[str, Any]]: + """Get schema definition from OpenAPI spec (supports both OpenAPI 3.0 and Swagger 2.0).""" + # Try OpenAPI 3.0 format (components/schemas) + components = spec.get('components', {}) + schemas = components.get('schemas', {}) + + if schema_name in schemas: + return schemas.get(schema_name) + + # Try Swagger 2.0 format (definitions) + definitions = spec.get('definitions', {}) + if schema_name in definitions: + return definitions.get(schema_name) + + return None + + +def java_type_mapping(openapi_type: str, format_type: Optional[str] = None) -> str: + """Map OpenAPI types to Java types.""" + type_map = { + 'string': 'String', + 'integer': 'Integer', + 'number': 'Double', + 'boolean': 'Boolean', + 'array': 'List', + 'object': 'Object' + } + + # Handle format-specific types + if format_type: + format_map = { + 'int32': 'Integer', + 'int64': 'Long', + 'float': 'Float', + 'double': 'Double', + 'date': 'LocalDate', + 'date-time': 'LocalDateTime' + } + return format_map.get(format_type, type_map.get(openapi_type, 'Object')) + + return type_map.get(openapi_type, 'Object') + + +def generate_dso_template(spec: Dict[str, Any], schema_name: str, package_name: str = "com.levi9.functionaltests.rest.data") -> str: + """Generate Java DSO class template from OpenAPI schema.""" + schema = get_schema_definition(spec, schema_name) + + if not schema: + # List available schemas + available_schemas = [] + + # Check OpenAPI 3.0 format + components = spec.get('components', {}) + schemas = components.get('schemas', {}) + available_schemas.extend(schemas.keys()) + + # Check Swagger 2.0 format + definitions = spec.get('definitions', {}) + available_schemas.extend(definitions.keys()) + + error_msg = f"Schema '{schema_name}' not found in OpenAPI spec." + if available_schemas: + error_msg += f"\n\nAvailable schemas: {', '.join(sorted(set(available_schemas)))}" + return error_msg + + properties = schema.get('properties', {}) + required_fields = schema.get('required', []) + + # Start building the class + lines = [ + "package " + package_name + ";", + "", + "import com.fasterxml.jackson.annotation.JsonProperty;", + "import lombok.AllArgsConstructor;", + "import lombok.Builder;", + "import lombok.Getter;", + "import lombok.NoArgsConstructor;", + "import lombok.Setter;", + "", + "import java.util.List;", + "import java.time.LocalDate;", + "import java.time.LocalDateTime;", + "", + "/**", + f" * Data Service Object for {schema_name}", + " * Generated from OpenAPI specification", + " */", + "@Getter", + "@Setter", + "@NoArgsConstructor", + "@AllArgsConstructor", + "@Builder(toBuilder = true)", + f"public class {schema_name}DSO {{", + "" + ] + + # Add fields + for prop_name, prop_info in properties.items(): + prop_type = prop_info.get('type', 'object') + prop_format = prop_info.get('format') + prop_description = prop_info.get('description', '') + + java_type = java_type_mapping(prop_type, prop_format) + + # Handle arrays + if prop_type == 'array': + items = prop_info.get('items', {}) + item_type = items.get('type', 'Object') + item_ref = items.get('$ref', '') + + if item_ref: + # Extract schema name from $ref + item_type = item_ref.split('/')[-1] + else: + item_type = java_type_mapping(item_type, items.get('format')) + + java_type = f"List<{item_type}>" + + # Handle object references + if '$ref' in prop_info: + java_type = prop_info['$ref'].split('/')[-1] + + # Add JavaDoc if description exists + if prop_description: + lines.append(f" /**") + lines.append(f" * {prop_description}") + lines.append(f" */") + + # Add @JsonProperty if field name differs from Java conventions + if '_' in prop_name or prop_name != prop_name.lower(): + lines.append(f' @JsonProperty("{prop_name}")') + + # Convert snake_case to camelCase for Java field name + java_field_name = ''.join(word.capitalize() if i > 0 else word + for i, word in enumerate(prop_name.split('_'))) + + lines.append(f" private {java_type} {java_field_name};") + lines.append("") + + lines.append("}") + + return '\n'.join(lines) + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + command = sys.argv[1] + + if command == 'list-endpoints': + if len(sys.argv) < 3: + print("Usage: openapi_helper.py list-endpoints ") + sys.exit(1) + + spec_file = sys.argv[2] + spec = load_openapi_spec(spec_file) + endpoints = list_endpoints(spec) + print_endpoints_table(endpoints) + + elif command == 'endpoint-details': + if len(sys.argv) < 4: + print("Usage: openapi_helper.py endpoint-details ") + sys.exit(1) + + spec_file = sys.argv[2] + path = sys.argv[3] + spec = load_openapi_spec(spec_file) + details = get_endpoint_details(spec, path) + + if details: + print_endpoint_details(details) + else: + print(f"Endpoint '{path}' not found in OpenAPI spec.") + sys.exit(1) + + elif command == 'generate-dso': + if len(sys.argv) < 4: + print("Usage: openapi_helper.py generate-dso [package-name]") + sys.exit(1) + + spec_file = sys.argv[2] + schema_name = sys.argv[3] + package_name = sys.argv[4] if len(sys.argv) > 4 else "com.levi9.functionaltests.rest.data" + + spec = load_openapi_spec(spec_file) + dso_code = generate_dso_template(spec, schema_name, package_name) + print(dso_code) + + else: + print(f"Unknown command: {command}") + print(__doc__) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/readme.md b/readme.md index 18d10b6..b84a196 100644 --- a/readme.md +++ b/readme.md @@ -143,6 +143,74 @@ That will start Restful Booker Platform locally. After everything is up and running you will have Restful Booker Platform available at `http://kube.local`. +## AI-Assisted Test Development (Functional Tests Skill) + +This project includes an AI skill that helps developers write, debug, and review tests following project conventions. The skill is located at `.github/skills/functional-tests-skill/` and automatically guides AI assistants to generate code matching existing patterns. + +### Using with IntelliJ IDEA + +**Prerequisites**: [GitHub Copilot plugin](https://plugins.jetbrains.com/plugin/17718-github-copilot) installed and authenticated. + +**How to use**: +1. Open the Copilot Chat panel (View → Tool Windows → GitHub Copilot Chat) +2. Type `/` to see available commands — `functional-tests-skill` will appear in the list +3. Either: + - Type `/functional-tests-skill` followed by your request, or + - Just ask naturally: "write a test for...", "add coverage for...", "review this test code" + +The skill activates automatically when you mention test-related keywords (feature files, step definitions, page objects, REST API tests, etc.). + +**Example prompts**: +``` +/functional-tests-skill Create a REST API test for creating a pet in the pet store + +Write a UI test for the login page with valid and invalid credentials + +Review this step definition class for best practices + +Add a Scenario Outline for creating multiple rooms with different types +``` + +### Using with VS Code + +**Prerequisites**: [GitHub Copilot extension](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot) and [GitHub Copilot Chat extension](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot-chat) installed and authenticated. + +**How to use**: +1. Open the Copilot Chat view (View → Open View → GitHub Copilot Chat, or press `Ctrl+Shift+I` / `Cmd+Shift+I`) +2. Type `/` to see available slash commands — `functional-tests-skill` will appear +3. Either: + - Type `/functional-tests-skill` followed by your request, or + - Just ask naturally: "write a test for...", "debug this failing test" + +The skill activates automatically when you mention: writing tests, creating feature files, Gherkin scenarios, step definitions, REST API tests, UI tests, page objects, debugging tests, etc. + +**Example prompts**: +``` +/functional-tests-skill Create a complete REST API test for booking a room + +Generate page object and step definitions for the contact form + +This test is failing with a 400 error - help me debug it + +Review RoomManagementStepdef.java for code quality issues +``` + +**What the skill provides**: +- ✅ Correct project patterns (Service layer, Storage, Page Objects, Step Definitions) +- ✅ Proper annotations (`@Slf4j`, `@Component`, `@Scope("cucumber-glue")`) +- ✅ Correct step annotation styles (Cucumber Expressions for Restful Booker, regex for Pet Store) +- ✅ Wait-based Selenium interactions (no `Thread.sleep()`) +- ✅ AssertJ assertions with descriptive messages +- ✅ Lombok usage (`@Getter`, `@Builder`, etc.) +- ✅ Comprehensive test coverage (happy path + negative cases) +- ✅ Code review using project-specific checklist + +### OpenAPI Helper Script + +The skill includes an [OpenAPI helper script](.github/skills/functional-tests-skill/scripts/README.md) that reads OpenAPI specifications to assist with test development. It can list endpoints, show endpoint details, and generate Java DSO class templates automatically from OpenAPI schemas. + +See [scripts/README.md](.github/skills/functional-tests-skill/scripts/README.md) for complete usage instructions. + ## Codding standards and rules ### Coding Standards From 4595c5c00d77203954987052b507768408d8ce06 Mon Sep 17 00:00:00 2001 From: StraKoka Date: Fri, 17 Apr 2026 14:24:51 +0200 Subject: [PATCH 2/4] [NOJIRA] - Fix syntax for IDEA in readme --- readme.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/readme.md b/readme.md index b84a196..1d12aad 100644 --- a/readme.md +++ b/readme.md @@ -153,16 +153,16 @@ This project includes an AI skill that helps developers write, debug, and review **How to use**: 1. Open the Copilot Chat panel (View → Tool Windows → GitHub Copilot Chat) -2. Type `/` to see available commands — `functional-tests-skill` will appear in the list +2. Type `/skill:` to see available skills — `functional-tests-skill` will appear in the list 3. Either: - - Type `/functional-tests-skill` followed by your request, or + - Type `/skill:functional-tests-skill` followed by your request, or - Just ask naturally: "write a test for...", "add coverage for...", "review this test code" The skill activates automatically when you mention test-related keywords (feature files, step definitions, page objects, REST API tests, etc.). **Example prompts**: ``` -/functional-tests-skill Create a REST API test for creating a pet in the pet store +/skill:functional-tests-skill Create a REST API test for creating a pet in the pet store Write a UI test for the login page with valid and invalid credentials From ba8280ea10488e75aedff2a5c066528fbeda7364 Mon Sep 17 00:00:00 2001 From: StraKoka Date: Wed, 22 Apr 2026 15:16:19 +0200 Subject: [PATCH 3/4] [NOJIRA] - Adding script to skill to be able to fetch --- .../skills/functional-tests-skill/SKILL.md | 1181 +++++++++-------- .../scripts/.env.example | 17 + .../functional-tests-skill/scripts/README.md | 220 +-- .../scripts/jira_ticket_fetcher.py | 582 ++++++++ 4 files changed, 1339 insertions(+), 661 deletions(-) create mode 100644 .github/skills/functional-tests-skill/scripts/.env.example create mode 100644 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py diff --git a/.github/skills/functional-tests-skill/SKILL.md b/.github/skills/functional-tests-skill/SKILL.md index aef8160..86f90b8 100644 --- a/.github/skills/functional-tests-skill/SKILL.md +++ b/.github/skills/functional-tests-skill/SKILL.md @@ -1,13 +1,13 @@ --- name: functional-tests-skill -description: Expert testing skill for the Levi9 functional-tests project — a Java 23 / Maven / Cucumber BDD framework using REST Assured for API tests and Selenium 4 for UI tests with Spring DI. Use this skill whenever the user wants to write, create, add, extend, debug, review, or refactor any kind of test in this project. Trigger when the user mentions writing tests, adding test coverage, creating feature files, Gherkin scenarios, step definitions, stepdefs, REST API tests, UI tests, Selenium, page objects, debugging failing tests, fixing test failures, test architecture improvements, generating test data, reviewing test code, or anything related to testing in this Cucumber/REST Assured/Selenium codebase. Even if the user just says "write a test" or "add coverage for X" or "this test is failing", this skill should activate immediately. +description: Expert testing skill for the Levi9 functional-tests project — a Java 23 / Maven / Cucumber BDD framework using REST Assured for API tests and Selenium 4 for UI tests with Spring DI. Use this skill whenever the user wants to write, create, add, extend, debug, review, or refactor any kind of test in this project. Trigger when the user mentions writing tests, adding test coverage, creating feature files, Gherkin scenarios, step definitions, stepdefs, REST API tests, UI tests, Selenium, page objects, debugging failing tests, fixing test failures, test architecture improvements, generating test data, reviewing test code, fetching Jira tickets, fetching GitHub issues, creating tests from Jira/GitHub/board tickets, or anything related to testing in this Cucumber/REST Assured/Selenium codebase. Even if the user just says "write a test" or "add coverage for X" or "this test is failing" or "write tests for PROJ-123" or "write tests for owner/repo#42", this skill should activate immediately. --- # Functional Tests Skill This skill guides you in working with the **Levi9 functional-tests** project — a Java 23 / Maven / Cucumber BDD framework that tests two systems: -- **Pet Store API** (REST API tests only) -- **Restful Booker Platform** (REST API + UI tests) +- **Pet Store API** (REST API tests only) — uses hand-crafted DSO classes for request/response bodies +- **Restful Booker Platform** (REST API + UI tests) — uses OpenAPI-generated models from `restfulbooker.model.*` for request/response bodies, plus a few hand-crafted DSOs ## Core Principles @@ -34,7 +34,7 @@ Service Layer (Business logic) ↓ REST Clients (BaseRestClient + specific) | UI Pages (BasePage + specific) ↓ -DSOs (Data Service Objects) | Storage (Entity classes) +DSOs / OpenAPI Models | Storage (Entity classes) ``` ### Key Packages @@ -43,19 +43,26 @@ DSOs (Data Service Objects) | Storage (Entity classes) src/main/java/com/levi9/functionaltests/ ├── exceptions/ # FunctionalTestsException ├── rest/ -│ ├── client/ # BaseRestClient, PetStoreRestClient, RestfulBookerRestClient -│ ├── data/ # DSO classes (request/response DTOs) +│ ├── client/ # BaseRestClient, PetStoreRestClient, RestfulBookerRestClient, RandomDogRestClient +│ ├── data/ # DSO classes, enums, helper classes +│ │ ├── petstore/ # PetDSO, OrderDSO, CategoryDSO, TagDSO, MessageDSO, PetStatus, OrderStatus +│ │ ├── restfulbooker/ # BookingsDSO, RoomAmenities, RoomType +│ │ └── randomdogimage/ # RandomDogImageDSO │ └── service/ # Service layer (@Component with business logic) +│ ├── petstore/ # PetService, StoreOrderService +│ ├── restfulbooker/ # AuthService, BookingService, RoomService +│ └── randomdogimage/ # RandomDogImageService ├── storage/ # Storage.java + Entity classes -│ ├── domain/ -│ │ ├── petstore/ # PetEntity, OrderEntity -│ │ └── restfulbooker/ # RoomEntity -│ └── ScenarioEntity # Test scenario metadata +│ ├── ScenarioEntity # Test scenario metadata + embed helpers +│ └── domain/ +│ ├── petstore/ # PetEntity, OrderEntity +│ └── restfulbooker/ # RoomEntity ├── ui/ -│ ├── base/ # BaseDriver, BasePage, Browser enum +│ ├── base/ # BaseDriver, BaseDriverListener, BasePage, Browser enum │ ├── helpers/ # WaitHelper, ActionsHelper, UploadHelper -│ └── pages/ # Page Object classes -└── util/ # FakeUtil, FileUtil +│ └── pages/ +│ └── restfulbooker/ # AdminPage, BannerPage, FrontPage, HeaderPage, RoomsPage +└── util/ # FakeUtil (random emails/phones), FileUtil src/test/java/com/levi9/functionaltests/ ├── config/ # SpringConfig (@PropertySource, @ComponentScan) @@ -63,16 +70,47 @@ src/test/java/com/levi9/functionaltests/ ├── runners/ # DryRunRunnerIT (validation) ├── stepdefs/ # Step definitions by domain │ ├── petstore/ # PetStepdef, StoreStepdef -│ └── restfulbooker/ # LoginStepdef, RoomManagementStepdef, etc. -└── typeregistry/ # Custom Cucumber parameter types +│ └── restfulbooker/ # LoginStepdef, RoomManagementStepdef, BookingStepdef, ContactStepdef +└── typeregistry/ # ParameterTypes.java — custom Cucumber parameter types src/test/resources/features/ -├── pet-store/ # Pet Store features -└── restful-booker-platform/ # Restful Booker features +├── pet-store/ # Pet Store features +│ ├── pet.feature +│ └── store.feature +└── restful-booker-platform/ # Restful Booker features ├── admin-panel/ + │ ├── login.feature + │ └── room-management.feature └── front-page/ + ├── book-a-room.feature + ├── book-a-room-invalid-validation.feature + ├── contact-hotel.feature + └── contact-hotel-invalid-validation.feature ``` +### Generated Models vs Hand-Crafted DSOs + +This is a critical distinction: + +**Pet Store** uses hand-crafted DSOs in `com.levi9.functionaltests.rest.data.petstore`: +```java +PetDSO, OrderDSO, CategoryDSO, TagDSO, MessageDSO +``` + +**Restful Booker** uses OpenAPI-generated models in `restfulbooker.model.*`: +```java +restfulbooker.model.room.Room, Rooms +restfulbooker.model.auth.Auth, Token +restfulbooker.model.booking.Booking, Bookings +// etc. +``` +Plus a few hand-crafted classes in `com.levi9.functionaltests.rest.data.restfulbooker`: +```java +BookingsDSO, RoomAmenities, RoomType +``` + +When creating new Restful Booker tests, prefer using the generated models for API request/response bodies. When creating Pet Store tests, create hand-crafted DSOs. + ## Test Types ### REST API Tests (`@api`) @@ -85,7 +123,7 @@ API tests use **REST Assured** via service layer pattern. Services are Spring `@ UI tests use **Selenium 4** via Page Object pattern. All pages extend `BasePage` which provides wait-based interactions. -**Tags**: `@ui`, plus domain tags like `@login`, `@management`, `@booking`, `@contact`, etc. +**Tags**: `@ui`, plus domain tags like `@login`, `@management`, `@room-management`, `@booking`, `@contact`, etc. ### Tags Reference @@ -121,58 +159,106 @@ Hooks in `src/test/java/com/levi9/functionaltests/hooks/Hooks.java` manage test ### 1. Service Layer Pattern (REST API Tests) -Services encapsulate business logic, make REST calls, validate responses, and update Storage. +Services encapsulate business logic, make REST calls, validate responses, and update Storage. Services use **constructor injection** (not field `@Autowired`) and **throw `FunctionalTestsException`** on bad status codes (not AssertJ assertions). -**Template**: +**Real example from PetService:** ```java @Slf4j @Component @Scope("cucumber-glue") -public class {Domain}Service { - - @Autowired - private {System}RestClient client; - +public class PetService { + + public static final String REST_PATH = "v2/pet/"; + + private final PetStoreRestClient petStoreRestClient; + private final Storage storage; + @Autowired - private Storage storage; - - public void performAction(String param) { - log.info("Performing action with param: {}", param); - - // 1. Build request DSO - {Action}{Resource}DSO requestBody = {Action}{Resource}DSO.builder() - .field(param) + public PetService(final PetStoreRestClient petStoreRestClient, final Storage storage) { + this.petStoreRestClient = petStoreRestClient; + this.storage = storage; + } + + public void addPetToStore(final String petName) { + final PetDSO body = PetDSO.builder() + .id(ThreadLocalRandom.current().nextInt(Integer.MAX_VALUE)) + .name(petName) + .status(PENDING.getValue()) .build(); - - // 2. Call REST client - Response response = client.post(requestBody, null, "/path/to/endpoint"); - - // 3. Validate response - assertThat(response.statusCode()) - .as("Status code should be 200") - .isEqualTo(200); - - // 4. Extract response - {Resource}DSO responseBody = response.as({Resource}DSO.class); - - // 5. Update storage - {Resource}Entity entity = {Resource}Entity.builder() - .id(responseBody.getId()) - .field(responseBody.getField()) + + final Response response = petStoreRestClient.post(body, null, REST_PATH); + if (response.statusCode() != HttpStatus.SC_OK) { + throw new FunctionalTestsException("Pet can not be added. Expected {}, but actual {}. Response message: {}", + HttpStatus.SC_OK, response.statusCode(), response.getBody().prettyPrint()); + } + + final PetEntity pet = PetEntity.builder() + .id(body.getId()) + .name(body.getName()) + .status(PENDING) .build(); - - storage.get{Resource}s().add(entity); - - log.info("{Resource} created with ID: {}", entity.getId()); + + storage.getPets().add(pet); } } ``` -**Key points**: -- Always use `@Slf4j` for logging -- Use `@Autowired` for dependency injection -- Use `@Scope("cucumber-glue")` for scenario-scoped lifecycle -- Validate responses with AssertJ assertions using `.as()` for descriptive messages +**Real example from RoomService (uses OpenAPI generated model):** +```java +@Slf4j +@Component +@Scope("cucumber-glue") +public class RoomService { + + public static final String REST_PATH = "room/"; + + private final RestfulBookerRestClient restfulBookerRestClient; + private final BookingService bookingService; + private final Storage storage; + + @Autowired + public RoomService(final RestfulBookerRestClient restfulBookerRestClient, final BookingService bookingService, final Storage storage) { + this.restfulBookerRestClient = restfulBookerRestClient; + this.bookingService = bookingService; + this.storage = storage; + } + + public void createRoom(final String roomName, final RoomType roomType, final boolean accessible, + final String roomPrice, final RoomAmenities roomAmenities) { + + // Uses OpenAPI-generated Room model, not a hand-crafted DSO + final Room body = Room.builder() + .roomName(roomName) + .roomPrice(Integer.parseInt(roomPrice)) + .type(roomType.getValue()) + .description("Created with Java Cucumber E2E Test Automation Framework") + .accessible(accessible) + .features(roomAmenities.getAmenitiesAsList()) + .image(getImageUrl(roomType)) + .build(); + + final Response response = restfulBookerRestClient.post(body, null, REST_PATH); + if (response.statusCode() != HttpStatus.SC_CREATED) { + throw new FunctionalTestsException("Room can not be created. Expected {}, but actual {}. Response message: {}", + HttpStatus.SC_OK, response.statusCode(), response.getBody().prettyPrint()); + } + + final Room createdRoom = response.as(Room.class); + final RoomEntity roomEntity = new RoomEntity(body); + roomEntity.setRoomId(createdRoom.getRoomid()); + + storage.getRooms().add(roomEntity); + } +} +``` + +**Key patterns for services:** +- Always use `@Slf4j`, `@Component`, `@Scope("cucumber-glue")` +- **Constructor injection** with `@Autowired` on constructor, storing dependencies in `private final` fields +- **Throw `FunctionalTestsException`** on unexpected status codes — do NOT use AssertJ assertions in services +- Use `HttpStatus.SC_OK`, `HttpStatus.SC_CREATED`, `HttpStatus.SC_ACCEPTED`, etc. from `org.apache.http.HttpStatus` +- Use `response.getBody().prettyPrint()` in exception messages for debugging +- `public static final String REST_PATH` constant for endpoint base path - Update Storage with entities after successful operations - Log important actions and results @@ -180,75 +266,35 @@ public class {Domain}Service { Page objects extend `BasePage` and use wait-based Selenium interactions. -**Template**: +**Real example from AdminPage:** ```java @Slf4j @Component @Scope("cucumber-glue") -public class {Page}Page extends BasePage<{Page}Page> { - - // Page locator for load/isLoaded checks - private final By page = By.xpath("//*[@data-testid='{page}-header']"); - - // Locators as private final fields - private final By fieldInput = By.id("fieldId"); - private final By submitButton = By.cssSelector(".submit-btn"); - private final By successMessage = By.xpath("//div[@class='success']"); - private final By errorMessages = By.cssSelector("div.alert.alert-danger"); - - protected {Page}Page(final BaseDriver baseDriver) { +public class AdminPage extends BasePage { + + private final By page = By.xpath("//*[@data-testid='login-header']"); + private final By usernameField = By.id("username"); + private final By passwordField = By.id("password"); + private final By loginButton = By.id("doLogin"); + + protected AdminPage(final BaseDriver baseDriver) { super(baseDriver); } - - /** - * Checks if page is loaded. - * - * @return true if yes, otherwise false - */ + public boolean isLoaded() { return isElementVisible(page, 5); } - - /** - * Load Page. - */ + public void load() { - openPage(getRestfulBookerPlatformUrl() + "#/{path}", page); + openPage(getRestfulBookerPlatformUrl() + "#/admin", page); } - - /** - * Fills the form with provided data. Null parameters skip the field (for negative tests). - * - * @param field field value, nullable for validation tests - */ - public void fillForm(@Nullable final String field) { - if (null != field) { - waitAndSendKeys(fieldInput, field); - } - } - - /** - * Clicks submit button - */ - public void clickSubmit() { - waitAndClick(submitButton); - log.info("Clicked submit button"); - } - - /** - * Checks if success message is displayed - */ - public boolean isSuccessMessageDisplayed() { - return isElementVisible(successMessage, 10); - } - - /** - * Get list of error messages displayed on the page. - * - * @return list of error message strings - */ - public List getErrorMessages() { - return waitAndGetWebElement(errorMessages).findElements(By.cssSelector("p")).stream().map(WebElement::getText).toList(); + + public void login(final String username, final String password) { + waitAndSendKeys(usernameField, username); + waitAndSendKeys(passwordField, password); + waitAndClick(loginButton); + log.info("Login via UI using username: '{}' and password '{}'", username, password); } } ``` @@ -259,15 +305,66 @@ public class {Page}Page extends BasePage<{Page}Page> { - **Every page MUST have**: `isLoaded()` (calls `isElementVisible(pageLocator, 5)`) and `load()` (calls `openPage(url, pageLocator)`) - Use `getRestfulBookerPlatformUrl()` for base URL (provided by BasePage via `@Value`) - Use descriptive locator names as `private final By` fields -- Use wait-based methods from BasePage: `waitAndClick()`, `waitAndSendKeys()`, `waitAndSelectByValue()`, `waitAndGetText()`, `waitAndGetAttribute()`, etc. +- Use wait-based methods from BasePage: `waitAndClick()`, `waitAndSendKeys()`, `waitAndSelectByValue()`, `waitAndSelectByVisibleText()`, `waitAndSelectByIndex()`, `waitAndGetText()`, `waitAndGetAttribute()`, `waitAndGetWebElement()`, etc. - **Return type**: Use `void` for action methods (this project's convention, not fluent API) -- Use `@Nullable` on parameters for methods used in negative/validation tests (skip interaction when null) +- Use `@Nullable` (from `javax.annotation.Nullable`) on parameters for methods used in negative/validation tests (skip interaction when null) - Error messages: retrieve via `findElements(By.cssSelector("p")).stream().map(WebElement::getText).toList()` - Log actions for debugging +- For drag-and-drop use `getActionsHelper().dragAndDrop(fromElement, toElement)` (from BasePage) -### 3. DSO (Data Service Object) Pattern +### 3. BaseRestClient Method Signatures -DSOs are request/response DTOs using Lombok. +All REST clients extend `BaseRestClient`. The **actual** method signatures are: + +```java +Response post(Object body, Map parameters, String path) +Response put(Object body, Map parameters, String path) +Response get(Map parameters, String path) +Response delete(Map parameters, String path) +Response uploadFile(File file, Map parameters, String path) +``` + +Pass `null` for `parameters` when query parameters are not needed. Pass `null` for `body` when no request body is needed. + +**Example with query parameters (from BookingService):** +```java +final Map parameters = new HashMap<>(); +parameters.put("roomId", Integer.toString(room.getRoomId())); +final Response response = restfulBookerRestClient.get(parameters, REST_PATH); +``` + +**Example without parameters (most common):** +```java +final Response response = petStoreRestClient.post(body, null, REST_PATH); +final Response response = petStoreRestClient.get(null, REST_PATH + pet.getId()); +final Response response = petStoreRestClient.delete(null, REST_PATH + pet.getId()); +``` + +### 4. REST Client Pattern + +REST clients are simple wrappers that extend `BaseRestClient` and pass the base URL from properties: + +```java +@Component +@Scope("cucumber-glue") +public class PetStoreRestClient extends BaseRestClient { + + public PetStoreRestClient(@Value("${pet-store.url}") final String serviceUrl) { + super(serviceUrl); + } +} +``` + +Base URLs are defined in `src/test/resources/application-{env}.properties`: +```properties +restful-booker-platform.url=http://localhost/ +pet-store.url=https://petstore.swagger.io/ +random.dog.url=https://random.dog/ +``` + +### 5. DSO (Data Service Object) Pattern + +DSOs are request/response DTOs using Lombok. Used for Pet Store and some Restful Booker classes. **Template**: ```java @@ -277,12 +374,12 @@ DSOs are request/response DTOs using Lombok. @AllArgsConstructor @Builder(toBuilder = true) public class {Action}{Resource}DSO { - + @JsonProperty("field_name") private String fieldName; - + private Integer count; - + private List items; } ``` @@ -292,37 +389,118 @@ public class {Action}{Resource}DSO { - Use `@JsonProperty` when JSON field names differ from Java conventions - Use `@Builder(toBuilder = true)` for immutability patterns -### 4. Entity Pattern +### 6. Enum Pattern + +Enums follow a consistent pattern with a `String` value and a `getEnum()` factory method: + +```java +public enum RoomType { + SINGLE("Single"), + TWIN("Twin"), + DOUBLE("Double"), + FAMILY("Family"), + SUITE("Suite"); + + @Getter(AccessLevel.PUBLIC) + private final String value; + + RoomType(final String value) { + this.value = value; + } + + public static RoomType getEnum(final String value) { + for (final RoomType roomType : values()) { + if (roomType.getValue().equals(value)) { + return roomType; + } + } + throw new FunctionalTestsException("Room Type Enum with value {} not found!", value); + } +} +``` + +### 7. Entity Pattern Entities represent test-side domain objects stored in Storage. -**Template**: +**Pet Store entities use builder pattern:** ```java @Getter @Setter -@Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor -public class {Resource}Entity { - +@Builder(toBuilder = true) +public class PetEntity { + private Integer id; + private CategoryDSO category; private String name; - private {Status}Enum status; // Use enums for status fields - - @Builder.Default + private List photoUrls; + private List tags; + private PetStatus status; + @Default private boolean deleted = false; } ``` -**Key points**: -- Use `@Builder.Default` for default values -- Use enums for status/type fields (not Strings) -- Include a `deleted` flag if the resource can be deleted +**Restful Booker entities may have a constructor from generated model:** +```java +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class RoomEntity { + + private Integer roomId; + private String roomName; + private Integer roomPrice; + private String description; + private RoomType type; + private Boolean accessible; + private String image; + private RoomAmenities amenities; + + // Constructor from OpenAPI-generated model + public RoomEntity(final Room room) { + this.roomId = room.getRoomid(); + this.roomName = room.getRoomName(); + // ... mapping from generated model fields + } +} +``` -### 5. Storage Pattern +### 8. Storage Pattern `Storage` is a Spring `@Component` with `@Scope("cucumber-glue")` that maintains test state. It uses Lombok `@Getter` to auto-generate getters — there are no explicit getter or add methods. +**Current Storage fields:** +```java +@Getter +@Component +@Scope("cucumber-glue") +public class Storage { + + // Test Scenario + private final ScenarioEntity testScenario = new ScenarioEntity(); + + // Used for REST API tests + private final List pets = new ArrayList<>(); + private final List orders = new ArrayList<>(); + + // Used for UI tests + private final List rooms = new ArrayList<>(); + + public PetEntity getLastPet() { + return pets.stream().reduce((first, last) -> last) + .orElseThrow(() -> new FunctionalTestsException("Last Pet not found!")); + } + + public OrderEntity getLastOrder() { /* same pattern */ } + public RoomEntity getLastRoom() { /* same pattern */ } +} +``` + **Adding a new entity to Storage**: 1. Add a field: `private final List<{Resource}Entity> {resource}s = new ArrayList<>();` @@ -340,7 +518,19 @@ public {Resource}Entity getLast{Resource}() { storage.get{Resource}s().add(entity); ``` -### 6. Step Definitions +### 9. ScenarioEntity — Embedding Artifacts + +`ScenarioEntity` provides methods to embed artifacts into Cucumber reports. Step definitions call these to attach PDFs, images, or HTML: + +```java +storage.getTestScenario().embedPdfToScenario(); // Embeds a static dummy PDF +storage.getTestScenario().embedPicture(imageUrl); // Embeds an image from URL +storage.getTestScenario().embedHtml(htmlContent); // Embeds HTML content +``` + +These are used in stepdefs with the `@pdf`, `@image`, `@html` tags to demonstrate report embedding. + +### 10. Step Definitions Step definitions are in `src/test/java/com/levi9/functionaltests/stepdefs/{system}/`. @@ -348,22 +538,34 @@ This project uses **two step annotation styles**: #### Style A: Cucumber Expressions (Restful Booker tests) -Used with `{string}`, `{int}`, and custom types like `{roomType}`, `{accessible}`. Gherkin uses **single quotes** for `{string}` values. +Used with `{string}`, `{int}`, and custom types like `{roomType}`, `{accessible}`. Gherkin uses **single quotes** for `{string}` values. Supports alternative text with `/` (e.g., `Validation/Mandatory`). ```java @Slf4j -public class {Domain}Stepdef { - +public class RoomManagementStepdef { + @Autowired private Storage storage; - + @Autowired - private {Domain}Service service; - + private RoomService roomService; + + @Autowired + private RoomsPage roomsPage; + + @Autowired + private BannerPage bannerPage; + @Given("User has created {roomType} type {accessible} room {string} priced at {int} GBP with {string}") public void userCreatedRoom(final RoomType roomType, final boolean accessible, final String roomName, final int roomPrice, final String features) { - log.info("Step: user created room '{}'", roomName); - service.createRoom(roomName, roomType, accessible, Integer.toString(roomPrice), new RoomAmenities(features)); + final RoomAmenities roomAmenities = new RoomAmenities(features); + roomService.createRoom(roomName, roomType, accessible, Integer.toString(roomPrice), roomAmenities); + } + + @Then("User will get validation/mandatory error message: {string}") + public void assertValidationOrMandatoryErrorMessage(final String message) { + assertThat(roomsPage.getValidationOrMandatoryErrorMessages()).as("Message '" + message + "' is not displayed!").contains(message); + log.info("Room Creation Validation / Mandatory Error Message '{}' is displayed", message); } } ``` @@ -371,31 +573,48 @@ public class {Domain}Stepdef { Matching Gherkin (single quotes): ```gherkin Given User has created Single type Accessible room '1408' priced at 50 GBP with 'WiFi, TV and Safe' +Then User will get mandatory error message: 'Room name must be set' ``` +**Cucumber Expression alternative text**: Use `/` to match either word in Gherkin. Example: +- Step: `@Then("Visitor will get Booking Validation/Mandatory Error Message: {string}")` +- Gherkin: `Then Visitor will get Booking Validation Error Message: 'some error'` +- Or: `Then Visitor will get Booking Mandatory Error Message: 'some error'` + +**Cucumber Expression optional text**: Use `(invalid )` (with space inside parentheses) to optionally match text: +- Step: `@When("Visitor {string} {string} with an (invalid )email {string} and phone number {string} tries to book a room {string}")` +- Matches both: `with an email 'x@y.com'` and `with an invalid email 'bad'` + #### Style B: Regex patterns (Pet Store tests) Used with `^...$` anchors, `"([^"]*)"` or `(.*)` for captures. Gherkin uses **double quotes** for string values. ```java @Slf4j -public class {Domain}Stepdef { - +public class PetStepdef { + @Autowired private Storage storage; - + @Autowired - private {Domain}Service service; - + private PetService petService; + @Given("^[Uu]ser add(?:s|ed) pet \"(.*)\" to the pet store$") public void addPet(final String petName) { - log.info("Step: user adds pet '{}'", petName); - service.addPetToStore(petName); + petService.addPetToStore(petName); + log.info("Pet " + petName + " added to the store."); } - + + @When("^[Pp]et status is set to \"(available|pending|sold)\"$") + public void setPetStatus(final String petStatus) { + final PetEntity pet = storage.getLastPet(); + final PetStatus status = PetStatus.getEnum(petStatus); + petService.updatePetStatus(pet, status); + log.info("Pet status is set to " + petStatus); + } + @Then("^[Ii]t (?:will be|is)? possible to sell it$") public void validatePossibleToSell() { - log.info("Step: validate possible to sell"); final PetEntity expectedPet = storage.getLastPet(); final PetDSO actualPet = petService.getPet(expectedPet); assertThat(actualPet.getStatus()).as("Pet is not available!").isEqualTo(AVAILABLE.getValue()); @@ -403,30 +622,33 @@ public class {Domain}Stepdef { } ``` -Matching Gherkin (double quotes): -```gherkin -Given User added pet "Beagle" to the pet store -``` - **Both styles are equally valid.** Match the style of the system you're extending. -### 7. Custom Parameter Types +**Pet Store regex patterns**: Use `[Uu]`, `[Pp]`, `[Ii]`, `[Oo]`, `[Rr]` for case-insensitive first letter. Use `(?:s|ed)` for tense flexibility. Use `(?:will be|is)?` for assertion flexibility. + +### 11. Custom Parameter Types Define custom parameter types in `src/test/java/com/levi9/functionaltests/typeregistry/ParameterTypes.java`. -**Template**: +**Current types:** ```java -@ParameterType("Value1|Value2|Value3") -public {Type} {type}(final String value) { - return {Type}.getEnum(value); +public class ParameterTypes { + + @ParameterType("Single|Twin|Double|Family|Suite") + public RoomType roomType(final String roomType) { + return RoomType.getEnum(roomType); + } + + @ParameterType("Accessible|Not Accessible") + public boolean accessible(final String accessible) { + return !accessible.toLowerCase().contains("not"); + } } ``` -**Note**: The method parameter is always `String` — the conversion to the target type happens inside the method body. - -This allows Gherkin steps like: `When user creates a Single type room` where `Single` is auto-converted to `RoomType.SINGLE`. +This allows Gherkin steps like: `When user creates a Single type Accessible room` where `Single` → `RoomType.SINGLE` and `Accessible` → `true`. -### 8. FunctionalTestsException +### 12. FunctionalTestsException The project's custom exception uses SLF4J-style `{}` placeholder formatting: @@ -435,7 +657,12 @@ throw new FunctionalTestsException("Order with ID {} not found!", orderId); throw new FunctionalTestsException("Expected status {} but got {}", expectedStatus, actualStatus); ``` -### 9. Soft Assertions +Also supports wrapping another exception: +```java +throw new FunctionalTestsException(e); +``` + +### 13. Soft Assertions Use `assertSoftly` for multi-field validations — all assertions run even if early ones fail: @@ -447,29 +674,40 @@ assertSoftly(softly -> { }); ``` -### 10. BaseRestClient Methods +### 14. FakeUtil and RandomStringUtils -All REST clients extend `BaseRestClient` which provides: +For generating test data, use the existing utility classes: + +**FakeUtil** (`com.levi9.functionaltests.util.FakeUtil`): +```java +FakeUtil.getRandomEmail() // e.g., "abcXyz123@mail.com" +FakeUtil.getRandomPhoneNumber() // e.g., "123-456-7890" +``` +**RandomStringUtils** (`org.apache.commons.lang3.RandomStringUtils`) — used directly in stepdefs: ```java -Response post(Object requestBody, CookieFilter auth, String path) -Response put(Object requestBody, CookieFilter auth, String path) -Response get(CookieFilter auth, String path) -Response delete(CookieFilter auth, String path) -Response uploadFile(String filePath, CookieFilter auth, String path) +RandomStringUtils.randomAlphabetic(firstNameLength) // Random letters +RandomStringUtils.randomAlphanumeric(200) // Random letters+digits +RandomStringUtils.randomNumeric(phoneNumberLength) // Random digits ``` -Pass `null` for `auth` when authentication is not needed. +### 15. RoomAmenities Helper -### 11. Cucumber Alternative Text +`RoomAmenities` is a helper class that converts between string representations and structured amenity data: -Gherkin supports `validation/mandatory` syntax to match either word: +```java +// From comma-separated string (used in Gherkin steps) +final RoomAmenities amenities = new RoomAmenities("WiFi, TV and Safe"); +amenities.isWifi(); // true +amenities.isTv(); // true +amenities.isSafe(); // true -```gherkin -Then User will get validation/mandatory error message: 'Room name must be set' -``` +// To list of strings (for API requests) +amenities.getAmenitiesAsList(); // ["WiFi", "TV", "Safe"] -This matches step definition: `@Then("User will get validation/mandatory error message: {string}")` +// To display string (for UI assertions) +amenities.getRoomDetailsFromAmenities(); // "WiFi, TV, Safe" or "No features added to the room" +``` ## Naming Conventions @@ -506,12 +744,12 @@ Feature: {Domain} Management Given user creates a {resource} with name "Test {Resource}" When user retrieves the {resource} Then the {resource} should have status "ACTIVE" - + @critical Scenario Outline: User can create multiple {resource}s Given user creates a {resource} with name "" Then the {resource} should be created successfully - + Examples: | name | | Resource1 | @@ -524,120 +762,20 @@ Feature: {Domain} Management Location: `src/test/java/com/levi9/functionaltests/stepdefs/{system}/{Domain}Stepdef.java` -```java -@Slf4j -public class {Domain}Stepdef { - - @Autowired - private Storage storage; - - @Autowired - private {Domain}Service service; - - @Given("^user creates a {resource} with name \"([^\"]*)\"$") - public void userCreates{Resource}(String name) { - log.info("Step: user creates a {resource} with name '{}'", name); - service.create{Resource}(name); - } - - @When("^user retrieves the {resource}$") - public void userRetrievesThe{Resource}() { - log.info("Step: user retrieves the {resource}"); - {Resource}Entity entity = storage.getLast{Resource}(); - service.retrieve{Resource}(entity.getId()); - } - - @Then("^the {resource} should have status \"([^\"]*)\"$") - public void the{Resource}ShouldHaveStatus(String status) { - log.info("Step: the {resource} should have status '{}'", status); - {Resource}Entity entity = storage.getLast{Resource}(); - - assertThat(entity.getStatus().getValue()) - .as("{Resource} status should be {}", status) - .isEqualTo(status); - } - - @Then("^the {resource} should be created successfully$") - public void the{Resource}ShouldBeCreatedSuccessfully() { - log.info("Step: the {resource} should be created successfully"); - {Resource}Entity entity = storage.getLast{Resource}(); - - assertThat(entity.getId()) - .as("{Resource} should have an ID") - .isNotNull(); - } -} -``` +Match the annotation style of the system: +- **Pet Store**: Regex patterns with `^...$`, double-quoted strings in Gherkin +- **Restful Booker**: Cucumber Expressions with `{string}`, `{int}`, single-quoted strings in Gherkin **Step 3: Create service** Location: `src/main/java/com/levi9/functionaltests/rest/service/{system}/{Domain}Service.java` -```java -@Slf4j -@Component -@Scope("cucumber-glue") -public class {Domain}Service { - - private static final String {RESOURCE}_PATH = "/api/{resources}"; - - @Autowired - private {System}RestClient client; - - @Autowired - private Storage storage; - - public void create{Resource}(String name) { - log.info("Creating {resource} with name: {}", name); - - Create{Resource}DSO requestBody = Create{Resource}DSO.builder() - .name(name) - .status({Status}.PENDING.getValue()) - .build(); - - Response response = client.post(requestBody, null, {RESOURCE}_PATH); - - assertThat(response.statusCode()) - .as("Status code should be 201") - .isEqualTo(201); - - {Resource}DSO responseBody = response.as({Resource}DSO.class); - - {Resource}Entity entity = {Resource}Entity.builder() - .id(responseBody.getId()) - .name(responseBody.getName()) - .status({Status}.getEnum(responseBody.getStatus())) - .build(); - - storage.get{Resource}s().add(entity); - - log.info("{Resource} created successfully with ID: {}", entity.getId()); - } - - public void retrieve{Resource}(Integer id) { - log.info("Retrieving {resource} with ID: {}", id); - - Response response = client.get(null, {RESOURCE}_PATH + "/" + id); - - assertThat(response.statusCode()) - .as("Status code should be 200") - .isEqualTo(200); - - {Resource}DSO responseBody = response.as({Resource}DSO.class); - - // Update storage with retrieved data - {Resource}Entity entity = storage.getLast{Resource}(); - entity.setName(responseBody.getName()); - entity.setStatus({Status}.getEnum(responseBody.getStatus())); - - log.info("Retrieved {resource}: {}", responseBody); - } -} -``` +Follow the constructor injection pattern. Throw `FunctionalTestsException` on bad status codes, NOT AssertJ assertions. Use the appropriate REST client for the system. -**Step 4: Create DSOs** +**Step 4: Create DSOs (if Pet Store) or use generated models (if Restful Booker)** -Location: `src/main/java/com/levi9/functionaltests/rest/data/{system}/` +For Pet Store: create DSOs in `src/main/java/com/levi9/functionaltests/rest/data/petstore/` +For Restful Booker: check if OpenAPI-generated models exist in `restfulbooker.model.*`. If not, either add to the OpenAPI spec and regenerate, or create a hand-crafted DSO in `src/main/java/com/levi9/functionaltests/rest/data/restfulbooker/`. **Tip**: If the API has an OpenAPI spec, use the helper script to generate DSO templates: ```bash @@ -647,67 +785,11 @@ python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate com.levi9.functionaltests.rest.data.{system} ``` -```java -// Create{Resource}DSO.java -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder(toBuilder = true) -public class Create{Resource}DSO { - private String name; - private String status; -} - -// {Resource}DSO.java -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder(toBuilder = true) -public class {Resource}DSO { - private Integer id; - private String name; - private String status; -} -``` - **Step 5: Create entity and add to Storage** Location: `src/main/java/com/levi9/functionaltests/storage/domain/{system}/{Resource}Entity.java` -```java -@Getter -@Setter -@Builder(toBuilder = true) -@NoArgsConstructor -@AllArgsConstructor -public class {Resource}Entity { - private Integer id; - private String name; - private {Status} status; - - @Builder.Default - private boolean deleted = false; -} -``` - -Then update `Storage.java`: -```java -private final List<{Resource}Entity> {resource}s = new ArrayList<>(); - -// @Getter generates get{Resource}s() automatically — do NOT add explicit getter - -public {Resource}Entity getLast{Resource}() { - return {resource}s.stream().reduce((first, last) -> last) - .orElseThrow(() -> new FunctionalTestsException("Last {Resource} not found!")); -} -``` - -Callers add entities via: -```java -storage.get{Resource}s().add(entity); -``` +Then update `Storage.java` with the new entity list and `getLast{Resource}()` convenience method. **Step 6: Verify step definitions are properly linked (dry-run)** @@ -715,7 +797,7 @@ storage.get{Resource}s().add(entity); mvn test -Dtest=DryRunRunnerIT ``` -This validates that all Gherkin steps have matching step definitions with correct regex patterns. Fix any "Undefined step" errors. +This validates that all Gherkin steps have matching step definitions. Fix any "Undefined step" errors. **Step 7: Run the test** @@ -729,15 +811,15 @@ If the test fails, investigate the failure, fix the issue, and run again. **Step 1: Create feature file** -Location: `src/test/resources/features/{system}/{domain}.feature` +Location: `src/test/resources/features/{system}/{subdirectory}/{domain}.feature` ```gherkin @ui @{domain} Feature: {Domain} UI - Background: User is on the {Page} Page + Background: {Descriptive background title} Given user is on the {Page} Page - + @blocker @sanity Scenario: User can perform action via UI When user fills form with 'Test Data' @@ -751,105 +833,24 @@ Feature: {Domain} UI Location: `src/main/java/com/levi9/functionaltests/ui/pages/{system}/{Page}Page.java` -```java -@Slf4j -@Component -@Scope("cucumber-glue") -public class {Page}Page extends BasePage<{Page}Page> { - - private final By page = By.xpath("//*[@data-testid='{page}-header']"); - private final By formInput = By.id("input-id"); - private final By submitButton = By.cssSelector(".submit-btn"); - private final By successMessage = By.xpath("//div[@class='success']"); - - protected {Page}Page(final BaseDriver baseDriver) { - super(baseDriver); - } - - /** - * Checks if page is loaded. - * - * @return true if yes, otherwise false - */ - public boolean isLoaded() { - return isElementVisible(page, 5); - } - - /** - * Load Page. - */ - public void load() { - openPage(getRestfulBookerPlatformUrl() + "#/{path}", page); - } - - /** - * Fills the form with provided data - */ - public void fillForm(final String data) { - log.info("Filling form with: {}", data); - waitAndSendKeys(formInput, data); - } - - /** - * Clicks the submit button - */ - public void clickSubmit() { - log.info("Clicking submit button"); - waitAndClick(submitButton); - } - - /** - * Checks if success message is displayed - */ - public boolean isSuccessMessageDisplayed() { - return isElementVisible(successMessage, 10); - } -} -``` +Follow the `BasePage` pattern. Every page needs `isLoaded()` and `load()`. Use `@Nullable` on parameters for negative/validation tests. **Step 3: Create step definitions** Location: `src/test/java/com/levi9/functionaltests/stepdefs/{system}/{Domain}Stepdef.java` +**Important**: Always inject `BannerPage` and call `bannerPage.closeBanner()` after loading any Restful Booker page — the welcome banner blocks interactions. + ```java -@Slf4j -public class {Domain}Stepdef { - - @Autowired - private {Page}Page page; - - @Autowired - private BannerPage bannerPage; - - @Given("User is on the {Page} Page") - public void userIsOnPage() { - page.load(); - bannerPage.closeBanner(); - assertThat(page.isLoaded()).as("User is not on the {Page} Page!").isTrue(); - log.info("User is on {Page} Page"); - } - - @When("User fills form with {string}") - public void userFillsForm(final String data) { - log.info("Step: user fills form with '{}'", data); - page.fillForm(data); - } - - @When("User clicks submit") - public void userClicksSubmit() { - page.clickSubmit(); - } - - @Then("Success message should be displayed") - public void successMessageShouldBeDisplayed() { - assertThat(page.isSuccessMessageDisplayed()).as("Success message is not displayed!").isTrue(); - log.info("Success message is displayed"); - } +@Given("User is on the {Page} Page") +public void userIsOnPage() { + page.load(); + bannerPage.closeBanner(); + assertThat(page.isLoaded()).as("User is not on the {Page} Page!").isTrue(); + log.info("User is on {Page} Page"); } ``` -**Important**: Always inject `BannerPage` and call `bannerPage.closeBanner()` after loading any Restful Booker page — the welcome banner blocks interactions. - **Step 4: Verify step definitions (dry-run)** ```bash @@ -862,114 +863,6 @@ mvn test -Dtest=DryRunRunnerIT mvn clean verify -Dtags='@{domain} and @ui' ``` -### Testing Error Handling and Validation - -When creating tests for error scenarios and validation: - -**Negative API Tests**: -```gherkin -@api @validation @{domain} -Feature: {Domain} Validation - - @critical - Scenario Outline: API rejects invalid {resource} data - When user attempts to create a {resource} with "" - Then the API should return status code - And the error message should contain "" - - Examples: - | field | value | statusCode | errorText | - | empty name | | 400 | name is required | - | invalid status | INVALID_STATUS | 400 | invalid status | - | null ID | null | 400 | ID cannot be null| -``` - -**Service layer error handling**: -```java -public void createResourceWithInvalidData(String field, String value) { - log.info("Attempting to create resource with invalid {}: {}", field, value); - - // Build invalid request - RequestDSO request = buildInvalidRequest(field, value); - - try { - Response response = client.post(request, null, RESOURCE_PATH); - - // For negative tests, expect 400 - if (response.statusCode() == HttpStatus.SC_BAD_REQUEST) { - log.info("Received expected 400 error: {}", response.body().asString()); - storage.setLastErrorResponse(response); // Store for assertion - } else { - throw new FunctionalTestsException( - "Expected 400 Bad Request but got {}", response.statusCode()); - } - } catch (Exception e) { - log.error("Error during invalid request: {}", e.getMessage()); - throw new FunctionalTestsException("Failed to handle invalid request: {}", e.getMessage()); - } -} -``` - -**Step definition for error cases**: -```java -@When("^user attempts to create a {resource} with (.*) \"([^\"]*)\"$") -public void userAttemptsInvalidCreate(String field, String value) { - log.info("Step: user attempts to create {resource} with {} '{}'", field, value); - service.createResourceWithInvalidData(field, value); -} - -@Then("^the API should return status code (\\d+)$") -public void apiShouldReturnStatusCode(int expectedStatus) { - log.info("Step: verifying API returned status code {}", expectedStatus); - Response errorResponse = storage.getLastErrorResponse(); - - assertThat(errorResponse.statusCode()) - .as("API should return status code {}", expectedStatus) - .isEqualTo(expectedStatus); -} - -@Then("^the error message should contain \"([^\"]*)\"$") -public void errorMessageShouldContain(String expectedText) { - log.info("Step: verifying error message contains '{}'", expectedText); - Response errorResponse = storage.getLastErrorResponse(); - String errorBody = errorResponse.body().asString(); - - assertThat(errorBody.toLowerCase()) - .as("Error message should contain '{}'", expectedText) - .contains(expectedText.toLowerCase()); -} -``` - -**UI validation testing**: -```gherkin -@ui @validation @{domain} -Feature: {Page} Form Validation - - Background: - Given user is on {page} page - - @critical - Scenario: Required fields show validation errors - When user clicks submit without filling required fields - Then validation error "Name is required" should be displayed - And validation error "Email is required" should be displayed - And the form should not be submitted -``` - -**Page object validation methods**: -```java -public void clickSubmitWithoutFilling() { - log.info("Clicking submit without filling required fields"); - waitAndClick(submitButton); -} - -public boolean isValidationErrorDisplayed(String errorMessage) { - log.info("Checking if validation error '{}' is displayed", errorMessage); - By errorLocator = By.xpath(String.format("//span[contains(text(), '%s')]", errorMessage)); - return isElementDisplayed(errorLocator); -} -``` - ### Debugging Failing Tests When a test fails, follow this systematic approach: @@ -986,11 +879,12 @@ When a test fails, follow this systematic approach: ``` 5. **Common issues**: - - **Undefined steps**: Step definition regex doesn't match Gherkin + - **Undefined steps**: Step definition regex/expression doesn't match Gherkin - **NullPointerException**: Storage entity not created or autowiring failed - **Assertion failure**: Response/UI state doesn't match expectation - **Timeout**: Element not found (UI) or slow response (API) - **404/500 errors**: Wrong endpoint path or server issue + - **Banner blocking**: Forgot `bannerPage.closeBanner()` after loading page 6. **Fix and verify** — After fixing, run the specific test again: ```bash @@ -1018,11 +912,13 @@ When reviewing test code, verify: **Services** (API): - [ ] Annotated with `@Component` and `@Scope("cucumber-glue")` -- [ ] Uses `@Autowired` for dependencies -- [ ] Validates response status codes +- [ ] Uses **constructor injection** with `@Autowired` on constructor (not field injection) +- [ ] Dependencies stored in `private final` fields +- [ ] **Throws `FunctionalTestsException`** on bad status codes (not AssertJ assertions) +- [ ] Uses `HttpStatus.SC_*` constants from `org.apache.http.HttpStatus` - [ ] Updates Storage after operations - [ ] Logs important actions and results -- [ ] Constants for endpoint paths +- [ ] `public static final String REST_PATH` constant for endpoint paths **Page Objects** (UI): - [ ] Extends `BasePage` with self-type @@ -1032,7 +928,7 @@ When reviewing test code, verify: - [ ] Uses wait-based methods (`waitAndClick`, `waitAndSendKeys`, etc.) - [ ] Action methods return `void` (project convention — not fluent API) - [ ] Verification methods return `boolean` or specific types -- [ ] Uses `@Nullable` on parameters for negative/validation test methods +- [ ] Uses `@Nullable` (`javax.annotation.Nullable`) on parameters for negative/validation test methods - [ ] Assertions done in step definitions, not page objects **DSOs**: @@ -1042,8 +938,9 @@ When reviewing test code, verify: **Entities**: - [ ] Uses enums for status/type fields (not Strings) -- [ ] Includes `deleted` flag if applicable +- [ ] Includes `deleted` flag if applicable (Pet Store entities) - [ ] Uses `@Builder.Default` for defaults +- [ ] Consider constructor from generated model (Restful Booker entities) **General**: - [ ] Consistent naming conventions @@ -1083,11 +980,11 @@ mvn clean verify -DparallelCount=10 ### Environment Selection ```bash -# Use development environment +# Use development environment (default) mvn clean verify -Denv=development -# Use staging environment -mvn clean verify -Denv=staging +# Use local environment +mvn clean verify -Denv=local ``` ### Browser Configuration (UI tests) @@ -1155,13 +1052,7 @@ python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate Room \ com.levi9.functionaltests.rest.data.restfulbooker ``` -This generates a complete Java DSO class with: -- Lombok annotations (`@Getter`, `@Setter`, `@Builder`, `@NoArgsConstructor`, `@AllArgsConstructor`) -- Correct Java types based on OpenAPI schema (String, Integer, List, etc.) -- `@JsonProperty` annotations for non-standard field names -- JavaDoc comments from OpenAPI descriptions - -Use this when creating new DSOs — it ensures consistency with the OpenAPI spec and saves time. +This generates a complete Java DSO class with Lombok annotations and correct types. ### Adding New OpenAPI Specs @@ -1200,6 +1091,154 @@ mvn clean compile - Right-click `target/generated-sources/generated` - Select "Mark Directory as" → "Generated Sources Root" +## Jira & GitHub Ticket Integration + +The skill includes a script to fetch ticket details from **Jira** or **GitHub Issues** and extract feature requirements, acceptance criteria, and linked issues — so you can generate tests directly from tickets. + +### Setup + +Copy the `.env.example` file and fill in your credentials: + +```bash +cp .github/skills/functional-tests-skill/scripts/.env.example \ + .github/skills/functional-tests-skill/scripts/.env +``` + +Or set environment variables directly — see `.env.example` for the full list. + +**Jira:** +```bash +export JIRA_BASE_URL=https://yourcompany.atlassian.net +export JIRA_USER_EMAIL=your-email@company.com +export JIRA_API_TOKEN=your-api-token +# OR for on-prem: export JIRA_PAT=your-personal-access-token +``` + +**GitHub:** +```bash +export GITHUB_TOKEN=ghp_your-personal-access-token +``` + +### Usage + +The script auto-detects the source from the ticket reference format: +- **Jira**: `PROJ-123` +- **GitHub**: `owner/repo#123` + +**Fetch ticket details** (description, acceptance criteria, subtasks, linked issues): +```bash +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch PROJ-123 +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch owner/repo#42 +``` + +**Fetch with all child/subtask details expanded** (Jira only): +```bash +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch PROJ-123 --include-children +``` + +**Output as JSON** (useful for piping into other tools): +```bash +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch PROJ-123 --format json +``` + +**Generate a Gherkin feature skeleton** from the ticket's acceptance criteria: +```bash +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py generate-gherkin PROJ-123 +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py generate-gherkin owner/repo#42 +``` + +### Workflow: From Ticket to Tests + +When the user provides a ticket reference (e.g., "write tests for PROJ-123" or "write tests for owner/repo#42"): + +1. **Check if credentials are configured** — Run the fetch script. If it fails with an authentication or missing env var error, **do NOT ask the user to provide tokens or credentials to you**. Instead, give them clear setup instructions: + + **If Python is not installed** (script fails with "command not found" or similar): + + > The ticket fetcher script requires **Python 3.10+**. It doesn't appear to be installed. Here's how to install it: + > + > **macOS** (using Homebrew): + > ```bash + > brew install python3 + > ``` + > If you don't have Homebrew: https://brew.sh + > + > **Windows**: + > Download from https://www.python.org/downloads/ and run the installer. Make sure to check "Add Python to PATH". + > + > **Linux (Debian/Ubuntu)**: + > ```bash + > sudo apt update && sudo apt install python3 + > ``` + > + > After installing, verify with `python3 --version` and try again. + + **Do NOT install Python for the user** — only provide the instructions above and let them do it. + + **If Jira/GitHub credentials are missing:** + + > For **Jira** tickets, you need to set up credentials before I can fetch ticket data. Here's how: + > + > 1. Copy the example env file: + > ```bash + > cp .github/skills/functional-tests-skill/scripts/.env.example .github/skills/functional-tests-skill/scripts/.env + > ``` + > 2. Edit `.github/skills/functional-tests-skill/scripts/.env` and fill in your Jira credentials: + > ``` + > JIRA_BASE_URL=https://yourcompany.atlassian.net + > JIRA_USER_EMAIL=your-email@company.com + > JIRA_API_TOKEN=your-api-token + > ``` + > Generate an API token at: https://id.atlassian.com/manage-profile/security/api-tokens + > + > Once configured, ask me again and I'll fetch the ticket. + + > For **GitHub** issues (private repos), you need a GitHub token: + > + > 1. Copy the example env file (if not already done): + > ```bash + > cp .github/skills/functional-tests-skill/scripts/.env.example .github/skills/functional-tests-skill/scripts/.env + > ``` + > 2. Edit `.github/skills/functional-tests-skill/scripts/.env` and add: + > ``` + > GITHUB_TOKEN=ghp_your-personal-access-token + > ``` + > Generate a token at: https://github.com/settings/tokens (needs `repo` scope for private repos) + > + > For public repos, no token is needed — the script works without it. + + **Important**: Never ask the user to paste tokens or credentials into the chat. Always direct them to edit the `.env` file themselves. + +2. **Fetch the ticket** using the script to get the full context — summary, description, acceptance criteria, subtasks, and linked issues. +3. **Analyze the output** to understand what feature is being described, what the expected behavior is, and what edge cases exist. +4. **Optionally generate a Gherkin skeleton** as a starting point using `generate-gherkin`. +5. **Follow the standard test creation workflow** (described in the Workflows section) to implement the full test — feature file, step definitions, services, page objects, etc. +6. **Map acceptance criteria to scenarios** — each acceptance criterion should become at least one scenario (happy path), plus rainy-day variants where applicable. + +The script supports Jira Cloud, Jira Server/Data Center (via PAT), and GitHub Issues (public and private repos). For GitHub, it also fetches issue comments and parses task list checkboxes as subtasks. + +## Spring Configuration + +The Spring context is configured in `src/test/java/com/levi9/functionaltests/config/SpringConfig.java`: + +```java +@Configuration +@Scope("cucumber-glue") +@PropertySource("classpath:application-${env:development}.properties") +@ComponentScan({ "com.levi9.functionaltests" }) +public class SpringConfig { + @Bean + public static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() { + return new PropertySourcesPlaceholderConfigurer(); + } +} +``` + +The `env` system property defaults to `development`. Property files are in `src/test/resources/`: +- `application-development.properties` +- `application-local.properties` +- `application-acceptance.properties` + ## Best Practices Summary 1. **Always run tests after creation/modification** — Verify they work, fix if they fail. @@ -1228,7 +1267,9 @@ mvn clean compile 13. **Match step annotation style** — Use Cucumber Expressions (with single-quoted strings) for Restful Booker, regex (with double-quoted strings) for Pet Store. -14. **Minimal effort maintenance** — Write code that's easy to understand and modify. +14. **Constructor injection in services** — Use `@Autowired` on constructor with `private final` fields. Throw `FunctionalTestsException` on failures (not AssertJ assertions). + +15. **Minimal effort maintenance** — Write code that's easy to understand and modify. ## When to Use This Skill @@ -1250,8 +1291,10 @@ Use this skill whenever working with tests in this project: - Extending REST clients - Working with OpenAPI models - Improving test architecture +- Fetching Jira or GitHub ticket details to generate tests +- Creating tests from Jira/GitHub tickets or acceptance criteria -Even if the user just mentions "write a test", "add coverage", "this test is failing", or "review this test", trigger this skill immediately. +Even if the user just mentions "write a test", "add coverage", "this test is failing", "review this test", "write tests for PROJ-123", "write tests for owner/repo#42", or references any ticket key, trigger this skill immediately. ## Final Note diff --git a/.github/skills/functional-tests-skill/scripts/.env.example b/.github/skills/functional-tests-skill/scripts/.env.example new file mode 100644 index 0000000..f454416 --- /dev/null +++ b/.github/skills/functional-tests-skill/scripts/.env.example @@ -0,0 +1,17 @@ +# Jira Ticket Fetcher — Environment Variables +# Copy this file to .env and fill in your values. +# Place .env next to the script (scripts/.env) or in the project root. + +# --- Jira Cloud --- +JIRA_BASE_URL=https://yourcompany.atlassian.net +JIRA_USER_EMAIL=your-email@company.com +JIRA_API_TOKEN=your-api-token + +# --- Jira Server / Data Center (use PAT instead of email+token) --- +# JIRA_BASE_URL=https://jira.yourcompany.com +# JIRA_PAT=your-personal-access-token + +# --- GitHub Issues --- +# GITHUB_TOKEN=ghp_your-github-personal-access-token +# Generate at: https://github.com/settings/tokens (needs "repo" scope for private repos) + diff --git a/.github/skills/functional-tests-skill/scripts/README.md b/.github/skills/functional-tests-skill/scripts/README.md index af37650..fe46adf 100644 --- a/.github/skills/functional-tests-skill/scripts/README.md +++ b/.github/skills/functional-tests-skill/scripts/README.md @@ -1,33 +1,44 @@ -# OpenAPI Helper Script +# Functional Tests Skill — Scripts -This Python script helps developers work with OpenAPI specifications during test development. +Helper scripts for the `functional-tests-skill`. No external dependencies required — all scripts use Python standard library only (PyYAML optional for YAML OpenAPI specs). + +## Scripts -## Overview +| Script | Purpose | +|--------|---------| +| [`openapi_helper.py`](#openapi-helper) | List endpoints, view details, generate Java DSOs from OpenAPI specs | +| [`jira_ticket_fetcher.py`](#jira--github-ticket-fetcher) | Fetch ticket details from Jira or GitHub Issues for test generation | -The `openapi_helper.py` script provides three main capabilities: -1. **List Endpoints** - Display all API endpoints from an OpenAPI spec -2. **Endpoint Details** - Show detailed information about a specific endpoint -3. **Generate DSO** - Generate Java DSO class templates from OpenAPI schemas +## Requirements -## Features +- Python 3.10+ +- PyYAML (optional, only for YAML OpenAPI specs): `pip install pyyaml` -✅ **Multiple Sources**: Supports local files and remote URLs -✅ **Format Support**: Handles both JSON and YAML OpenAPI specifications -✅ **No Dependencies**: Uses Python standard library (PyYAML optional for YAML support) +--- -## Requirements +## OpenAPI Helper + +This Python script helps developers work with OpenAPI specifications during test development. + +### Capabilities + +1. **List Endpoints** — Display all API endpoints from an OpenAPI spec +2. **Endpoint Details** — Show detailed information about a specific endpoint +3. **Generate DSO** — Generate Java DSO class templates from OpenAPI schemas -- Python 3.x -- PyYAML (optional, only needed for YAML format support): `pip install pyyaml` +✅ Supports local files and remote URLs +✅ Handles both JSON and YAML OpenAPI specifications +✅ No external dependencies -## Usage +### Usage -### 1. List All Endpoints +#### List All Endpoints Display a table of all available API endpoints: ```bash -python3 openapi_helper.py list-endpoints +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py list-endpoints \ + src/main/resources/openapi/restfulbooker/room-open-api.json ``` **Examples:** @@ -65,12 +76,14 @@ POST / createRoom Total endpoints: 5 ``` -### 2. Get Endpoint Details +#### Get Endpoint Details Show detailed information about a specific endpoint: ```bash -python3 openapi_helper.py endpoint-details +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py endpoint-details \ + src/main/resources/openapi/restfulbooker/room-open-api.json \ + /room/{id} ``` **Examples:** @@ -89,19 +102,15 @@ python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py endpoint /pet/{petId} ``` -**Output includes:** -- HTTP methods supported -- Operation ID and summary -- Request parameters (path, query, header) -- Request body schema -- Response codes and descriptions - -### 3. Generate DSO Class Template +#### Generate DSO Class Template Generate a complete Java DSO class from an OpenAPI schema: ```bash -python3 openapi_helper.py generate-dso [package-name] +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate-dso \ + src/main/resources/openapi/restfulbooker/room-open-api.json \ + Room \ + com.levi9.functionaltests.rest.data.restfulbooker ``` **Examples:** @@ -166,7 +175,7 @@ The generated DSO includes: - ✅ JavaDoc comments from OpenAPI descriptions - ✅ Proper imports for List, LocalDate, LocalDateTime -## Type Mapping +### Type Mapping The script automatically maps OpenAPI types to Java types: @@ -183,95 +192,122 @@ The script automatically maps OpenAPI types to Java types: | array | - | List | | object | - | Object or referenced type | -## When to Use +### Notes -Use this script when: -- **Starting a new REST API test** - List endpoints to understand what's available -- **Creating DSO classes** - Generate templates that match the OpenAPI spec exactly -- **API documentation is unclear** - View endpoint details with parameters and schemas -- **API spec changes** - Regenerate DSOs to ensure consistency -- **Working with third-party APIs** - Fetch specs directly from remote URLs (e.g., Swagger UI endpoints) -- **Evaluating external APIs** - Quickly explore API structure without downloading files +- Reads OpenAPI 3.0 and Swagger 2.0 specifications +- Supports both JSON and YAML formats (PyYAML required for YAML) +- URLs are fetched with a 30-second timeout +- Default package: `com.levi9.functionaltests.rest.data` +- Generated DSOs follow project conventions (Lombok, builder pattern, etc.) -## Supported Formats +--- -| Source Type | Format | Example | -|-------------|--------|---------| -| Local file | JSON | `src/main/resources/openapi/room-open-api.json` | -| Local file | YAML | `specs/api-spec.yaml` | -| Remote URL | JSON | `https://petstore.swagger.io/v2/swagger.json` | -| Remote URL | YAML | `https://example.com/api/openapi.yaml` | -| Swagger UI | JSON | `https://api.example.com/swagger/v1/swagger.json` | +## Jira & GitHub Ticket Fetcher -## Integration with AI Skill +Fetches ticket details from **Jira** or **GitHub Issues** and extracts feature requirements, acceptance criteria, subtasks, and linked issues — enabling test generation directly from tickets. -This script is referenced in the `functional-tests-skill` SKILL.md: -- In the "Creating a New REST API Test" workflow (Step 4: Create DSOs) -- In the "OpenAPI Model Generation" section +### Setup -The AI assistant can invoke this script to help generate accurate test code that matches the OpenAPI specification. +1. Copy the example environment file and fill in your credentials: -## Examples +```bash +cp .github/skills/functional-tests-skill/scripts/.env.example \ + .github/skills/functional-tests-skill/scripts/.env +``` -### Complete Workflow Example +2. Fill in the relevant values in `.env`: -When creating a new REST API test for booking: +**Jira Cloud:** +``` +JIRA_BASE_URL=https://yourcompany.atlassian.net +JIRA_USER_EMAIL=your-email@company.com +JIRA_API_TOKEN=your-api-token +``` -1. **Discover available endpoints:** -```bash -python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py list-endpoints \ - src/main/resources/openapi/restfulbooker/booking-open-api.json +**Jira Server / Data Center (PAT):** +``` +JIRA_BASE_URL=https://jira.yourcompany.com +JIRA_PAT=your-personal-access-token ``` -2. **Get details about the POST endpoint:** -```bash -python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py endpoint-details \ - src/main/resources/openapi/restfulbooker/booking-open-api.json \ - /booking +**GitHub Issues:** +``` +GITHUB_TOKEN=ghp_your-personal-access-token ``` -3. **Generate DSO for the Booking schema:** +> The `.env` file is auto-loaded from next to the script or from the project root. Already-set environment variables take precedence. + +### Usage + +The script auto-detects the source based on the ticket reference format: +- **Jira:** `PROJ-123` +- **GitHub:** `owner/repo#123` + +#### Fetch Ticket Details + ```bash -python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate-dso \ - src/main/resources/openapi/restfulbooker/booking-open-api.json \ - Booking \ - com.levi9.functionaltests.rest.data.restfulbooker > src/main/java/com/levi9/functionaltests/rest/data/restfulbooker/BookingDSO.java -``` +# Jira +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch PROJ-123 -4. **Review and customize** the generated DSO if needed +# GitHub +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch owner/repo#42 +``` -### Working with Third-Party APIs +Output includes: summary, description, acceptance criteria, subtasks, linked issues, and (for GitHub) comments. -When testing external APIs like Petstore: +#### Fetch with Child/Subtask Details (Jira only) -1. **List endpoints from remote spec:** ```bash -python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py list-endpoints \ - https://petstore.swagger.io/v2/swagger.json +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch PROJ-123 --include-children ``` -2. **Explore specific endpoint:** +#### Output as JSON + ```bash -python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py endpoint-details \ - https://petstore.swagger.io/v2/swagger.json \ - /pet/{petId} +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch PROJ-123 --format json ``` -3. **Generate Pet DSO:** +#### Generate Gherkin Skeleton + +Generates a `.feature` file skeleton from the ticket's acceptance criteria: + ```bash -python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate-dso \ - https://petstore.swagger.io/v2/swagger.json \ - Pet \ - com.levi9.functionaltests.rest.data.petstore +# Jira +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py generate-gherkin PROJ-123 + +# GitHub +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py generate-gherkin owner/repo#42 ``` -## Notes +### GitHub-Specific Features -- The script reads OpenAPI 3.0 and Swagger 2.0 specifications -- Supports both JSON and YAML formats -- For YAML files, PyYAML must be installed: `pip install pyyaml` -- URLs are fetched with a 30-second timeout -- If `package-name` is not provided, it defaults to `com.levi9.functionaltests.rest.data` -- Generated DSOs follow project conventions (Lombok usage, builder pattern, etc.) -- The script is read-only — it never modifies the OpenAPI spec files or remote sources -- User-Agent header is set to `OpenAPI-Helper/1.0` for URL requests +- Fetches issue comments (up to 10) for additional context +- Parses task list checkboxes (`- [ ]` / `- [x]`) as subtasks +- Extracts `## Acceptance Criteria` sections from the issue body +- Works with public repos without a token; private repos require `GITHUB_TOKEN` with `repo` scope + +### Workflow: From Ticket to Tests + +1. **Fetch the ticket** to get full context +2. **Analyze** the description and acceptance criteria +3. **Generate a Gherkin skeleton** (optional starting point) +4. **Implement the full test** — feature file, step definitions, services, page objects, etc. +5. **Map each acceptance criterion** to at least one scenario + +### Notes + +- Uses Jira REST API v3 (Cloud) — works with Jira Server/Data Center via PAT +- `.env` file is searched next to the script, then in the project root +- No external Python dependencies required + +--- + +## File Structure + +``` +scripts/ +├── README.md ← This file +├── .env.example ← Template for environment variables +├── openapi_helper.py ← OpenAPI spec helper +└── jira_ticket_fetcher.py ← Jira & GitHub ticket fetcher +``` diff --git a/.github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py b/.github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py new file mode 100644 index 0000000..9bd1681 --- /dev/null +++ b/.github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 +""" +Jira Ticket Fetcher — Extracts ticket details to help generate test scenarios. + +Connects to a Jira instance (or compatible board like Azure DevOps, Linear, etc.) +via REST API, fetches ticket details, and outputs structured information about +what the feature is, what needs to be done, and how it should work. + +Usage: + # Fetch a single ticket + python3 jira_ticket_fetcher.py fetch PROJ-123 + + # Fetch a ticket with all linked/child issues + python3 jira_ticket_fetcher.py fetch PROJ-123 --include-children + + # Fetch and output as markdown (default) + python3 jira_ticket_fetcher.py fetch PROJ-123 --format markdown + + # Fetch and output as JSON + python3 jira_ticket_fetcher.py fetch PROJ-123 --format json + + # Fetch and generate a Gherkin skeleton from acceptance criteria + python3 jira_ticket_fetcher.py generate-gherkin PROJ-123 + + # GitHub Issues — fetch an issue + python3 jira_ticket_fetcher.py fetch owner/repo#42 + python3 jira_ticket_fetcher.py fetch owner/repo#42 --format json + + # GitHub Issues — generate Gherkin skeleton + python3 jira_ticket_fetcher.py generate-gherkin owner/repo#42 + +Environment Variables (set before running, or use a .env file — see .env.example): + + Jira: + JIRA_BASE_URL — Base URL (e.g., https://yourcompany.atlassian.net) + JIRA_USER_EMAIL — Email for Jira Cloud authentication + JIRA_API_TOKEN — API token (https://id.atlassian.com/manage-profile/security/api-tokens) + JIRA_PAT — Personal Access Token (alternative to email+token, for on-prem) + + GitHub: + GITHUB_TOKEN — Personal access token (https://github.com/settings/tokens) +""" + +import argparse +import json +import os +import re +import sys +import textwrap +from pathlib import Path +from urllib.request import Request, urlopen +from urllib.error import HTTPError, URLError +from base64 import b64encode + + +# --------------------------------------------------------------------------- +# .env file loader +# --------------------------------------------------------------------------- + +def _load_env_file(): + """Load variables from a .env file if present. + + Searches for .env in this order: + 1. Next to this script (.github/skills/functional-tests-skill/scripts/.env) + 2. In the project root (functional-tests/.env) + + Lines like KEY=VALUE or KEY="VALUE" are parsed. Comments (#) and blank + lines are ignored. Already-set env vars are NOT overwritten. + """ + script_dir = Path(__file__).resolve().parent + project_root = script_dir.parents[3] # .github/skills/functional-tests-skill/scripts -> project root + + candidates = [script_dir / ".env", project_root / ".env"] + + for env_path in candidates: + if env_path.is_file(): + with open(env_path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("\"'") + # Don't overwrite existing env vars + if key and key not in os.environ: + os.environ[key] = value + break # use the first .env found + + +_load_env_file() + + +# --------------------------------------------------------------------------- +# Authentication helpers +# --------------------------------------------------------------------------- + +def _get_auth_header(): + """Build the Authorization header based on available env vars.""" + pat = os.environ.get("JIRA_PAT") + if pat: + return f"Bearer {pat}" + + email = os.environ.get("JIRA_USER_EMAIL") + token = os.environ.get("JIRA_API_TOKEN") + if email and token: + creds = b64encode(f"{email}:{token}".encode()).decode() + return f"Basic {creds}" + + return None + + +def _get_base_url(): + url = os.environ.get("JIRA_BASE_URL", "").rstrip("/") + if not url: + print("ERROR: JIRA_BASE_URL environment variable is not set.", file=sys.stderr) + print(" Set it to your Jira instance URL, e.g.:", file=sys.stderr) + print(" export JIRA_BASE_URL=https://yourcompany.atlassian.net", file=sys.stderr) + sys.exit(1) + return url + + +# --------------------------------------------------------------------------- +# API interaction +# --------------------------------------------------------------------------- + +def _api_get(path: str) -> dict: + """Perform a GET request to the Jira REST API.""" + base_url = _get_base_url() + url = f"{base_url}/rest/api/3/{path.lstrip('/')}" + + headers = {"Accept": "application/json", "Content-Type": "application/json"} + auth = _get_auth_header() + if auth: + headers["Authorization"] = auth + + req = Request(url, headers=headers, method="GET") + try: + with urlopen(req) as resp: + return json.loads(resp.read().decode()) + except HTTPError as e: + body = e.read().decode() if e.fp else "" + print(f"ERROR: HTTP {e.code} from {url}", file=sys.stderr) + if body: + print(body[:500], file=sys.stderr) + sys.exit(1) + except URLError as e: + print(f"ERROR: Cannot reach {url} — {e.reason}", file=sys.stderr) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Ticket parsing +# --------------------------------------------------------------------------- + +def _extract_ticket(data: dict) -> dict: + """Extract the useful fields from a Jira issue JSON response.""" + fields = data.get("fields", {}) + + # Acceptance criteria may live in a custom field or in description + acceptance_criteria = ( + fields.get("customfield_10400") # common custom field id + or fields.get("customfield_10300") + or fields.get("customfield_10206") + or "" + ) + + # Extract subtasks / child issues + subtasks = [] + for sub in fields.get("subtasks", []): + subtasks.append({ + "key": sub.get("key"), + "summary": sub.get("fields", {}).get("summary"), + "status": sub.get("fields", {}).get("status", {}).get("name"), + }) + + # Extract linked issues + links = [] + for link in fields.get("issuelinks", []): + linked = link.get("outwardIssue") or link.get("inwardIssue") + if linked: + links.append({ + "key": linked.get("key"), + "summary": linked.get("fields", {}).get("summary"), + "relationship": link.get("type", {}).get("outward") or link.get("type", {}).get("name"), + }) + + return { + "key": data.get("key"), + "summary": fields.get("summary"), + "type": fields.get("issuetype", {}).get("name"), + "status": fields.get("status", {}).get("name"), + "priority": fields.get("priority", {}).get("name"), + "assignee": (fields.get("assignee") or {}).get("displayName"), + "reporter": (fields.get("reporter") or {}).get("displayName"), + "description": fields.get("description") or "", + "acceptance_criteria": acceptance_criteria, + "labels": fields.get("labels", []), + "components": [c.get("name") for c in fields.get("components", [])], + "fix_versions": [v.get("name") for v in fields.get("fixVersions", [])], + "subtasks": subtasks, + "linked_issues": links, + } + + +def _strip_jira_markup(text: str) -> str: + """Rough conversion of Jira wiki markup / ADF to plain text.""" + if not text: + return "" + # Handle ADF (Atlassian Document Format) JSON + if isinstance(text, dict): + return _adf_to_text(text) + # Simple wiki-markup stripping + text = re.sub(r"\{noformat\}.*?\{noformat\}", "", text, flags=re.DOTALL) + text = re.sub(r"\{code[^}]*\}.*?\{code\}", "", text, flags=re.DOTALL) + text = re.sub(r"\[([^|]*)\|[^\]]*\]", r"\1", text) # [text|url] -> text + text = re.sub(r"[{*_~^+]", "", text) # remove markup chars + text = re.sub(r"h[1-6]\.\s*", "", text) # headings + text = re.sub(r"^[#\-\*]+\s*", "", text, flags=re.MULTILINE) # list markers + return text.strip() + + +def _adf_to_text(node: dict) -> str: + """Recursively extract text from Atlassian Document Format.""" + if node.get("type") == "text": + return node.get("text", "") + parts = [] + for child in node.get("content", []): + parts.append(_adf_to_text(child)) + return "\n".join(parts) + + +# --------------------------------------------------------------------------- +# GitHub Issues support +# --------------------------------------------------------------------------- + +def _is_github_ref(ticket: str) -> bool: + """Check if the ticket reference is a GitHub issue (owner/repo#123).""" + return bool(re.match(r"^[\w.-]+/[\w.-]+#\d+$", ticket)) + + +def _parse_github_ref(ticket: str) -> tuple[str, str, int]: + """Parse 'owner/repo#123' into (owner, repo, issue_number).""" + match = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", ticket) + if not match: + print(f"ERROR: Invalid GitHub issue reference: {ticket}", file=sys.stderr) + print(" Expected format: owner/repo#123", file=sys.stderr) + sys.exit(1) + return match.group(1), match.group(2), int(match.group(3)) + + +def _github_api_get(path: str) -> dict: + """Perform a GET request to the GitHub REST API.""" + url = f"https://api.github.com/{path.lstrip('/')}" + headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + + req = Request(url, headers=headers, method="GET") + try: + with urlopen(req) as resp: + return json.loads(resp.read().decode()) + except HTTPError as e: + body = e.read().decode() if e.fp else "" + print(f"ERROR: HTTP {e.code} from {url}", file=sys.stderr) + if e.code == 401: + print(" Set GITHUB_TOKEN env var for authentication.", file=sys.stderr) + elif e.code == 404: + print(" Issue not found or repo is private (set GITHUB_TOKEN with 'repo' scope).", file=sys.stderr) + if body: + try: + msg = json.loads(body).get("message", body[:300]) + except Exception: + msg = body[:300] + print(f" {msg}", file=sys.stderr) + sys.exit(1) + except URLError as e: + print(f"ERROR: Cannot reach {url} — {e.reason}", file=sys.stderr) + sys.exit(1) + + +def _extract_github_issue(owner: str, repo: str, issue_number: int) -> dict: + """Fetch and extract a GitHub issue into the standard ticket dict.""" + data = _github_api_get(f"repos/{owner}/{repo}/issues/{issue_number}") + + # Fetch comments for additional context + comments = [] + if data.get("comments", 0) > 0: + comments_data = _github_api_get(f"repos/{owner}/{repo}/issues/{issue_number}/comments") + for c in comments_data[:10]: # limit to 10 + comments.append({ + "author": (c.get("user") or {}).get("login"), + "body": c.get("body", ""), + }) + + # Try to extract acceptance criteria from issue body + body = data.get("body") or "" + acceptance_criteria = _extract_ac_from_markdown(body) + + # Linked issues / sub-tasks from task list checkboxes + subtasks = _extract_task_list(body) + + return { + "key": f"{owner}/{repo}#{issue_number}", + "summary": data.get("title", ""), + "type": "Pull Request" if data.get("pull_request") else "Issue", + "status": data.get("state", "open").capitalize(), + "priority": None, + "assignee": (data.get("assignee") or {}).get("login"), + "reporter": (data.get("user") or {}).get("login"), + "description": body, + "acceptance_criteria": acceptance_criteria, + "labels": [l.get("name") for l in data.get("labels", [])], + "components": [], + "fix_versions": [m.get("title") for m in [data.get("milestone")] if m], + "subtasks": subtasks, + "linked_issues": [], + "_comments": comments, + } + + +def _extract_ac_from_markdown(body: str) -> str: + """Try to extract an 'Acceptance Criteria' section from a markdown body.""" + # Look for a heading like ## Acceptance Criteria, ### AC, etc. + match = re.search( + r"(?:^|\n)#{1,4}\s*(?:acceptance\s*criteria|AC)\s*\n(.*?)(?=\n#{1,4}\s|\Z)", + body, re.IGNORECASE | re.DOTALL, + ) + return match.group(1).strip() if match else "" + + +def _extract_task_list(body: str) -> list[dict]: + """Extract GitHub task list items (- [ ] / - [x]) as subtasks.""" + items = re.findall(r"- \[([ xX])\]\s+(.*)", body) + subtasks = [] + for checked, text in items: + subtasks.append({ + "key": None, + "summary": text.strip(), + "status": "Done" if checked.lower() == "x" else "To Do", + }) + return subtasks + + +# --------------------------------------------------------------------------- +# Formatters +# --------------------------------------------------------------------------- + +def _format_markdown(ticket: dict, children: list[dict] | None = None) -> str: + """Format ticket info as readable markdown.""" + lines = [] + lines.append(f"# {ticket['key']}: {ticket['summary']}") + lines.append("") + lines.append(f"**Type:** {ticket['type']} ") + lines.append(f"**Status:** {ticket['status']} ") + lines.append(f"**Priority:** {ticket['priority']} ") + if ticket["assignee"]: + lines.append(f"**Assignee:** {ticket['assignee']} ") + if ticket["labels"]: + lines.append(f"**Labels:** {', '.join(ticket['labels'])} ") + if ticket["components"]: + lines.append(f"**Components:** {', '.join(ticket['components'])} ") + lines.append("") + + desc = _strip_jira_markup(ticket["description"]) + if desc: + lines.append("## Description") + lines.append("") + lines.append(desc) + lines.append("") + + ac = _strip_jira_markup(ticket["acceptance_criteria"]) + if ac: + lines.append("## Acceptance Criteria") + lines.append("") + lines.append(ac) + lines.append("") + + if ticket["subtasks"]: + lines.append("## Subtasks") + lines.append("") + for st in ticket["subtasks"]: + lines.append(f"- **{st['key']}** — {st['summary']} _{st['status']}_") + lines.append("") + + if ticket["linked_issues"]: + lines.append("## Linked Issues") + lines.append("") + for li in ticket["linked_issues"]: + lines.append(f"- {li['relationship']}: **{li['key']}** — {li['summary']}") + lines.append("") + + if children: + lines.append("## Child Ticket Details") + lines.append("") + for child in children: + lines.append(f"### {child['key']}: {child['summary']}") + child_desc = _strip_jira_markup(child["description"]) + if child_desc: + lines.append(child_desc) + child_ac = _strip_jira_markup(child["acceptance_criteria"]) + if child_ac: + lines.append("") + lines.append("**Acceptance Criteria:**") + lines.append(child_ac) + lines.append("") + + # GitHub comments + comments = ticket.get("_comments", []) + if comments: + lines.append("## Comments") + lines.append("") + for c in comments: + lines.append(f"**{c['author']}:**") + lines.append(c["body"].strip()) + lines.append("") + + return "\n".join(lines) + + +def _format_json(ticket: dict, children: list[dict] | None = None) -> str: + """Format as JSON.""" + output = dict(ticket) + if children: + output["children_details"] = children + return json.dumps(output, indent=2, ensure_ascii=False) + + +# --------------------------------------------------------------------------- +# Gherkin generator +# --------------------------------------------------------------------------- + +def _generate_gherkin_skeleton(ticket: dict) -> str: + """Generate a rough Gherkin feature skeleton from ticket details.""" + lines = [] + summary = ticket["summary"] + desc = _strip_jira_markup(ticket["description"]) + ac = _strip_jira_markup(ticket["acceptance_criteria"]) + + # Determine tags + tags = ["@api"] # default; the user can adjust + ticket_type = (ticket.get("type") or "").lower() + if "ui" in ticket_type or "frontend" in ticket_type: + tags = ["@ui"] + + lines.append(f"# Auto-generated from {ticket['key']}") + lines.append(f"# Review and refine before use") + lines.append("") + lines.append(" ".join(f"@{t.lstrip('@')}" for t in tags)) + lines.append(f"Feature: {summary}") + lines.append("") + if desc: + for line in textwrap.wrap(desc, width=100): + lines.append(f" {line}") + lines.append("") + + # Try to parse acceptance criteria into scenarios + scenarios = _parse_ac_into_scenarios(ac or desc) + if scenarios: + for i, scenario in enumerate(scenarios, 1): + lines.append(f" @normal") + lines.append(f" Scenario: {scenario['title']}") + lines.append(f" # TODO: Implement steps") + lines.append(f" Given ") + lines.append(f" When ") + lines.append(f" Then ") + lines.append("") + else: + lines.append(" @normal") + lines.append(f" Scenario: {summary}") + lines.append(f" # TODO: Derive steps from description and acceptance criteria") + lines.append(f" Given ") + lines.append(f" When ") + lines.append(f" Then ") + lines.append("") + + return "\n".join(lines) + + +def _parse_ac_into_scenarios(text: str) -> list[dict]: + """Try to extract individual acceptance criteria as scenario titles.""" + if not text: + return [] + scenarios = [] + # Match numbered or bulleted items + items = re.findall(r"(?:^|\n)\s*(?:\d+[.)]\s*|[-*•]\s*)(.*?)(?=\n\s*(?:\d+[.)]\s*|[-*•]\s*)|\Z)", text, re.DOTALL) + for item in items: + title = item.strip().split("\n")[0].strip() + if len(title) > 10: # skip tiny fragments + # Clean up and truncate + title = title[:120].rstrip(".") + scenarios.append({"title": title}) + return scenarios + + +# --------------------------------------------------------------------------- +# Ticket resolver — routes to Jira or GitHub +# --------------------------------------------------------------------------- + +def _fetch_ticket(ticket_ref: str) -> dict: + """Fetch a ticket from Jira or GitHub based on the reference format.""" + if _is_github_ref(ticket_ref): + owner, repo, number = _parse_github_ref(ticket_ref) + return _extract_github_issue(owner, repo, number) + else: + data = _api_get(f"issue/{ticket_ref}?expand=renderedFields") + return _extract_ticket(data) + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + +def cmd_fetch(args): + """Fetch and display a ticket.""" + ticket = _fetch_ticket(args.ticket) + + children = [] + if args.include_children and not _is_github_ref(args.ticket): + for sub in ticket.get("subtasks", []): + child_data = _api_get(f"issue/{sub['key']}") + children.append(_extract_ticket(child_data)) + + if args.format == "json": + print(_format_json(ticket, children)) + else: + print(_format_markdown(ticket, children)) + + +def cmd_generate_gherkin(args): + """Fetch a ticket and generate a Gherkin skeleton.""" + ticket = _fetch_ticket(args.ticket) + print(_generate_gherkin_skeleton(ticket)) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser( + description="Fetch Jira ticket details for test generation.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=textwrap.dedent("""\ + Jira env vars: + JIRA_BASE_URL https://yourcompany.atlassian.net + JIRA_USER_EMAIL your-email@company.com + JIRA_API_TOKEN your-api-token + OR: + JIRA_PAT personal-access-token + + GitHub env vars: + GITHUB_TOKEN ghp_your-personal-access-token + + See .env.example for a template. + """), + ) + sub = parser.add_subparsers(dest="command", required=True) + + # fetch + p_fetch = sub.add_parser("fetch", help="Fetch ticket details") + p_fetch.add_argument("ticket", help="Ticket key: PROJ-123 (Jira) or owner/repo#123 (GitHub)") + p_fetch.add_argument("--format", choices=["markdown", "json"], default="markdown") + p_fetch.add_argument("--include-children", action="store_true", help="Also fetch subtask/child details") + p_fetch.set_defaults(func=cmd_fetch) + + # generate-gherkin + p_gherkin = sub.add_parser("generate-gherkin", help="Generate Gherkin skeleton from ticket") + p_gherkin.add_argument("ticket", help="Ticket key: PROJ-123 (Jira) or owner/repo#123 (GitHub)") + p_gherkin.set_defaults(func=cmd_generate_gherkin) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() + + From 3bcc90c6eb17d196f55fcae7f0e9344c5d539582 Mon Sep 17 00:00:00 2001 From: StraKoka Date: Wed, 22 Apr 2026 15:21:45 +0200 Subject: [PATCH 4/4] [NOJIRA]-Push updates --- .../skills/functional-tests-skill/SKILL.md | 2 +- .../scripts/.env.example | 1 - .../functional-tests-skill/scripts/README.md | 2 +- .../scripts/jira_ticket_fetcher.py | 640 ++++++++++-------- .gitignore | 1 + 5 files changed, 369 insertions(+), 277 deletions(-) diff --git a/.github/skills/functional-tests-skill/SKILL.md b/.github/skills/functional-tests-skill/SKILL.md index 86f90b8..3eab82d 100644 --- a/.github/skills/functional-tests-skill/SKILL.md +++ b/.github/skills/functional-tests-skill/SKILL.md @@ -569,7 +569,7 @@ public class RoomManagementStepdef { } } ``` - + Matching Gherkin (single quotes): ```gherkin Given User has created Single type Accessible room '1408' priced at 50 GBP with 'WiFi, TV and Safe' diff --git a/.github/skills/functional-tests-skill/scripts/.env.example b/.github/skills/functional-tests-skill/scripts/.env.example index f454416..7bb14c7 100644 --- a/.github/skills/functional-tests-skill/scripts/.env.example +++ b/.github/skills/functional-tests-skill/scripts/.env.example @@ -14,4 +14,3 @@ JIRA_API_TOKEN=your-api-token # --- GitHub Issues --- # GITHUB_TOKEN=ghp_your-github-personal-access-token # Generate at: https://github.com/settings/tokens (needs "repo" scope for private repos) - diff --git a/.github/skills/functional-tests-skill/scripts/README.md b/.github/skills/functional-tests-skill/scripts/README.md index fe46adf..678a65e 100644 --- a/.github/skills/functional-tests-skill/scripts/README.md +++ b/.github/skills/functional-tests-skill/scripts/README.md @@ -1,7 +1,7 @@ # Functional Tests Skill — Scripts Helper scripts for the `functional-tests-skill`. No external dependencies required — all scripts use Python standard library only (PyYAML optional for YAML OpenAPI specs). - + ## Scripts | Script | Purpose | diff --git a/.github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py b/.github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py index 9bd1681..dd5abb4 100644 --- a/.github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py +++ b/.github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py @@ -1,16 +1,16 @@ #!/usr/bin/env python3 """ -Jira Ticket Fetcher — Extracts ticket details to help generate test scenarios. +Jira & GitHub Ticket Fetcher — Extracts ticket details to help generate test scenarios. -Connects to a Jira instance (or compatible board like Azure DevOps, Linear, etc.) -via REST API, fetches ticket details, and outputs structured information about -what the feature is, what needs to be done, and how it should work. +Connects to a Jira instance or GitHub via REST API, fetches ticket details, and +outputs structured information about the feature, what needs to be done, and how +it should work. Usage: - # Fetch a single ticket + # Fetch a single Jira ticket python3 jira_ticket_fetcher.py fetch PROJ-123 - # Fetch a ticket with all linked/child issues + # Fetch a Jira ticket with all linked/child issues python3 jira_ticket_fetcher.py fetch PROJ-123 --include-children # Fetch and output as markdown (default) @@ -41,23 +41,52 @@ GITHUB_TOKEN — Personal access token (https://github.com/settings/tokens) """ +from __future__ import annotations + import argparse import json import os import re import sys import textwrap +from base64 import b64encode from pathlib import Path -from urllib.request import Request, urlopen from urllib.error import HTTPError, URLError -from base64 import b64encode +from urllib.request import Request, urlopen + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +_GITHUB_API_BASE = "https://api.github.com" +_GITHUB_API_VERSION = "2022-11-28" +_JIRA_API_VERSION = "3" +_MAX_GITHUB_COMMENTS = 10 +_MAX_AC_TITLE_LENGTH = 120 +_MIN_AC_TITLE_LENGTH = 10 +_MAX_ERROR_BODY_LENGTH = 500 +_GHERKIN_WRAP_WIDTH = 100 + +# Acceptance criteria custom field IDs (common Jira configurations) +_AC_CUSTOM_FIELDS = ("customfield_10400", "customfield_10300", "customfield_10206") + +# Pre-compiled regex patterns +_GITHUB_REF_PATTERN = re.compile(r"^([\w.-]+)/([\w.-]+)#(\d+)$") +_AC_HEADING_PATTERN = re.compile( + r"(?:^|\n)#{1,4}\s*(?:acceptance\s*criteria|AC)\s*\n(.*?)(?=\n#{1,4}\s|\Z)", + re.IGNORECASE | re.DOTALL, +) +_TASK_LIST_PATTERN = re.compile(r"- \[([ xX])\]\s+(.*)") +_AC_ITEMS_PATTERN = re.compile( + r"(?:^|\n)\s*(?:\d+[.)]\s*|[-*•]\s*)(.*?)(?=\n\s*(?:\d+[.)]\s*|[-*•]\s*)|\Z)", + re.DOTALL, +) # --------------------------------------------------------------------------- # .env file loader # --------------------------------------------------------------------------- -def _load_env_file(): +def _load_env_file() -> None: """Load variables from a .env file if present. Searches for .env in this order: @@ -68,37 +97,81 @@ def _load_env_file(): lines are ignored. Already-set env vars are NOT overwritten. """ script_dir = Path(__file__).resolve().parent - project_root = script_dir.parents[3] # .github/skills/functional-tests-skill/scripts -> project root - - candidates = [script_dir / ".env", project_root / ".env"] + project_root = script_dir.parents[3] - for env_path in candidates: + for env_path in (script_dir / ".env", project_root / ".env"): if env_path.is_file(): - with open(env_path) as f: - for line in f: - line = line.strip() - if not line or line.startswith("#"): - continue - if "=" not in line: - continue - key, _, value = line.partition("=") - key = key.strip() - value = value.strip().strip("\"'") - # Don't overwrite existing env vars - if key and key not in os.environ: - os.environ[key] = value - break # use the first .env found + _parse_env_file(env_path) + break -_load_env_file() +def _parse_env_file(env_path: Path) -> None: + """Parse a single .env file and set missing environment variables.""" + with open(env_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("\"'") + if key and key not in os.environ: + os.environ[key] = value + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + +def _exit_with_error(message: str) -> None: + """Print an error message to stderr and exit.""" + print(f"ERROR: {message}", file=sys.stderr) + sys.exit(1) + + +def _handle_http_error(error: HTTPError, url: str) -> None: + """Handle an HTTP error by printing details and exiting.""" + body = error.read().decode() if error.fp else "" + messages = [f"HTTP {error.code} from {url}"] + + if error.code == 401: + messages.append(" Authentication failed. Check your credentials.") + elif error.code == 404: + messages.append(" Resource not found. Check the ticket key or repository.") + + if body: + try: + parsed_msg = json.loads(body).get("message", body[:_MAX_ERROR_BODY_LENGTH]) + except (json.JSONDecodeError, AttributeError): + parsed_msg = body[:_MAX_ERROR_BODY_LENGTH] + messages.append(f" {parsed_msg}") + + _exit_with_error("\n".join(messages)) + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + +def _http_get(url: str, headers: dict[str, str]) -> dict: + """Perform an HTTP GET request and return parsed JSON.""" + req = Request(url, headers=headers, method="GET") + try: + with urlopen(req) as resp: + return json.loads(resp.read().decode()) + except HTTPError as e: + _handle_http_error(e, url) + except URLError as e: + _exit_with_error(f"Cannot reach {url} — {e.reason}") + return {} # unreachable, satisfies type checker # --------------------------------------------------------------------------- # Authentication helpers # --------------------------------------------------------------------------- -def _get_auth_header(): - """Build the Authorization header based on available env vars.""" +def _get_jira_auth_header() -> str | None: + """Build the Authorization header for Jira based on available env vars.""" pat = os.environ.get("JIRA_PAT") if pat: return f"Bearer {pat}" @@ -112,113 +185,143 @@ def _get_auth_header(): return None -def _get_base_url(): +def _get_jira_base_url() -> str: + """Return the Jira base URL from environment, or exit with an error.""" url = os.environ.get("JIRA_BASE_URL", "").rstrip("/") if not url: - print("ERROR: JIRA_BASE_URL environment variable is not set.", file=sys.stderr) - print(" Set it to your Jira instance URL, e.g.:", file=sys.stderr) - print(" export JIRA_BASE_URL=https://yourcompany.atlassian.net", file=sys.stderr) - sys.exit(1) + _exit_with_error( + "JIRA_BASE_URL environment variable is not set.\n" + " Set it to your Jira instance URL, e.g.:\n" + " export JIRA_BASE_URL=https://yourcompany.atlassian.net" + ) return url # --------------------------------------------------------------------------- -# API interaction +# Jira API interaction # --------------------------------------------------------------------------- -def _api_get(path: str) -> dict: +def _jira_api_get(path: str) -> dict: """Perform a GET request to the Jira REST API.""" - base_url = _get_base_url() - url = f"{base_url}/rest/api/3/{path.lstrip('/')}" - + base_url = _get_jira_base_url() + url = f"{base_url}/rest/api/{_JIRA_API_VERSION}/{path.lstrip('/')}" headers = {"Accept": "application/json", "Content-Type": "application/json"} - auth = _get_auth_header() + auth = _get_jira_auth_header() if auth: headers["Authorization"] = auth + return _http_get(url, headers) - req = Request(url, headers=headers, method="GET") - try: - with urlopen(req) as resp: - return json.loads(resp.read().decode()) - except HTTPError as e: - body = e.read().decode() if e.fp else "" - print(f"ERROR: HTTP {e.code} from {url}", file=sys.stderr) - if body: - print(body[:500], file=sys.stderr) - sys.exit(1) - except URLError as e: - print(f"ERROR: Cannot reach {url} — {e.reason}", file=sys.stderr) - sys.exit(1) + +# --------------------------------------------------------------------------- +# GitHub API interaction +# --------------------------------------------------------------------------- + +def _github_api_get(path: str) -> dict: + """Perform a GET request to the GitHub REST API.""" + url = f"{_GITHUB_API_BASE}/{path.lstrip('/')}" + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": _GITHUB_API_VERSION, + } + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + return _http_get(url, headers) + + +# --------------------------------------------------------------------------- +# Utility +# --------------------------------------------------------------------------- + +def _nested_get(data: dict | None, *keys: str) -> str | None: + """Safely traverse nested dicts, returning None if any key is missing.""" + current: dict | None = data + for key in keys: + if not isinstance(current, dict): + return None + current = current.get(key) + return current # --------------------------------------------------------------------------- -# Ticket parsing +# Jira ticket parsing # --------------------------------------------------------------------------- -def _extract_ticket(data: dict) -> dict: +def _extract_jira_ticket(data: dict) -> dict: """Extract the useful fields from a Jira issue JSON response.""" fields = data.get("fields", {}) + return { + "key": data.get("key"), + "summary": fields.get("summary"), + "type": _nested_get(fields, "issuetype", "name"), + "status": _nested_get(fields, "status", "name"), + "priority": _nested_get(fields, "priority", "name"), + "assignee": _nested_get(fields, "assignee", "displayName"), + "reporter": _nested_get(fields, "reporter", "displayName"), + "description": fields.get("description") or "", + "acceptance_criteria": _find_acceptance_criteria(fields), + "labels": fields.get("labels", []), + "components": [c.get("name") for c in fields.get("components", [])], + "fix_versions": [v.get("name") for v in fields.get("fixVersions", [])], + "subtasks": _extract_jira_subtasks(fields), + "linked_issues": _extract_jira_links(fields), + } - # Acceptance criteria may live in a custom field or in description - acceptance_criteria = ( - fields.get("customfield_10400") # common custom field id - or fields.get("customfield_10300") - or fields.get("customfield_10206") - or "" - ) - # Extract subtasks / child issues - subtasks = [] - for sub in fields.get("subtasks", []): - subtasks.append({ +def _find_acceptance_criteria(fields: dict) -> str: + """Find acceptance criteria from known Jira custom fields.""" + for field_id in _AC_CUSTOM_FIELDS: + value = fields.get(field_id) + if value: + return value + return "" + + +def _extract_jira_subtasks(fields: dict) -> list[dict]: + """Extract subtask info from Jira fields.""" + return [ + { "key": sub.get("key"), - "summary": sub.get("fields", {}).get("summary"), - "status": sub.get("fields", {}).get("status", {}).get("name"), - }) + "summary": _nested_get(sub, "fields", "summary"), + "status": _nested_get(sub, "fields", "status", "name"), + } + for sub in fields.get("subtasks", []) + ] - # Extract linked issues + +def _extract_jira_links(fields: dict) -> list[dict]: + """Extract linked issue info from Jira fields.""" links = [] for link in fields.get("issuelinks", []): linked = link.get("outwardIssue") or link.get("inwardIssue") if linked: links.append({ "key": linked.get("key"), - "summary": linked.get("fields", {}).get("summary"), - "relationship": link.get("type", {}).get("outward") or link.get("type", {}).get("name"), + "summary": _nested_get(linked, "fields", "summary"), + "relationship": ( + _nested_get(link, "type", "outward") + or _nested_get(link, "type", "name") + ), }) + return links - return { - "key": data.get("key"), - "summary": fields.get("summary"), - "type": fields.get("issuetype", {}).get("name"), - "status": fields.get("status", {}).get("name"), - "priority": fields.get("priority", {}).get("name"), - "assignee": (fields.get("assignee") or {}).get("displayName"), - "reporter": (fields.get("reporter") or {}).get("displayName"), - "description": fields.get("description") or "", - "acceptance_criteria": acceptance_criteria, - "labels": fields.get("labels", []), - "components": [c.get("name") for c in fields.get("components", [])], - "fix_versions": [v.get("name") for v in fields.get("fixVersions", [])], - "subtasks": subtasks, - "linked_issues": links, - } +# --------------------------------------------------------------------------- +# Jira markup conversion +# --------------------------------------------------------------------------- -def _strip_jira_markup(text: str) -> str: - """Rough conversion of Jira wiki markup / ADF to plain text.""" +def _strip_jira_markup(text: str | dict) -> str: + """Convert Jira wiki markup or ADF to plain text.""" if not text: return "" - # Handle ADF (Atlassian Document Format) JSON if isinstance(text, dict): return _adf_to_text(text) - # Simple wiki-markup stripping text = re.sub(r"\{noformat\}.*?\{noformat\}", "", text, flags=re.DOTALL) text = re.sub(r"\{code[^}]*\}.*?\{code\}", "", text, flags=re.DOTALL) - text = re.sub(r"\[([^|]*)\|[^\]]*\]", r"\1", text) # [text|url] -> text - text = re.sub(r"[{*_~^+]", "", text) # remove markup chars - text = re.sub(r"h[1-6]\.\s*", "", text) # headings - text = re.sub(r"^[#\-\*]+\s*", "", text, flags=re.MULTILINE) # list markers + text = re.sub(r"\[([^|]*)\|[^\]]*\]", r"\1", text) + text = re.sub(r"[{*_~^+]", "", text) + text = re.sub(r"h[1-6]\.\s*", "", text) + text = re.sub(r"^[#\-*]+\s*", "", text, flags=re.MULTILINE) return text.strip() @@ -226,10 +329,7 @@ def _adf_to_text(node: dict) -> str: """Recursively extract text from Atlassian Document Format.""" if node.get("type") == "text": return node.get("text", "") - parts = [] - for child in node.get("content", []): - parts.append(_adf_to_text(child)) - return "\n".join(parts) + return "\n".join(_adf_to_text(child) for child in node.get("content", [])) # --------------------------------------------------------------------------- @@ -238,70 +338,24 @@ def _adf_to_text(node: dict) -> str: def _is_github_ref(ticket: str) -> bool: """Check if the ticket reference is a GitHub issue (owner/repo#123).""" - return bool(re.match(r"^[\w.-]+/[\w.-]+#\d+$", ticket)) + return bool(_GITHUB_REF_PATTERN.match(ticket)) def _parse_github_ref(ticket: str) -> tuple[str, str, int]: """Parse 'owner/repo#123' into (owner, repo, issue_number).""" - match = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", ticket) + match = _GITHUB_REF_PATTERN.match(ticket) if not match: - print(f"ERROR: Invalid GitHub issue reference: {ticket}", file=sys.stderr) - print(" Expected format: owner/repo#123", file=sys.stderr) - sys.exit(1) + _exit_with_error( + f"Invalid GitHub issue reference: {ticket}\n" + " Expected format: owner/repo#123" + ) return match.group(1), match.group(2), int(match.group(3)) -def _github_api_get(path: str) -> dict: - """Perform a GET request to the GitHub REST API.""" - url = f"https://api.github.com/{path.lstrip('/')}" - headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} - token = os.environ.get("GITHUB_TOKEN") - if token: - headers["Authorization"] = f"Bearer {token}" - - req = Request(url, headers=headers, method="GET") - try: - with urlopen(req) as resp: - return json.loads(resp.read().decode()) - except HTTPError as e: - body = e.read().decode() if e.fp else "" - print(f"ERROR: HTTP {e.code} from {url}", file=sys.stderr) - if e.code == 401: - print(" Set GITHUB_TOKEN env var for authentication.", file=sys.stderr) - elif e.code == 404: - print(" Issue not found or repo is private (set GITHUB_TOKEN with 'repo' scope).", file=sys.stderr) - if body: - try: - msg = json.loads(body).get("message", body[:300]) - except Exception: - msg = body[:300] - print(f" {msg}", file=sys.stderr) - sys.exit(1) - except URLError as e: - print(f"ERROR: Cannot reach {url} — {e.reason}", file=sys.stderr) - sys.exit(1) - - def _extract_github_issue(owner: str, repo: str, issue_number: int) -> dict: """Fetch and extract a GitHub issue into the standard ticket dict.""" data = _github_api_get(f"repos/{owner}/{repo}/issues/{issue_number}") - - # Fetch comments for additional context - comments = [] - if data.get("comments", 0) > 0: - comments_data = _github_api_get(f"repos/{owner}/{repo}/issues/{issue_number}/comments") - for c in comments_data[:10]: # limit to 10 - comments.append({ - "author": (c.get("user") or {}).get("login"), - "body": c.get("body", ""), - }) - - # Try to extract acceptance criteria from issue body body = data.get("body") or "" - acceptance_criteria = _extract_ac_from_markdown(body) - - # Linked issues / sub-tasks from task list checkboxes - subtasks = _extract_task_list(body) return { "key": f"{owner}/{repo}#{issue_number}", @@ -309,40 +363,53 @@ def _extract_github_issue(owner: str, repo: str, issue_number: int) -> dict: "type": "Pull Request" if data.get("pull_request") else "Issue", "status": data.get("state", "open").capitalize(), "priority": None, - "assignee": (data.get("assignee") or {}).get("login"), - "reporter": (data.get("user") or {}).get("login"), + "assignee": _nested_get(data, "assignee", "login"), + "reporter": _nested_get(data, "user", "login"), "description": body, - "acceptance_criteria": acceptance_criteria, - "labels": [l.get("name") for l in data.get("labels", [])], + "acceptance_criteria": _extract_ac_from_markdown(body), + "labels": [label.get("name") for label in data.get("labels", [])], "components": [], "fix_versions": [m.get("title") for m in [data.get("milestone")] if m], - "subtasks": subtasks, + "subtasks": _extract_task_list(body), "linked_issues": [], - "_comments": comments, + "_comments": _fetch_github_comments(owner, repo, issue_number, data), } +def _fetch_github_comments( + owner: str, repo: str, issue_number: int, issue_data: dict +) -> list[dict]: + """Fetch issue comments if any exist.""" + if issue_data.get("comments", 0) == 0: + return [] + comments_data = _github_api_get( + f"repos/{owner}/{repo}/issues/{issue_number}/comments" + ) + return [ + { + "author": _nested_get(c, "user", "login"), + "body": c.get("body", ""), + } + for c in comments_data[:_MAX_GITHUB_COMMENTS] + ] + + def _extract_ac_from_markdown(body: str) -> str: """Try to extract an 'Acceptance Criteria' section from a markdown body.""" - # Look for a heading like ## Acceptance Criteria, ### AC, etc. - match = re.search( - r"(?:^|\n)#{1,4}\s*(?:acceptance\s*criteria|AC)\s*\n(.*?)(?=\n#{1,4}\s|\Z)", - body, re.IGNORECASE | re.DOTALL, - ) + match = _AC_HEADING_PATTERN.search(body) return match.group(1).strip() if match else "" def _extract_task_list(body: str) -> list[dict]: """Extract GitHub task list items (- [ ] / - [x]) as subtasks.""" - items = re.findall(r"- \[([ xX])\]\s+(.*)", body) - subtasks = [] - for checked, text in items: - subtasks.append({ + return [ + { "key": None, "summary": text.strip(), "status": "Done" if checked.lower() == "x" else "To Do", - }) - return subtasks + } + for checked, text in _TASK_LIST_PATTERN.findall(body) + ] # --------------------------------------------------------------------------- @@ -351,7 +418,19 @@ def _extract_task_list(body: str) -> list[dict]: def _format_markdown(ticket: dict, children: list[dict] | None = None) -> str: """Format ticket info as readable markdown.""" - lines = [] + lines: list[str] = [] + _append_header(lines, ticket) + _append_section(lines, "Description", _strip_jira_markup(ticket["description"])) + _append_section(lines, "Acceptance Criteria", _strip_jira_markup(ticket["acceptance_criteria"])) + _append_subtasks(lines, ticket.get("subtasks", [])) + _append_linked_issues(lines, ticket.get("linked_issues", [])) + _append_children(lines, children) + _append_comments(lines, ticket.get("_comments", [])) + return "\n".join(lines) + + +def _append_header(lines: list[str], ticket: dict) -> None: + """Append ticket header metadata.""" lines.append(f"# {ticket['key']}: {ticket['summary']}") lines.append("") lines.append(f"**Type:** {ticket['type']} ") @@ -359,66 +438,64 @@ def _format_markdown(ticket: dict, children: list[dict] | None = None) -> str: lines.append(f"**Priority:** {ticket['priority']} ") if ticket["assignee"]: lines.append(f"**Assignee:** {ticket['assignee']} ") - if ticket["labels"]: + if ticket.get("labels"): lines.append(f"**Labels:** {', '.join(ticket['labels'])} ") - if ticket["components"]: + if ticket.get("components"): lines.append(f"**Components:** {', '.join(ticket['components'])} ") lines.append("") - desc = _strip_jira_markup(ticket["description"]) - if desc: - lines.append("## Description") - lines.append("") - lines.append(desc) - lines.append("") - ac = _strip_jira_markup(ticket["acceptance_criteria"]) - if ac: - lines.append("## Acceptance Criteria") - lines.append("") - lines.append(ac) - lines.append("") +def _append_section(lines: list[str], heading: str, content: str) -> None: + """Append a markdown section if content is non-empty.""" + if content: + lines.extend([f"## {heading}", "", content, ""]) - if ticket["subtasks"]: - lines.append("## Subtasks") - lines.append("") - for st in ticket["subtasks"]: - lines.append(f"- **{st['key']}** — {st['summary']} _{st['status']}_") - lines.append("") - if ticket["linked_issues"]: - lines.append("## Linked Issues") - lines.append("") - for li in ticket["linked_issues"]: - lines.append(f"- {li['relationship']}: **{li['key']}** — {li['summary']}") - lines.append("") +def _append_subtasks(lines: list[str], subtasks: list[dict]) -> None: + """Append subtasks section if present.""" + if not subtasks: + return + lines.extend(["## Subtasks", ""]) + for st in subtasks: + lines.append(f"- **{st['key']}** — {st['summary']} _{st['status']}_") + lines.append("") - if children: - lines.append("## Child Ticket Details") - lines.append("") - for child in children: - lines.append(f"### {child['key']}: {child['summary']}") - child_desc = _strip_jira_markup(child["description"]) - if child_desc: - lines.append(child_desc) - child_ac = _strip_jira_markup(child["acceptance_criteria"]) - if child_ac: - lines.append("") - lines.append("**Acceptance Criteria:**") - lines.append(child_ac) - lines.append("") - - # GitHub comments - comments = ticket.get("_comments", []) - if comments: - lines.append("## Comments") + +def _append_linked_issues(lines: list[str], linked_issues: list[dict]) -> None: + """Append linked issues section if present.""" + if not linked_issues: + return + lines.extend(["## Linked Issues", ""]) + for li in linked_issues: + lines.append(f"- {li['relationship']}: **{li['key']}** — {li['summary']}") + lines.append("") + + +def _append_children(lines: list[str], children: list[dict] | None) -> None: + """Append expanded child ticket details if present.""" + if not children: + return + lines.extend(["## Child Ticket Details", ""]) + for child in children: + lines.append(f"### {child['key']}: {child['summary']}") + child_desc = _strip_jira_markup(child["description"]) + if child_desc: + lines.append(child_desc) + child_ac = _strip_jira_markup(child["acceptance_criteria"]) + if child_ac: + lines.extend(["", "**Acceptance Criteria:**", child_ac]) lines.append("") - for c in comments: - lines.append(f"**{c['author']}:**") - lines.append(c["body"].strip()) - lines.append("") - return "\n".join(lines) + +def _append_comments(lines: list[str], comments: list[dict]) -> None: + """Append GitHub comments section if present.""" + if not comments: + return + lines.extend(["## Comments", ""]) + for c in comments: + lines.append(f"**{c['author']}:**") + lines.append(c["body"].strip()) + lines.append("") def _format_json(ticket: dict, children: list[dict] | None = None) -> str: @@ -435,63 +512,68 @@ def _format_json(ticket: dict, children: list[dict] | None = None) -> str: def _generate_gherkin_skeleton(ticket: dict) -> str: """Generate a rough Gherkin feature skeleton from ticket details.""" - lines = [] summary = ticket["summary"] desc = _strip_jira_markup(ticket["description"]) ac = _strip_jira_markup(ticket["acceptance_criteria"]) + tag = _determine_test_tag(ticket) - # Determine tags - tags = ["@api"] # default; the user can adjust - ticket_type = (ticket.get("type") or "").lower() - if "ui" in ticket_type or "frontend" in ticket_type: - tags = ["@ui"] + lines = [ + f"# Auto-generated from {ticket['key']}", + "# Review and refine before use", + "", + f"@{tag}", + f"Feature: {summary}", + "", + ] - lines.append(f"# Auto-generated from {ticket['key']}") - lines.append(f"# Review and refine before use") - lines.append("") - lines.append(" ".join(f"@{t.lstrip('@')}" for t in tags)) - lines.append(f"Feature: {summary}") - lines.append("") if desc: - for line in textwrap.wrap(desc, width=100): + for line in textwrap.wrap(desc, width=_GHERKIN_WRAP_WIDTH): lines.append(f" {line}") lines.append("") - # Try to parse acceptance criteria into scenarios scenarios = _parse_ac_into_scenarios(ac or desc) if scenarios: - for i, scenario in enumerate(scenarios, 1): - lines.append(f" @normal") - lines.append(f" Scenario: {scenario['title']}") - lines.append(f" # TODO: Implement steps") - lines.append(f" Given ") - lines.append(f" When ") - lines.append(f" Then ") - lines.append("") + for scenario in scenarios: + lines.extend([ + " @normal", + f" Scenario: {scenario['title']}", + " # TODO: Implement steps", + " Given ", + " When ", + " Then ", + "", + ]) else: - lines.append(" @normal") - lines.append(f" Scenario: {summary}") - lines.append(f" # TODO: Derive steps from description and acceptance criteria") - lines.append(f" Given ") - lines.append(f" When ") - lines.append(f" Then ") - lines.append("") + lines.extend([ + " @normal", + f" Scenario: {summary}", + " # TODO: Derive steps from description and acceptance criteria", + " Given ", + " When ", + " Then ", + "", + ]) return "\n".join(lines) +def _determine_test_tag(ticket: dict) -> str: + """Determine the primary test tag based on ticket type.""" + ticket_type = (ticket.get("type") or "").lower() + if "ui" in ticket_type or "frontend" in ticket_type: + return "ui" + return "api" + + def _parse_ac_into_scenarios(text: str) -> list[dict]: """Try to extract individual acceptance criteria as scenario titles.""" if not text: return [] scenarios = [] - # Match numbered or bulleted items - items = re.findall(r"(?:^|\n)\s*(?:\d+[.)]\s*|[-*•]\s*)(.*?)(?=\n\s*(?:\d+[.)]\s*|[-*•]\s*)|\Z)", text, re.DOTALL) - for item in items: + for item in _AC_ITEMS_PATTERN.findall(text): title = item.strip().split("\n")[0].strip() - if len(title) > 10: # skip tiny fragments - # Clean up and truncate - title = title[:120].rstrip(".") + if len(title) > _MIN_AC_TITLE_LENGTH: + title = title[:_MAX_AC_TITLE_LENGTH].rstrip(".") scenarios.append({"title": title}) return scenarios @@ -505,24 +587,23 @@ def _fetch_ticket(ticket_ref: str) -> dict: if _is_github_ref(ticket_ref): owner, repo, number = _parse_github_ref(ticket_ref) return _extract_github_issue(owner, repo, number) - else: - data = _api_get(f"issue/{ticket_ref}?expand=renderedFields") - return _extract_ticket(data) + data = _jira_api_get(f"issue/{ticket_ref}?expand=renderedFields") + return _extract_jira_ticket(data) # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- -def cmd_fetch(args): +def cmd_fetch(args: argparse.Namespace) -> None: """Fetch and display a ticket.""" ticket = _fetch_ticket(args.ticket) - children = [] + children: list[dict] = [] if args.include_children and not _is_github_ref(args.ticket): for sub in ticket.get("subtasks", []): - child_data = _api_get(f"issue/{sub['key']}") - children.append(_extract_ticket(child_data)) + child_data = _jira_api_get(f"issue/{sub['key']}") + children.append(_extract_jira_ticket(child_data)) if args.format == "json": print(_format_json(ticket, children)) @@ -530,7 +611,7 @@ def cmd_fetch(args): print(_format_markdown(ticket, children)) -def cmd_generate_gherkin(args): +def cmd_generate_gherkin(args: argparse.Namespace) -> None: """Fetch a ticket and generate a Gherkin skeleton.""" ticket = _fetch_ticket(args.ticket) print(_generate_gherkin_skeleton(ticket)) @@ -540,9 +621,10 @@ def cmd_generate_gherkin(args): # CLI # --------------------------------------------------------------------------- -def main(): +def main() -> None: + """Entry point for the CLI.""" parser = argparse.ArgumentParser( - description="Fetch Jira ticket details for test generation.", + description="Fetch Jira or GitHub ticket details for test generation.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=textwrap.dedent("""\ Jira env vars: @@ -560,16 +642,26 @@ def main(): ) sub = parser.add_subparsers(dest="command", required=True) - # fetch p_fetch = sub.add_parser("fetch", help="Fetch ticket details") - p_fetch.add_argument("ticket", help="Ticket key: PROJ-123 (Jira) or owner/repo#123 (GitHub)") - p_fetch.add_argument("--format", choices=["markdown", "json"], default="markdown") - p_fetch.add_argument("--include-children", action="store_true", help="Also fetch subtask/child details") + p_fetch.add_argument( + "ticket", help="Ticket key: PROJ-123 (Jira) or owner/repo#123 (GitHub)" + ) + p_fetch.add_argument( + "--format", choices=["markdown", "json"], default="markdown" + ) + p_fetch.add_argument( + "--include-children", + action="store_true", + help="Also fetch subtask/child details", + ) p_fetch.set_defaults(func=cmd_fetch) - # generate-gherkin - p_gherkin = sub.add_parser("generate-gherkin", help="Generate Gherkin skeleton from ticket") - p_gherkin.add_argument("ticket", help="Ticket key: PROJ-123 (Jira) or owner/repo#123 (GitHub)") + p_gherkin = sub.add_parser( + "generate-gherkin", help="Generate Gherkin skeleton from ticket" + ) + p_gherkin.add_argument( + "ticket", help="Ticket key: PROJ-123 (Jira) or owner/repo#123 (GitHub)" + ) p_gherkin.set_defaults(func=cmd_generate_gherkin) args = parser.parse_args() @@ -577,6 +669,6 @@ def main(): if __name__ == "__main__": + _load_env_file() main() - diff --git a/.gitignore b/.gitignore index 1e94c67..c90cf4a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ gradle.bat /out **/classes *.iws +__pycache__ # STS CONFIG .springBeans