diff --git a/pages/__init__.py b/interactions/__init__.py similarity index 100% rename from pages/__init__.py rename to interactions/__init__.py diff --git a/interactions/duckduckgo/__init__.py b/interactions/duckduckgo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/interactions/duckduckgo/pages.py b/interactions/duckduckgo/pages.py new file mode 100644 index 0000000..67f439f --- /dev/null +++ b/interactions/duckduckgo/pages.py @@ -0,0 +1,23 @@ +""" +This module contains DuckDuckGo pages. +""" + +from playwright.sync_api import Page + + +class SearchPage: + + URL = 'https://www.duckduckgo.com' + + def __init__(self, page: Page) -> None: + self.page = page + self.search_button = page.locator('#search_button_homepage') + self.search_input = page.locator('#search_form_input_homepage') + + +class ResultPage: + def __init__(self, page: Page) -> None: + self.page = page + self.result_links = page.locator('a[data-testid="result-title-a"]') + self.search_input = page.locator('#search_form_input') + \ No newline at end of file diff --git a/interactions/duckduckgo/questions.py b/interactions/duckduckgo/questions.py new file mode 100644 index 0000000..b1a35c4 --- /dev/null +++ b/interactions/duckduckgo/questions.py @@ -0,0 +1,35 @@ +""" +This module contains DuckDuckGo Questions. +""" + +from abc import ABC, abstractmethod +from interactions.duckduckgo.pages import ResultPage +from playwright.sync_api import Page +from screenplay.pattern import Actor, Question, Answer + + +# ------------------------------------------------------------ +# DuckDuckGo Question parent class +# ------------------------------------------------------------ + +class DuckDuckGoQuestion(Question[Answer], ABC): + + @abstractmethod + def request_on_page(self, actor: Actor, page: Page) -> Answer: + pass + + def request_as(self, actor: Actor) -> Answer: + page: Page = actor.using('page') + return self.request_on_page(actor, page) + + +# ------------------------------------------------------------ +# DuckDuckGo Questions +# ------------------------------------------------------------ + +class result_link_titles(DuckDuckGoQuestion[list[str]]): + + def request_on_page(self, _, page: Page) -> list[str]: + result_page = ResultPage(page) + result_page.result_links.nth(4).wait_for() + return result_page.result_links.all_text_contents() diff --git a/interactions/duckduckgo/tasks.py b/interactions/duckduckgo/tasks.py new file mode 100644 index 0000000..888933c --- /dev/null +++ b/interactions/duckduckgo/tasks.py @@ -0,0 +1,80 @@ +""" +This module contains DuckDuckGo Tasks. +""" + +from abc import ABC, abstractmethod +from interactions.duckduckgo.pages import ResultPage, SearchPage +from interactions.duckduckgo.questions import result_link_titles +from playwright.sync_api import Page, expect +from screenplay.pattern import Actor, Task + + +# ------------------------------------------------------------ +# DuckDuckGo Task parent class +# ------------------------------------------------------------ + +class DuckDuckGoTask(Task, ABC): + + @abstractmethod + def perform_on_page(self, actor: Actor, page: Page) -> None: + pass + + def perform_as(self, actor: Actor) -> None: + page: Page = actor.using('page') + self.perform_on_page(actor, page) + + +# ------------------------------------------------------------ +# DuckDuckGo Tasks +# ------------------------------------------------------------ + +class load_duckduckgo(DuckDuckGoTask): + + def perform_on_page(self, _, page: Page) -> None: + page.goto(SearchPage.URL) + + +class search_duckduckgo_for(DuckDuckGoTask): + + def __init__(self, phrase: str) -> None: + super().__init__() + self.phrase = phrase + + def perform_on_page(self, _, page: Page) -> None: + search_page = SearchPage(page) + search_page.search_input.fill(self.phrase) + search_page.search_button.click() + + +class verify_page_title_is(DuckDuckGoTask): + + def __init__(self, title: str) -> None: + super().__init__() + self.title = title + + def perform_on_page(self, _, page: Page) -> None: + expect(page).to_have_title(self.title) + + +class verify_result_link_titles_contain(Task): + + def __init__(self, phrase: str, minimum: int = 1) -> None: + super().__init__() + self.phrase = phrase + self.minimum = minimum + + def perform_as(self, actor: Actor) -> None: + titles = actor.asks_for(result_link_titles()) + matches = [t for t in titles if self.phrase.lower() in t.lower()] + assert len(matches) >= self.minimum + + +class verify_search_result_query_is(DuckDuckGoTask): + + def __init__(self, phrase: str) -> None: + super().__init__() + self.phrase = phrase + + def perform_on_page(self, _, page: Page) -> None: + result_page = ResultPage(page) + expect(result_page.search_input).to_have_value(self.phrase) diff --git a/interactions/github/__init__.py b/interactions/github/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/interactions/github/calls.py b/interactions/github/calls.py new file mode 100644 index 0000000..b84606c --- /dev/null +++ b/interactions/github/calls.py @@ -0,0 +1,52 @@ +""" +This module contains REST API calls for GitHub Projects. +""" + +from abc import ABC, abstractmethod +from playwright.sync_api import APIRequestContext, APIResponse, expect +from screenplay.pattern import Actor, Question + + +# ------------------------------------------------------------ +# GitHub Project Call parent class +# ------------------------------------------------------------ + +class GitHubProjectCall(Question[APIResponse], ABC): + + @abstractmethod + def call_as(self, actor: Actor, context: APIRequestContext) -> APIResponse: + pass + + def request_as(self, actor: Actor) -> APIResponse: + context: APIRequestContext = actor.using('gh_context') + return self.call_as(actor, context) + + +# ------------------------------------------------------------ +# GitHub Project Calls +# ------------------------------------------------------------ + +class create_card(GitHubProjectCall): + + def __init__(self, column_id: str, note: str | None = None) -> None: + self.column_id = column_id + self.note = note + + def call_as(self, actor: Actor, context: APIRequestContext) -> APIResponse: + response = context.post( + f'/projects/columns/{self.column_id}/cards', + data={'note': self.note}) + expect(response).to_be_ok() + assert response.json()['note'] == self.note + return response + + +class retrieve_card(GitHubProjectCall): + + def __init__(self, card_id: str) -> None: + self.card_id = card_id + + def call_as(self, actor: Actor, context: APIRequestContext) -> APIResponse: + response = context.get(f'/projects/columns/cards/{self.card_id}') + expect(response).to_be_ok() + return response \ No newline at end of file diff --git a/interactions/github/tasks.py b/interactions/github/tasks.py new file mode 100644 index 0000000..e100251 --- /dev/null +++ b/interactions/github/tasks.py @@ -0,0 +1,70 @@ +""" +This module contains GitHub Project Tasks. +""" + +from abc import ABC, abstractmethod +from playwright.sync_api import Page, expect +from screenplay.pattern import Actor, Task + + +# ------------------------------------------------------------ +# GitHub Project Task parent class +# ------------------------------------------------------------ + +class GitHubProjectTask(Task, ABC): + + @abstractmethod + def perform_on_page(self, actor: Actor, page: Page) -> None: + pass + + def perform_as(self, actor: Actor) -> None: + page: Page = actor.using('page') + self.perform_on_page(actor, page) + + +# ------------------------------------------------------------ +# GitHub Project Tasks +# ------------------------------------------------------------ + +class log_into_github_as(GitHubProjectTask): + + def __init__(self, username: str, password: str) -> None: + self.username = username + self.password = password + + def perform_on_page(self, actor: Actor, page: Page) -> None: + page.goto(f'https://github.com/login') + page.locator('id=login_field').fill(self.username) + page.locator('id=password').fill(self.password) + page.locator('input[name="commit"]').click() + + +class load_github_project_for(GitHubProjectTask): + + def __init__(self, username: str, project_number: str) -> None: + self.username = username + self.project_number = project_number + + def perform_on_page(self, actor: Actor, page: Page) -> None: + page.goto(f'https://github.com/users/{self.username}/projects/{self.project_number}') + + +class verify_card_appears_with(GitHubProjectTask): + + def __init__(self, column_id: str, note: str) -> None: + self.column_id = column_id + self.note = note + + def perform_on_page(self, actor: Actor, page: Page) -> None: + card_xpath = f'//div[@id="column-cards-{self.column_id}"]//p[contains(text(), "{self.note}")]' + expect(page.locator(card_xpath)).to_be_visible() + + +class move_card_to(GitHubProjectTask): + + def __init__(self, column_id: str, note: str) -> None: + self.column_id = column_id + self.note = note + + def perform_on_page(self, actor: Actor, page: Page) -> None: + page.drag_and_drop(f'text="{self.note}"', f'id=column-cards-{self.column_id}') \ No newline at end of file diff --git a/pages/result.py b/pages/result.py deleted file mode 100644 index 53c591c..0000000 --- a/pages/result.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -This module contains DuckDuckGoResultPage, -the page object for the DuckDuckGo result page. -""" - -from playwright.sync_api import Page -from typing import List - - -class DuckDuckGoResultPage: - - def __init__(self, page: Page) -> None: - self.page = page - self.result_links = page.locator('a[data-testid="result-title-a"]') - self.search_input = page.locator('#search_form_input') - - def result_link_titles(self) -> List[str]: - self.result_links.nth(4).wait_for() - return self.result_links.all_text_contents() - - def result_link_titles_contain_phrase(self, phrase: str, minimum: int = 1) -> bool: - titles = self.result_link_titles() - matches = [t for t in titles if phrase.lower() in t.lower()] - return len(matches) >= minimum diff --git a/pages/search.py b/pages/search.py deleted file mode 100644 index 92d7103..0000000 --- a/pages/search.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -This module contains DuckDuckGoSearchPage, -the page object for the DuckDuckGo search page. -""" - -from playwright.sync_api import Page - - -class DuckDuckGoSearchPage: - - URL = 'https://www.duckduckgo.com' - - def __init__(self, page: Page) -> None: - self.page = page - self.search_button = page.locator('#search_button_homepage') - self.search_input = page.locator('#search_form_input_homepage') - - def load(self) -> None: - self.page.goto(self.URL) - - def search(self, phrase: str) -> None: - self.search_input.fill(phrase) - self.search_button.click() diff --git a/tests/conftest.py b/tests/conftest.py index fb5ef24..ebc01d6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,24 +9,26 @@ import os import pytest -from pages.result import DuckDuckGoResultPage -from pages.search import DuckDuckGoSearchPage from playwright.sync_api import Playwright, APIRequestContext, Page, expect +from screenplay.pattern import Actor from typing import Generator # ------------------------------------------------------------ -# DuckDuckGo search fixtures +# Screenplay fixtures # ------------------------------------------------------------ @pytest.fixture -def result_page(page: Page) -> DuckDuckGoResultPage: - return DuckDuckGoResultPage(page) +def actor(page: Page) -> Actor: + actor = Actor() + actor.can_use(page=page) + return actor @pytest.fixture -def search_page(page: Page) -> DuckDuckGoSearchPage: - return DuckDuckGoSearchPage(page) +def gh_actor(actor: Actor, gh_context: APIRequestContext) -> Actor: + actor.can_use(gh_context=gh_context) + return actor # ------------------------------------------------------------ diff --git a/tests/test_github_project.py b/tests/test_github_project.py index 7ce3bc5..b56aa52 100644 --- a/tests/test_github_project.py +++ b/tests/test_github_project.py @@ -8,7 +8,9 @@ import time -from playwright.sync_api import APIRequestContext, Page, expect +from interactions.github.calls import * +from interactions.github.tasks import * +from screenplay.pattern import Actor # ------------------------------------------------------------ @@ -16,7 +18,7 @@ # ------------------------------------------------------------ def test_create_project_card( - gh_context: APIRequestContext, + gh_actor: Actor, project_column_ids: list[str]) -> None: # Prep test data @@ -24,16 +26,11 @@ def test_create_project_card( note = f'A new task at {now}' # Create a new card - c_response = gh_context.post( - f'/projects/columns/{project_column_ids[0]}/cards', - data={'note': note}) - expect(c_response).to_be_ok() - assert c_response.json()['note'] == note + c_response = gh_actor.calls(create_card(project_column_ids[0], note)) # Retrieve the newly created card card_id = c_response.json()['id'] - r_response = gh_context.get(f'/projects/columns/cards/{card_id}') - expect(r_response).to_be_ok() + r_response = gh_actor.calls(retrieve_card(card_id)) assert r_response.json() == c_response.json() @@ -42,10 +39,9 @@ def test_create_project_card( # ------------------------------------------------------------ def test_move_project_card( - gh_context: APIRequestContext, + gh_actor: Actor, gh_project: dict, project_column_ids: list[str], - page: Page, gh_username: str, gh_password: str) -> None: @@ -56,33 +52,24 @@ def test_move_project_card( note = f'Move this card at {now}' # Create a new card via API - c_response = gh_context.post( - f'/projects/columns/{source_col}/cards', - data={'note': note}) - expect(c_response).to_be_ok() + c_response = gh_actor.calls(create_card(source_col, note)) # Log in via UI - page.goto(f'https://github.com/login') - page.locator('id=login_field').fill(gh_username) - page.locator('id=password').fill(gh_password) - page.locator('input[name="commit"]').click() + gh_actor.attempts_to(log_into_github_as(gh_username, gh_password)) # Load the project page - page.goto(f'https://github.com/users/{gh_username}/projects/{gh_project["number"]}') + gh_actor.attempts_to(load_github_project_for(gh_username, gh_project["number"])) # Verify the card appears in the first column - card_xpath = f'//div[@id="column-cards-{source_col}"]//p[contains(text(), "{note}")]' - expect(page.locator(card_xpath)).to_be_visible() + gh_actor.attempts_to(verify_card_appears_with(source_col, note)) # Move a card to the second column via web UI - page.drag_and_drop(f'text="{note}"', f'id=column-cards-{dest_col}') + gh_actor.attempts_to(move_card_to(dest_col, note)) # Verify the card is in the second column via UI - card_xpath = f'//div[@id="column-cards-{dest_col}"]//p[contains(text(), "{note}")]' - expect(page.locator(card_xpath)).to_be_visible() + gh_actor.attempts_to(verify_card_appears_with(dest_col, note)) # Verify the backend is updated via API card_id = c_response.json()['id'] - r_response = gh_context.get(f'/projects/columns/cards/{card_id}') - expect(r_response).to_be_ok() + r_response = gh_actor.calls(retrieve_card(card_id)) assert r_response.json()['column_url'].endswith(str(dest_col)) diff --git a/tests/test_search.py b/tests/test_search.py index a9c762f..a89305b 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -4,9 +4,8 @@ import pytest -from pages.result import DuckDuckGoResultPage -from pages.search import DuckDuckGoSearchPage -from playwright.sync_api import expect, Page +from interactions.duckduckgo.tasks import * +from screenplay.pattern import Actor ANIMALS = [ @@ -24,23 +23,19 @@ @pytest.mark.parametrize('phrase', ANIMALS) -def test_basic_duckduckgo_search( - phrase: str, - page: Page, - search_page: DuckDuckGoSearchPage, - result_page: DuckDuckGoResultPage) -> None: +def test_basic_duckduckgo_search(phrase: str, actor: Actor) -> None: # Given the DuckDuckGo home page is displayed - search_page.load() + actor.attempts_to(load_duckduckgo()) # When the user searches for a phrase - search_page.search(phrase) + actor.attempts_to(search_duckduckgo_for(phrase)) # Then the search result query is the phrase - expect(result_page.search_input).to_have_value(phrase) + actor.attempts_to(verify_search_result_query_is(phrase)) # And the search result links pertain to the phrase - assert result_page.result_link_titles_contain_phrase(phrase) + actor.attempts_to(verify_result_link_titles_contain(phrase)) # And the search result title contains the phrase - expect(page).to_have_title(f'{phrase} at DuckDuckGo') + actor.attempts_to(verify_page_title_is(f'{phrase} at DuckDuckGo'))