Skip to content

Commit bd493e7

Browse files
Merge branch 'main' of github.com:NHSDigital/bcss-playwright into feature/BCSS-20856-selenium-to-playwright-datasets-setup
# Conflicts: # conftest.py # pages/base_page.py
2 parents 4ceec7d + a815c5c commit bd493e7

18 files changed

+1900
-12
lines changed

buildBase.dockerfile

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,37 @@
1+
# This dockerfile allows for the code from the project to be built into a Docker image,
2+
# for use in a CI/CD-style environment such as GitHub Actions or Jenkins.
3+
# Further reading on this: https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-an-image/
4+
15
FROM python:3.13-slim
26

7+
# Create non-root OS user/group and configure environment
8+
RUN addgroup --system nonroot \
9+
&& adduser --system --home /home/nonroot nonroot --ingroup nonroot
10+
311
WORKDIR /test
412

13+
ENV HOME=/home/nonroot
14+
ENV PATH="$HOME/.local/bin:$PATH"
15+
516
# Install dependencies
617
COPY ./requirements.txt ./requirements.txt
7-
RUN pip install --no-cache-dir -r requirements.txt
8-
RUN playwright install --with-deps
9-
RUN playwright install chrome
18+
RUN pip install --no-cache-dir -r requirements.txt && \
19+
playwright install --with-deps && \
20+
mkdir -p /tests/ && \
21+
mkdir -p /utils/ && \
22+
mkdir -p /pages/
1023

11-
RUN mkdir -p /tests/
24+
# Copy project files
1225
COPY ./tests/ ./tests/
13-
RUN mkdir -p /utils/
1426
COPY ./utils/ ./utils/
27+
COPY ./pages/ ./pages/
28+
COPY ./conftest.py ./conftest.py
1529
COPY ./pytest.ini ./pytest.ini
1630
COPY ./run_tests.sh ./run_tests.sh
31+
COPY ./users.json ./users.json
32+
33+
# Set permissions, make the script executable and switch OS user
34+
RUN chmod +x ./run_tests.sh \
35+
&& chown -R nonroot:nonroot /test
1736

18-
RUN chmod +x ./run_tests.sh
37+
USER nonroot

conftest.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,16 @@
66

77
import pytest
88
import os
9+
import typing
910
from dotenv import load_dotenv
1011
from pathlib import Path
12+
from _pytest.python import Function
13+
from pytest_html.report_data import ReportData
1114
from utils.load_properties_file import PropertiesFile
15+
from _pytest.config.argparsing import Parser
16+
from _pytest.fixtures import FixtureRequest
17+
18+
# Environment Variable Handling
1219

1320
LOCAL_ENV_PATH = Path(os.getcwd()) / "local.env"
1421

@@ -37,10 +44,31 @@ def general_properties() -> dict:
3744
return PropertiesFile().get_general_properties()
3845

3946

40-
from typing import Any
41-
import pytest
42-
from _pytest.config.argparsing import Parser
43-
from _pytest.fixtures import FixtureRequest
47+
# HTML Report Customization
48+
49+
50+
def pytest_html_report_title(report: ReportData) -> None:
51+
report.title = "BCSS Test Automation Report"
52+
53+
54+
def pytest_html_results_table_header(cells: list) -> None:
55+
cells.insert(2, "<th>Description</th>")
56+
57+
58+
def pytest_html_results_table_row(report: object, cells: list) -> None:
59+
description = getattr(report, "description", "N/A")
60+
cells.insert(2, f"<td>{description}</td>")
61+
62+
63+
@pytest.hookimpl(hookwrapper=True)
64+
def pytest_runtest_makereport(item: Function) -> typing.Generator[None, None, None]:
65+
outcome = yield
66+
if outcome is not None:
67+
report = outcome.get_result()
68+
report.description = str(item.function.__doc__)
69+
70+
71+
# Command-Line Options for Pytest
4472

4573

4674
def pytest_addoption(parser: Parser) -> None:

docs/utility-guides/LastTestRun.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ See the docstrings in the code for details on each function.
5555
- **save_last_run_data(data: Dict[str, Any]) -> None**
5656
Saves the provided dictionary to the JSON file.
5757

58-
- **has_test_run_today(test_name: str) -> boolean**
58+
- **has_test_run_today(test_name: str) -> `bool`**
5959
Checks if the given test has already run today. If not, updates the record to mark it as run today.
6060

6161
---
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Utility Guide: Note Test Utilities
2+
3+
The **Note Test Utility** module provides reusable helper functions for verifying and comparing note data during test automation of screening subjects in BCSS.
4+
It includes helpers for:
5+
6+
1. Fetching supporting notes from the database.
7+
2. Verifying that a note matches expected values from the DB or UI.
8+
3. Confirming that a removed note is archived properly as obsolete.
9+
10+
## Table of Contents
11+
12+
- [Utility Guide: Note Test Utilities](#utility-guide-note-test-utilities)
13+
- [Table of Contents](#table-of-contents)
14+
- [Using These Utilities](#using-these-utilities)
15+
16+
---
17+
18+
## Using These Utilities
19+
20+
You can import functions into your test files like so:
21+
22+
```python
23+
from utils.note_test_util import (
24+
fetch_supporting_notes_from_db,
25+
verify_note_content_matches_expected,
26+
verify_note_content_ui_vs_db,
27+
verify_note_removal_and_obsolete_transition,
28+
)

pages/base_page.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,3 +275,18 @@ def handle_dialog(dialog: Dialog):
275275
def go_to_log_in_page(self) -> None:
276276
"""Click on the Log in button to navigate to the login page."""
277277
self.click(self.log_in_page)
278+
279+
def safe_accept_dialog_select_option(self, locator: Locator, option: str) -> None:
280+
"""
281+
Safely accepts a dialog triggered by selecting a dropdown, avoiding the error:
282+
playwright._impl._errors.Error: Dialog.accept: Cannot accept dialog which is already handled!
283+
If no dialog appears, continues without error.
284+
Args:
285+
locator (Locator): The locator that triggers the dialog when clicked.
286+
example: If clicking a save button opens a dialog, pass that save button's locator.
287+
"""
288+
self.page.once("dialog", self._accept_dialog)
289+
try:
290+
locator.select_option(option)
291+
except Exception as e:
292+
logging.error(f"Option selection failed: {e}")
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from playwright.sync_api import Page, Locator
2+
from pages.base_page import BasePage
3+
from enum import StrEnum
4+
import logging
5+
import pytest
6+
from utils.table_util import TableUtils
7+
8+
9+
class SubjectEventsNotes(BasePage):
10+
"""Subject Events Notes Page locators, and methods for interacting with the page."""
11+
12+
def __init__(self, page: Page):
13+
super().__init__(page)
14+
self.page = page
15+
self.table_utils = TableUtils(
16+
page, "#displayRS"
17+
) # Initialize TableUtils for the table with id="displayRS"
18+
# Subject Events Notes - page filters
19+
self.additional_care_note_checkbox = self.page.get_by_label(
20+
"Additional Care Needs Note"
21+
)
22+
self.subject_note_checkbox = self.page.get_by_label("Subject Note")
23+
self.kit_note_checkbox = self.page.get_by_label("Kit Note")
24+
self.note_title = self.page.get_by_label("Note Title")
25+
self.additional_care_note_type = self.page.locator("#UI_ADDITIONAL_CARE_NEED")
26+
self.notes_upto_500_char = self.page.get_by_label("Notes (up to 500 char)")
27+
self.update_notes_button = self.page.get_by_role("button", name="Update Notes")
28+
self.note_type = self.page.locator("#UI_ADDITIONAL_CARE_NEED_FILTER")
29+
self.note_status = self.page.locator(
30+
"//table[@id='displayRS']/tbody/tr[2]/td[3]/select"
31+
)
32+
self.episode_note_status = self.page.locator(
33+
"//table[@id='displayRS']/tbody/tr[2]/td[4]/select"
34+
)
35+
36+
def select_additional_care_note(self) -> None:
37+
"""Selects the 'Additional Care Needs Note' checkbox."""
38+
self.additional_care_note_checkbox.check()
39+
40+
def select_subject_note(self) -> None:
41+
"""Selects the 'subject note' checkbox."""
42+
self.subject_note_checkbox.check()
43+
44+
def select_kit_note(self) -> None:
45+
"""Selects the 'kit note' checkbox."""
46+
self.kit_note_checkbox.check()
47+
48+
def select_additional_care_note_type(self, option: str) -> None:
49+
"""Selects an option from the 'Additional Care Note Type' dropdown.
50+
51+
Args:
52+
option (AdditionalCareNoteTypeOptions): The option to select from the dropdown.
53+
Use one of the predefined values from the
54+
AdditionalCareNoteTypeOptions enum, such as:
55+
- AdditionalCareNoteTypeOptions.LEARNING_DISABILITY
56+
- AdditionalCareNoteTypeOptions.SIGHT_DISABILITY
57+
- AdditionalCareNoteTypeOptions.HEARING_DISABILITY
58+
- AdditionalCareNoteTypeOptions.MOBILITY_DISABILITY
59+
- AdditionalCareNoteTypeOptions.MANUAL_DEXTERITY
60+
- AdditionalCareNoteTypeOptions.SPEECH_DISABILITY
61+
- AdditionalCareNoteTypeOptions.CONTINENCE_DISABILITY
62+
- AdditionalCareNoteTypeOptions.LANGUAGE
63+
- AdditionalCareNoteTypeOptions.OTHER
64+
"""
65+
self.additional_care_note_type.select_option(option)
66+
67+
def select_note_type(self, option: str) -> None:
68+
"""
69+
Selects a note type from the dropdown menu.
70+
71+
Args:
72+
option (str): The value of the option to select from the dropdown.
73+
"""
74+
self.note_type.select_option(option)
75+
76+
def select_note_status(self, option: str) -> None:
77+
"""
78+
Selects a note status from the dropdown menu.
79+
80+
Args:
81+
option (str): The value of the option to select from the dropdown.
82+
"""
83+
self.note_status.select_option(option)
84+
85+
def fill_note_title(self, title: str) -> None:
86+
"""Fills the title field with the provided text."""
87+
self.note_title.fill(title)
88+
89+
def fill_notes(self, notes: str) -> None:
90+
"""Fills the notes field with the provided text."""
91+
self.notes_upto_500_char.fill(notes)
92+
93+
def accept_dialog_and_update_notes(self) -> None:
94+
"""Clicks the 'Update Notes' button and handles the dialog by clicking 'OK'."""
95+
self.page.once("dialog", lambda dialog: dialog.accept())
96+
self.update_notes_button.click()
97+
98+
def accept_dialog_and_add_replacement_note(self) -> None:
99+
"""
100+
Dismisses the dialog and clicks the 'Add Replacement Note' button.
101+
"""
102+
self.page.once("dialog", lambda dialog: dialog.accept())
103+
self.page.get_by_role("button", name="Add Replacement Note").click()
104+
105+
def get_title_and_note_from_row(self, row_number: int = 0) -> dict:
106+
"""
107+
Extracts title and note from a specific row's 'Notes' column using dynamic column index.
108+
"""
109+
cell_text = self.table_utils.get_cell_value("Notes", row_number)
110+
lines = cell_text.split("\n\n")
111+
title = lines[0].strip() if len(lines) > 0 else ""
112+
note = lines[1].strip() if len(lines) > 1 else ""
113+
logging.info(
114+
f"Extracted title: '{title}' and note: '{note}' from row {row_number}"
115+
)
116+
return {"title": title, "note": note}
117+
118+
119+
class AdditionalCareNoteTypeOptions(StrEnum):
120+
"""Enum for AdditionalCareNoteTypeOptions."""
121+
122+
LEARNING_DISABILITY = "4120"
123+
SIGHT_DISABILITY = "4121"
124+
HEARING_DISABILITY = "4122"
125+
MOBILITY_DISABILITY = "4123"
126+
MANUAL_DEXTERITY = "4124"
127+
SPEECH_DISABILITY = "4125"
128+
CONTINENCE_DISABILITY = "4126"
129+
LANGUAGE = "4128"
130+
OTHER = "4127"
131+
132+
133+
class NotesOptions(StrEnum):
134+
"""Enum for NoteTypeOptions."""
135+
136+
SUBJECT_NOTE = "4111"
137+
KIT_NOTE = "308015"
138+
ADDITIONAL_CARE_NOTE = "4112"
139+
EPISODE_NOTE = "4110"
140+
141+
142+
class NotesStatusOptions(StrEnum):
143+
"""Enum for NoteStatusOptions."""
144+
145+
ACTIVE = "4100"
146+
OBSOLETE = "4101"
147+
INVALID = "4102"

pages/screening_subject_search/subject_screening_search_page.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def __init__(self, page: Page):
1717
self.demographics_filter = self.page.get_by_role("radio", name="Demographics")
1818
self.datasets_filter = self.page.get_by_role("radio", name="Datasets")
1919
self.nhs_number_filter = self.page.get_by_role("textbox", name="NHS Number")
20+
self.nhs_number_input = self.page.get_by_label("NHS Number")
2021
self.surname_filter = self.page.locator("#A_C_Surname")
2122
self.soundex_filter = self.page.get_by_role("checkbox", name="Use soundex")
2223
self.forename_filter = self.page.get_by_role("textbox", name="Forename")

pages/screening_subject_search/subject_screening_summary_page.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def __init__(self, page: Page):
5454
self.advance_fobt_screening_episode_button = self.page.get_by_role(
5555
"button", name="Advance FOBT Screening Episode"
5656
)
57+
self.additional_care_note_link = self.page.get_by_role("link", name="(AN)")
5758
self.temporary_address_icon = self.page.get_by_role(
5859
"link", name="The person has a current"
5960
)
@@ -198,6 +199,47 @@ def click_advance_fobt_screening_episode_button(self) -> None:
198199
except Exception as e:
199200
pytest.fail(f"Unable to advance the episode: {e}")
200201

202+
def verify_additional_care_note_visible(self) -> None:
203+
"""Verifies that the '(AN)' link is visible."""
204+
expect(self.additional_care_note_link).to_be_visible()
205+
206+
def verify_note_link_present(self, note_type_name: str) -> None:
207+
"""
208+
Verifies that the link for the specified note type is visible on the page.
209+
210+
Args:
211+
note_type_name (str): The name of the note type to check (e.g., 'Additional Care Note', 'Episode Note').
212+
213+
Raises:
214+
AssertionError: If the link is not visible on the page.
215+
"""
216+
logging.info(f"Checking if the '{note_type_name}' link is visible.")
217+
note_link_locator = self.page.get_by_role(
218+
"link", name=f"({note_type_name})"
219+
) # Dynamic locator for the note type link
220+
assert (
221+
note_link_locator.is_visible()
222+
), f"'{note_type_name}' link is not visible, but it should be."
223+
logging.info(f"Verified: '{note_type_name}' link is visible.")
224+
225+
def verify_note_link_not_present(self, note_type_name: str) -> None:
226+
"""
227+
Verifies that the link for the specified note type is not visible on the page.
228+
229+
Args:
230+
note_type_name (str): The name of the note type to check (e.g., 'Additional Care Note', 'Episode Note').
231+
232+
Raises:
233+
AssertionError: If the link is visible on the page.
234+
"""
235+
logging.info(f"Checking if the '{note_type_name}' link is not visible.")
236+
note_link_locator = self.page.get_by_role(
237+
"link", name=f"({note_type_name})"
238+
) # Dynamic locator for the note type link
239+
assert (
240+
not note_link_locator.is_visible()
241+
), f"'{note_type_name}' link is visible, but it should not be."
242+
logging.info(f"Verified: '{note_type_name}' link is not visible.")
201243
def verify_temporary_address_popup_visible(self) -> None:
202244
"""Verify that the temporary address popup is visible."""
203245
try:

pytest.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,6 @@ markers =
3939
vpn_required: for tests that require a VPN connection
4040
regression: tests that are part of the regression test suite
4141
call_and_recall: tests that are part of the call and recall test suite
42+
note_tests: tests that are part of the notes test suite
4243
subject_tests: tests that are part of the subject tests suite
4344
subject_search: tests that are part of the subject search test suite

tests/bcss_tests.properties

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,17 @@ episode_closed_date=22/09/2020
2828
daily_invitation_rate=28
2929
fobt_daily_invitation_rate=6
3030
weekly_invitation_rate=130
31+
32+
# ----------------------------------
33+
# Subject Notes Test DATA
34+
# ----------------------------------
35+
additional_care_note_name=AN
36+
additional_care_note_type_value=4112
37+
episode_note_name=EN
38+
episode_note_type_value=4110
39+
subject_note_name=SN
40+
subject_note_type_value=4111
41+
kit_note_name=KN
42+
kit_note_type_value=308015
43+
note_status_active=4100
44+
note_status_obsolete=4101

0 commit comments

Comments
 (0)