Repository: communitiesuk/funding-service-design-fund-application-builder
Pull request #305: Adding TC-002 e2e changes to check grant creation test
Author: login: nuwan-samarasinghe
Created: 2025-02-28T13:56:30Z | Merged: 2025-03-04T10:31:34Z

**Ticket : https://mhclgdigital.atlassian.net/browse/FS-5037**


### Change description
adding TC-002 e2e changes to check grant creation test

- [x] Unit tests and other appropriate tests added or updated
- [x] README and other documentation has been updated / added (if needed)
- [x] Commit messages are meaningful and follow good commit message guidelines (e.g. "FS-XXXX: Add margin to nav items preventing overlapping of logo")


### How to test
follow e2e steps



Diff:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 90de6100..d7b1e0e5 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -22,4 +22,4 @@ repos:
             "--disable-plugin",
             "Base64HighEntropyString",
           ]
-        exclude: .env.development
+        exclude: 'tests/e2e/keys/.*'
diff --git a/pyproject.toml b/pyproject.toml
index 05c86dfd..cf0c586b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -65,5 +65,5 @@ dev = [
     "python-dotenv==1.0.1",
     "ruff==0.9.9",
     "pytest-playwright==0.7.0",
-    "pytest-xdist==3.6.1",
+    "Faker==36.1.1",
 ]
diff --git a/tests/e2e/README.MD b/tests/e2e/README.MD
index 0abd63e6..1d870e73 100644
--- a/tests/e2e/README.MD
+++ b/tests/e2e/README.MD
@@ -48,9 +48,3 @@ In this case, `fsd-dev` is the name of the profile in the aws-vault config file
     role_arn=arn:aws:iam::123123123123:role/developer
     mfa_serial=arn:aws:iam::123123123123:mfa/user.name
 ```
-
-## Parallel test runs
-
-By default the tests are set to spawn 3 workers and run in parallel. This is configured
-in [pyproject.toml](../pyproject.toml) under the pytest options. The project
-installs [pytest-xdist](https://pytest-xdist.readthedocs.io/en/stable/index.html) to enable this.
diff --git a/tests/e2e/config.py b/tests/e2e/config.py
index 7f0e9614..71d560be 100644
--- a/tests/e2e/config.py
+++ b/tests/e2e/config.py
@@ -1,5 +1,7 @@
+import base64
 import json
 import subprocess
+from pathlib import Path
 from typing import Literal, Protocol
 
 import boto3
@@ -10,12 +12,25 @@ class EndToEndTestSecrets(Protocol):
     @property
     def HTTP_BASIC_AUTH(self) -> HttpCredentials | None: ...
 
+    @property
+    def JWT_SIGNING_KEY(self) -> str | None: ...
+
 
 class LocalEndToEndSecrets:
     @property
     def HTTP_BASIC_AUTH(self) -> None:
         return None
 
+    @property
+    def JWT_SIGNING_KEY(self) -> str:
+        _test_private_key_path = (
+            str(Path(__file__).parent.parent) + "/e2e/keys/rsa256/private.pem"  # pragma: allowlist secret
+        )
+        with open(_test_private_key_path, mode="r") as private_key_file:
+            rsa256_private_key = private_key_file.read()
+
+        return rsa256_private_key
+
 
 class AWSEndToEndSecrets:
     def __init__(self, e2e_env: Literal["dev", "test"], e2e_aws_vault_profile: str | None):
@@ -65,3 +80,10 @@ def HTTP_BASIC_AUTH(self) -> HttpCredentials:
                 f"/copilot/pre-award/{self.e2e_env}/secrets/BASIC_AUTH_PASSWORD"
             ),
         }
+
+    @property
+    def JWT_SIGNING_KEY(self) -> str:
+        base64_value = self._read_aws_parameter_store_value(
+            f"/copilot/pre-award/{self.e2e_env}/secrets/RSA256_PRIVATE_KEY_BASE64"
+        )
+        return base64.b64decode(base64_value).decode()
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
index 52f32823..5ccee965 100644
--- a/tests/e2e/conftest.py
+++ b/tests/e2e/conftest.py
@@ -1,11 +1,18 @@
+import datetime
 import os
 import secrets
 import sys
+import uuid
 
+import jwt
 import pytest
-from pytest_playwright import CreateContextCallback
+from playwright.sync_api import BrowserContext
+from pytest import FixtureRequest
+from pytest_playwright.pytest_playwright import CreateContextCallback
 
+from config import Config
 from tests.e2e.config import AWSEndToEndSecrets, EndToEndTestSecrets, LocalEndToEndSecrets
+from tests.e2e.dataclass import Account, FabDomains
 
 
 @pytest.fixture
@@ -27,15 +34,24 @@ def unique_token():
 
 
 @pytest.fixture()
-def domains(request: pytest.FixtureRequest, get_e2e_params) -> str:
+def domains(request: pytest.FixtureRequest, get_e2e_params) -> FabDomains:
     e2e_env = get_e2e_params["e2e_env"]
     match e2e_env:
         case "local":
-            return "https://fund-application-builder.levellingup.gov.localhost:3011"
+            return FabDomains(
+                fab_url="https://fund-application-builder.levellingup.gov.localhost:3011",
+                cookie_domain=".levellingup.gov.localhost",
+            )
         case "dev":
-            return "https://fund-application-builder.access-funding.dev.communities.gov.uk"
+            return FabDomains(
+                fab_url="https://fund-application-builder.access-funding.dev.communities.gov.uk",
+                cookie_domain=".dev.access-funding.test.levellingup.gov.uk",
+            )
         case "test":
-            return "https://fund-application-builder.test.access-funding.test.levellingup.gov.uk"
+            return FabDomains(
+                fab_url="https://fund-application-builder.test.access-funding.test.levellingup.gov.uk",
+                cookie_domain=".test.access-funding.test.levellingup.gov.uk",
+            )
         case _:
             raise ValueError(f"not configured for {e2e_env}")
 
@@ -64,3 +80,67 @@ def e2e_test_secrets(request: pytest.FixtureRequest, get_e2e_params) -> EndToEnd
         return AWSEndToEndSecrets(e2e_env=e2e_env, e2e_aws_vault_profile=e2e_aws_vault_profile)
 
     raise ValueError(f"Unknown e2e_env: {e2e_env}.")
+
+
+@pytest.fixture()
+def user_auth(
+    request: FixtureRequest,
+    domains: FabDomains,
+    context: BrowserContext,
+    e2e_test_secrets: EndToEndTestSecrets,
+) -> Account:
+    """This fixture sets up the browser with an auth cookie so that the test user is 'logged in' correctly.
+
+    It bypasses the standard authentication process of doing this (using Authenticator), and instead (ab)uses our
+    JWT authentication model by self-signing the blob of data that authenticator provides.
+
+    We should be careful to keep this blob of JWT data in sync with what Authenticator would actually set."""
+    email_address = _generate_email_address(
+        test_name=request.node.originalname,
+        email_domain="communities.gov.uk",
+    )
+    roles_marker = request.node.get_closest_marker("user_roles")
+    user_roles = roles_marker.args[0] if roles_marker else ["FSD_ADMIN"]
+
+    now = int(datetime.datetime.timestamp(datetime.datetime.now()))
+    jwt_data = {
+        "accountId": str(uuid.uuid4()),
+        "azureAdSubjectId": str(uuid.uuid4()),
+        "email": email_address,
+        "fullName": f"E2E Test User - {request.node.originalname}",
+        "roles": user_roles,
+        "iat": now,
+        "exp": now + (15 * 60),  # 15 minutes from now
+    }
+
+    # The algorithm below must match that used by fsd-authenticator
+    cookie_value = jwt.encode(jwt_data, e2e_test_secrets.JWT_SIGNING_KEY, algorithm="RS256")
+
+    context.add_cookies(
+        [
+            {
+                "name": Config.FSD_USER_TOKEN_COOKIE_NAME,
+                "value": cookie_value,
+                "domain": domains.cookie_domain,
+                "path": "/",
+                "httpOnly": True,
+                "secure": True,
+                "sameSite": "Lax",
+                "exp": now + (15 * 60),
+            }
+        ]
+    )
+
+    return Account(email_address=email_address, roles=user_roles)
+
+
+def _generate_email_address(
+    test_name: str,
+    email_domain: str,
+) -> str:
+    # Help disambiguate tests running around the same time by injecting a random token into the email, so that
+    # when we lookup the email it should be unique. We avoid a UUID so as to keep the emails 'short enough'.
+    token = secrets.token_urlsafe(8)
+    email_address = f"fsd-e2e-tests+{test_name}-{token}@{email_domain}".lower()
+
+    return email_address
diff --git a/tests/e2e/dataclass.py b/tests/e2e/dataclass.py
new file mode 100644
index 00000000..17d94614
--- /dev/null
+++ b/tests/e2e/dataclass.py
@@ -0,0 +1,13 @@
+from dataclasses import dataclass
+
+
+@dataclass
+class Account:
+    email_address: str
+    roles: list[str]
+
+
+@dataclass
+class FabDomains:
+    fab_url: str
+    cookie_domain: str
diff --git a/tests/e2e/features/test_e2e_add_new_grant_and_back_to_dashboard.py b/tests/e2e/features/test_e2e_add_new_grant_and_back_to_dashboard.py
new file mode 100644
index 00000000..1516ef0b
--- /dev/null
+++ b/tests/e2e/features/test_e2e_add_new_grant_and_back_to_dashboard.py
@@ -0,0 +1,29 @@
+from playwright.sync_api import Page
+
+from tests.e2e.dataclass import FabDomains
+from tests.e2e.pages.dashboard_page import DashboardPage
+from tests.e2e.pages.grants_page import GrantsPage
+
+
+# TC-002-[1]: Add new grant and back to dashboard
+def test_add_new_grant_from_dashboard_and_back_to_dashboard(page: Page, domains: FabDomains, user_auth):
+    (
+        DashboardPage(page, domains.fab_url)
+        .when_goto_dashboard()
+        .then_click_add_a_new_grant()
+        .then_fill_non_welsh_competitive_grant_details()
+        .then_click_save_and_return_home()
+        .and_validate_success_message()
+    )
+
+
+# TC-002-[2]: Add new grant from grant page
+def test_add_new_grant_from_grants_and_back_to_dashboard(page: Page, domains: FabDomains, user_auth):
+    (
+        GrantsPage(page, domains.fab_url)
+        .when_goto_grants()
+        .then_click_add_new_grant()
+        .then_fill_non_welsh_competitive_grant_details()
+        .then_click_save_and_return_home()
+        .and_validate_success_message()
+    )
diff --git a/tests/e2e/features/test_e2e_google.py b/tests/e2e/features/test_e2e_google.py
deleted file mode 100644
index 7abd2706..00000000
--- a/tests/e2e/features/test_e2e_google.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from playwright.sync_api import sync_playwright
-
-# TODO this test is generated to test the e2e frame work once tests implementing this will be removed
-
-
-def test_homepage():
-    with sync_playwright() as p:
-        browser = p.chromium.launch(headless=True)
-        page = browser.new_page()
-        page.goto("https://google.com")
-        assert page.title() == "Google"
-        browser.close()
diff --git a/tests/e2e/keys/rsa256/private.pem b/tests/e2e/keys/rsa256/private.pem
new file mode 100644
index 00000000..4c1ef58c
--- /dev/null
+++ b/tests/e2e/keys/rsa256/private.pem
@@ -0,0 +1,16 @@
+# This is public "private" key for testing only
+-----BEGIN RSA PRIVATE KEY-----
+MIICWwIBAAKBgHGbtF1yVGW+rCAFOIdakUVwCfuvxHE38lE/i0KWpMwdSHZFFLYn
+HjBVOOh15EiizYza4FTJTMvL2E4QrLpqYj6KE6tvHrhyP/N5fypSzt8vCj9spZ8+
+0kFucW9zyMkPuDisYtmkWGdxBmkd3gtYp3mOI53VVDgKbtoIFU3sIk5NAgMBAAEC
+gYBXIXrgXGocKnNqj3Z+YNifr8EIVhLMXoMrCxgsNssnfKHxiyPKXBAMM6BUsO4n
+Qy1whQGeJVEP0EQSAznmMucrWAYol+ve95LgXtrLEWPtetq/oU/boqcSFIY2jy45
+P0DqJ556D2kCMvYOzY/SnLqVUOM8KNOl5/I2851i1jmIIQJBALEOkKVnNwG4FHPj
+UUbQ/+MjB6rZBS9j5vtpszZvsv9kmgLzI3UNqtv7C0gtCY4Pg20+3Q93bTYLEUtJ
+sIXdxtkCQQCkQwzaPzwx84bRrsOwqQe82VtRSFtcOlz6Rhic+V9WXM+ZjCTCRsqZ
+T6rtcBkmsCIymEIDRbQe/+WJDT5fUuKVAkA0Y9rpFmFwYMesgtbJ3Y3Z58OdCho+
+q5DtU5lzwhl0+I+Zz9fuCt1DukTcVnc9UdnRuYgvy2bFVwEHBgb1lWoBAkAddnVY
+tBzs7LxS4eDxz++2XNo3Qx439bP1pBsIFOaXy//kj7GMMzxlsVd8TS4FtXP81TiJ
+87eyE74tDfYRDQHdAkEAhSbPlRcQSwEom57cFY3tWznRUM3ox+B56txlCjX1JpiX
+XP9m718FpBtSWq+4gs0yAoLujZyK9BoWvEmqrm6O8Q==
+-----END RSA PRIVATE KEY-----
diff --git a/tests/e2e/keys/rsa256/public.pem b/tests/e2e/keys/rsa256/public.pem
new file mode 100644
index 00000000..7388187b
--- /dev/null
+++ b/tests/e2e/keys/rsa256/public.pem
@@ -0,0 +1,7 @@
+# This is public key used for testing only
+-----BEGIN PUBLIC KEY-----
+MIGeMA0GCSqGSIb3DQEBAQUAA4GMADCBiAKBgHGbtF1yVGW+rCAFOIdakUVwCfuv
+xHE38lE/i0KWpMwdSHZFFLYnHjBVOOh15EiizYza4FTJTMvL2E4QrLpqYj6KE6tv
+HrhyP/N5fypSzt8vCj9spZ8+0kFucW9zyMkPuDisYtmkWGdxBmkd3gtYp3mOI53V
+VDgKbtoIFU3sIk5NAgMBAAE=
+-----END PUBLIC KEY-----
diff --git a/tests/e2e/pages/create_grant_page.py b/tests/e2e/pages/create_grant_page.py
new file mode 100644
index 00000000..8fea9a7e
--- /dev/null
+++ b/tests/e2e/pages/create_grant_page.py
@@ -0,0 +1,57 @@
+from typing import Literal
+
+from playwright.sync_api import Locator, Page
+
+from tests.e2e.pages.page_base import PageBase
+
+
+class CreateGrantPage(PageBase):
+    def __init__(self, page: Page, base_url: str = None):
+        super().__init__(page, base_url)
+        # Initialize locators
+        self.is_welsh: Locator = self.page.get_by_role("group", name="Is this grant available in Welsh?")
+        self.grant_name: Locator = self.page.get_by_role("textbox", name="Grant name", exact=True)
+        self.grant_short_name: Locator = self.page.get_by_role("textbox", name="Grant short name", exact=True)
+        self.application_name: Locator = self.page.get_by_role("textbox", name="Application name", exact=True)
+        self.grant_description: Locator = self.page.get_by_role("textbox", name="Grant description", exact=True)
+        self.grant_type: Locator = self.page.get_by_role("group", name="Funding type")
+        self.ggis_field: Locator = self.page.get_by_role("textbox", name="GGIS scheme reference number")
+
+        self.save_and_continue: Locator = self.page.get_by_role("button", name="Save and continue")
+        self.cancel: Locator = self.page.get_by_role("link", name="Cancel")
+        self.save_and_return_home: Locator = self.page.get_by_role("button", name="Save and return home")
+
+    def go_to_create_grant_page(self):
+        """Navigates to the Create Grant page and waits for it to load."""
+        if self.base_url:
+            self.page.goto(f"{self.base_url}/grants/create")
+        return self
+
+    def then_fill_non_welsh_competitive_grant_details(self):
+        """Fills in the grant form with fake competitive grant details."""
+        self._select_is_welsh("No")
+        self.grant_name.fill(self.fake.company())
+        self.grant_short_name.fill(self.fake.word()[:5])
+        self.application_name.fill(self.fake.bs())
+        self.grant_description.fill(self.fake.paragraph())
+        self._select_grant_type("COMPETITIVE")
+        self.ggis_field.fill(self.fake.uuid4())
+        return self
+
+    def then_click_save_and_return_home(self):
+        """Clicks the 'Save and return home' button."""
+        self.save_and_return_home.click()
+
+        from tests.e2e.pages.dashboard_page import DashboardPage
+
+        return DashboardPage(self.page)
+
+    def _select_grant_type(self, type: Literal["COMPETITIVE", "UNCOMPETED", "EOI"]):
+        """Selects the grant type."""
+        self.grant_type.get_by_role("radio", name=type).check()
+        return self
+
+    def _select_is_welsh(self, option: Literal["Yes", "No"]):
+        """Selects whether the grant is available in Welsh."""
+        self.is_welsh.get_by_role("radio", name=option).check()
+        return self
diff --git a/tests/e2e/pages/dashboard_page.py b/tests/e2e/pages/dashboard_page.py
new file mode 100644
index 00000000..c50e6565
--- /dev/null
+++ b/tests/e2e/pages/dashboard_page.py
@@ -0,0 +1,36 @@
+from playwright.sync_api import Page, expect
+
+from tests.e2e.pages.page_base import PageBase
+
+
+class DashboardPage(PageBase):
+    def __init__(self, page: Page, base_url: str = None):
+        super().__init__(page, base_url)
+        # Initialize locators
+        self.add_a_new_grant = self.page.get_by_role("link", name="1. Add a new grant")
+        self.setup_a_new_application = self.page.get_by_role("link", name="2. Set up a new application")
+        self.design_your_application = self.page.get_by_role("link", name="3. Design your application")
+        self.view_and_create_template = self.page.get_by_role("link", name="View and create templates")
+
+    def when_goto_dashboard(self):
+        """Navigates to the Dashboard page and waits for it to load."""
+        if self.base_url:
+            self.page.goto(f"{self.base_url}/dashboard")
+        return self
+
+    def then_click_add_a_new_grant(self):
+        """Clicks the 'Add a new grant' button."""
+        self.add_a_new_grant.click()
+
+        from tests.e2e.pages.create_grant_page import CreateGrantPage
+
+        return CreateGrantPage(self.page)
+
+    def and_validate_success_message(self):
+        """Validate the success message"""
+        banner = self.page.locator(".govuk-notification-banner--success")
+        expect(banner.get_by_role("heading", name="New grant added successfully")).to_be_visible()
+        expect(banner.locator("a")).to_have_count(2)
+        next_step_link = banner.get_by_role("link", name="Set up a new application")
+        expect(next_step_link).to_be_visible()
+        return self
diff --git a/tests/e2e/pages/grants_page.py b/tests/e2e/pages/grants_page.py
new file mode 100644
index 00000000..634466d1
--- /dev/null
+++ b/tests/e2e/pages/grants_page.py
@@ -0,0 +1,24 @@
+from playwright.sync_api import Page
+
+from tests.e2e.pages.page_base import PageBase
+
+
+class GrantsPage(PageBase):
+    def __init__(self, page: Page, base_url: str = None):
+        super().__init__(page, base_url)
+        # Initialize locators
+        self.add_new_grant = self.page.get_by_role("button", name="Add new grant")
+
+    def when_goto_grants(self):
+        """Navigates to the Grants page and waits for it to load."""
+        if self.base_url:
+            self.page.goto(f"{self.base_url}/grants")
+        return self
+
+    def then_click_add_new_grant(self):
+        """Clicks the 'Add new grant' button."""
+        self.add_new_grant.click()
+
+        from tests.e2e.pages.create_grant_page import CreateGrantPage
+
+        return CreateGrantPage(self.page)
diff --git a/tests/e2e/pages/page_base.py b/tests/e2e/pages/page_base.py
new file mode 100644
index 00000000..a2cc4f6d
--- /dev/null
+++ b/tests/e2e/pages/page_base.py
@@ -0,0 +1,9 @@
+from faker import Faker
+from playwright.sync_api import Page
+
+
+class PageBase:
+    def __init__(self, page: Page, base_url: str):
+        self.page = page
+        self.base_url = base_url
+        self.fake = Faker()
diff --git a/uv.lock b/uv.lock
index d914a87f..d7fdaa81 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,4 @@
 version = 1
-revision = 1
 requires-python = ">=3.10.0, <3.11"
 resolution-markers = [
     "(platform_machine == 'AMD64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32') or (platform_machine == 'WIN32' and platform_python_implementation != 'PyPy' and sys_platform == 'win32') or (platform_machine == 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32') or (platform_machine == 'amd64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32') or (platform_machine == 'ppc64le' and platform_python_implementation != 'PyPy' and sys_platform == 'win32') or (platform_machine == 'win32' and platform_python_implementation != 'PyPy' and sys_platform == 'win32') or (platform_machine == 'x86_64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32')",
@@ -105,7 +104,7 @@ source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "jmespath" },
     { name = "python-dateutil" },
-    { name = "urllib3" },
+    { name = "urllib3", marker = "python_full_version == '3.10.*'" },
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/8c/66/01d63edf404b2ef2c5594701565ac0c031ce7253231298d423e2514566b8/botocore-1.34.144.tar.gz", hash = "sha256:4215db28d25309d59c99507f1f77df9089e5bebbad35f6e19c7c44ec5383a3e8", size = 12618703 }
 wheels = [
@@ -181,7 +180,7 @@ name = "click"
 version = "8.1.7"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
-    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "colorama", marker = "platform_system == 'Windows'" },
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 }
 wheels = [
@@ -294,9 +293,9 @@ dependencies = [
     { name = "pathspec" },
     { name = "pyyaml" },
     { name = "regex" },
-    { name = "tomli" },
+    { name = "tomli", marker = "python_full_version == '3.10.*'" },
     { name = "tqdm" },
-    { name = "typing-extensions" },
+    { name = "typing-extensions", marker = "python_full_version == '3.10.*'" },
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/74/89/ecf5be9f5c59a0c53bcaa29671742c5e269cc7d0e2622e3f65f41df251bf/djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1", size = 47849 }
 wheels = [
@@ -323,12 +322,15 @@ wheels = [
 ]
 
 [[package]]
-name = "execnet"
-version = "2.1.1"
+name = "faker"
+version = "36.1.1"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524 }
+dependencies = [
+    { name = "tzdata" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/8f/40d002bed58bd6b79bf970505582b769fc975afcacc62c2fe1518d5729c2/faker-36.1.1.tar.gz", hash = "sha256:7cb2bbd4c8f040e4a340ae4019e9a48b6cf1db6a71bda4e5a61d8d13b7bef28d", size = 1874935 }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612 },
+    { url = "https://files.pythonhosted.org/packages/65/79/e13ae542f63ce40d02b0fe63809563b102f19ffa3b94e6062ee9286a7801/Faker-36.1.1-py3-none-any.whl", hash = "sha256:ad1f1be7fd692ec0256517404a9d7f007ab36ac5d4674082fa72404049725eaa", size = 1917865 },
 ]
 
 [[package]]
@@ -473,6 +475,7 @@ dependencies = [
 dev = [
     { name = "debugpy" },
     { name = "djlint" },
+    { name = "faker" },
     { name = "invoke" },
     { name = "pre-commit" },
     { name = "pytest" },
@@ -480,7 +483,6 @@ dev = [
     { name = "pytest-html" },
     { name = "pytest-mock" },
     { name = "pytest-playwright" },
-    { name = "pytest-xdist" },
     { name = "python-dotenv" },
     { name = "ruff" },
 ]
@@ -509,6 +511,7 @@ requires-dist = [
 dev = [
     { name = "debugpy", specifier = "==1.8.12" },
     { name = "djlint", specifier = "==1.36.4" },
+    { name = "faker", specifier = "==36.1.1" },
     { name = "invoke", specifier = "==2.2.0" },
     { name = "pre-commit", specifier = "==4.1.0" },
     { name = "pytest", specifier = "==8.3.5" },
@@ -516,7 +519,6 @@ dev = [
     { name = "pytest-html", specifier = "==4.1.1" },
     { name = "pytest-mock", specifier = "==3.14.0" },
     { name = "pytest-playwright", specifier = "==0.7.0" },
-    { name = "pytest-xdist", specifier = "==3.6.1" },
     { name = "python-dotenv", specifier = "==1.0.1" },
     { name = "ruff", specifier = "==0.9.9" },
 ]
@@ -755,7 +757,7 @@ version = "1.14.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "mypy-extensions" },
-    { name = "tomli" },
+    { name = "tomli", marker = "python_full_version == '3.10.*'" },
     { name = "typing-extensions" },
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/b9/eb/2c92d8ea1e684440f54fa49ac5d9a5f19967b7b472a281f419e69a8d228e/mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6", size = 3216051 }
@@ -927,11 +929,11 @@ version = "8.3.5"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "colorama", marker = "sys_platform == 'win32'" },
-    { name = "exceptiongroup" },
+    { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
     { name = "iniconfig" },
     { name = "packaging" },
     { name = "pluggy" },
-    { name = "tomli" },
+    { name = "tomli", marker = "python_full_version == '3.10.*'" },
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 }
 wheels = [
@@ -957,7 +959,7 @@ version = "1.1.5"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "pytest" },
-    { name = "tomli" },
+    { name = "tomli", marker = "python_full_version == '3.10.*'" },
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/1f/31/27f28431a16b83cab7a636dce59cf397517807d247caa38ee67d65e71ef8/pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf", size = 8911 }
 wheels = [
@@ -1017,19 +1019,6 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/d8/96/5f8a4545d783674f3de33f0ebc4db16cc76ce77a4c404d284f43f09125e3/pytest_playwright-0.7.0-py3-none-any.whl", hash = "sha256:2516d0871fa606634bfe32afbcc0342d68da2dbff97fe3459849e9c428486da2", size = 16618 },
 ]
 
-[[package]]
-name = "pytest-xdist"
-version = "3.6.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "execnet" },
-    { name = "pytest" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/41/c4/3c310a19bc1f1e9ef50075582652673ef2bfc8cd62afef9585683821902f/pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d", size = 84060 }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108 },
-]
-
 [[package]]
 name = "python-dateutil"
 version = "2.9.0.post0"
@@ -1102,7 +1091,7 @@ name = "redis"
 version = "4.6.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
-    { name = "async-timeout" },
+    { name = "async-timeout", marker = "python_full_version == '3.10.*'" },
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/73/88/63d802c2b18dd9eaa5b846cbf18917c6b2882f20efda398cc16a7500b02c/redis-4.6.0.tar.gz", hash = "sha256:585dc516b9eb042a619ef0a39c3d7d55fe81bdb4df09a52c9cdde0d07bf1aa7d", size = 4561721 }
 wheels = [
@@ -1287,7 +1276,7 @@ name = "sqlalchemy"
 version = "2.0.38"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
-    { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
+    { name = "greenlet", marker = "(platform_machine == 'AMD64' and python_full_version == '3.10.*') or (platform_machine == 'WIN32' and python_full_version == '3.10.*') or (platform_machine == 'aarch64' and python_full_version == '3.10.*') or (platform_machine == 'amd64' and python_full_version == '3.10.*') or (platform_machine == 'ppc64le' and python_full_version == '3.10.*') or (platform_machine == 'win32' and python_full_version == '3.10.*') or (platform_machine == 'x86_64' and python_full_version == '3.10.*')" },
     { name = "typing-extensions" },
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/e4/08/9a90962ea72acd532bda71249a626344d855c4032603924b1b547694b837/sqlalchemy-2.0.38.tar.gz", hash = "sha256:e5a4d82bdb4bf1ac1285a68eab02d253ab73355d9f0fe725a97e1e0fa689decb", size = 9634782 }
@@ -1343,7 +1332,7 @@ name = "tqdm"
 version = "4.66.4"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
-    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "colorama", marker = "platform_system == 'Windows'" },
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/5a/c0/b7599d6e13fe0844b0cda01b9aaef9a0e87dbb10b06e4ee255d3fa1c79a2/tqdm-4.66.4.tar.gz", hash = "sha256:e4d936c9de8727928f3be6079590e97d9abfe8d39a590be678eb5919ffc186bb", size = 169392 }
 wheels = [
@@ -1359,6 +1348,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
 ]
 
+[[package]]
+name = "tzdata"
+version = "2025.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/0f/fa4723f22942480be4ca9527bbde8d43f6c3f2fe8412f00e7f5f6746bc8b/tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", size = 194950 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 },
+]
+
 [[package]]
 name = "urllib3"
 version = "2.2.2"


Reviews:
user: login: samuelhwilliams
body: I have stopped reviewing as the test doesn't work for me locally. Please ping me again when fixed.

Also - I am unable to run this test against the `dev` environment. Please can you check and fix the use of the `--e2e-env` flag if needed 🙏 
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T10:27:21Z
user: login: nuwan-samarasinghe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T10:37:50Z
user: login: nuwan-samarasinghe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T10:38:14Z
user: login: samuelhwilliams
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T10:40:07Z
user: login: nuwan-samarasinghe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T10:40:24Z
user: login: nuwan-samarasinghe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T09:24:38Z
user: login: samuelhwilliams
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T10:19:59Z
user: login: samuelhwilliams
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T10:20:53Z
user: login: nuwan-samarasinghe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T10:22:03Z
user: login: nuwan-samarasinghe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T10:25:04Z
user: login: samuelhwilliams
body: Very nice - these look great! Good work 🙇  I love how readable the test cases are - good approach.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T10:31:33Z

Review comments:
diff_hunk: @@ -0,0 +1,32 @@
+from playwright.sync_api import Page
+
+from tests.e2e.pages.create_grant_page import CreateGrantPage
+from tests.e2e.pages.dashboard_page import DashboardPage
+from tests.e2e.pages.grants_page import GrantsPage
+
+
+# TC-002-[1]: Add new grant and back to dashboard
+def test_add_new_grant_from_dashboard_and_back_to_dashboard(page: Page, domains: str):
+    dashboard = DashboardPage(page, domains)
+    dashboard.when_goto_dashboard()
+    dashboard.then_load_dashboard()
+    dashboard.then_click_add_a_new_grant()
+    create_grant = CreateGrantPage(page)
+    create_grant.then_load_create_grant_page()
+    create_grant.then_fill_non_welsh_competitive_grant_details()
+    create_grant.then_click_save_and_return_home()
+    dashboard.finally_validate_success_message()
path: tests/e2e/features/test_e2e_add_new_grant_and_back_to_dashboard.py
user: login: samuelhwilliams
body: Nice work on the method names - nice and readable 👍 

I'm unsure about the `finally` method prefix. It feels like that reduces the reusability? Maybe we should just stick to when/then?
created_at: 2025-03-03T10:22:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,32 @@
+from playwright.sync_api import Page
+
+from tests.e2e.pages.create_grant_page import CreateGrantPage
+from tests.e2e.pages.dashboard_page import DashboardPage
+from tests.e2e.pages.grants_page import GrantsPage
+
+
+# TC-002-[1]: Add new grant and back to dashboard
+def test_add_new_grant_from_dashboard_and_back_to_dashboard(page: Page, domains: str):
+    dashboard = DashboardPage(page, domains)
+    dashboard.when_goto_dashboard()
+    dashboard.then_load_dashboard()
+    dashboard.then_click_add_a_new_grant()
+    create_grant = CreateGrantPage(page)
+    create_grant.then_load_create_grant_page()
+    create_grant.then_fill_non_welsh_competitive_grant_details()
path: tests/e2e/features/test_e2e_add_new_grant_and_back_to_dashboard.py
user: login: samuelhwilliams
body: I think I would like to see `then_click_add_a_new_grant` return an instance of the create grant page? Since a user will end up on the create grant page after clicking 'add a new grant', it doesn't feel representative for the test to manually load the grant page.

I think we could end up with this?

```suggestion
    create_grant = dashboard.then_click_add_a_new_grant()
    create_grant.then_fill_non_welsh_competitive_grant_details()
```

And apply the same principle to any other actions which cause the user to change page. What do you think?
created_at: 2025-03-03T10:23:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,56 @@
+from typing import Literal
+
+from playwright.sync_api import Locator, Page
+
+from tests.e2e.pages.page_base import PageBase
+
+
+class CreateGrantPage(PageBase):
+    is_welsh: Locator
+    grant_name: Locator
+    grant_short_name: Locator
+    application_name: Locator
+    grant_description: Locator
+    grant_type: Locator
+    ggis_field: Locator
+    save_and_continue: Locator
+    cancel: Locator
+    save_and_return_home: Locator
+
+    def __init__(self, page: Page, base_url: str = None):
+        super().__init__(page, base_url)
+
+    def when_goto_create_grant_page(self):
+        if self.base_url:
+            self.page.goto(f"{self.base_url}/grants/create")
+
+    def then_load_create_grant_page(self):
+        self.is_welsh = self.page.get_by_role("group", name="Is this grant available in Welsh?")
+        self.grant_name = self.page.get_by_role("textbox", name="Grant name")
path: tests/e2e/pages/create_grant_page.py
user: login: samuelhwilliams
body: Does this pass for you locally? I'm getting errors at this step because it also matches `Grant name (Welsh)`.

<img width="1624" alt="image" src="https://github.com/user-attachments/assets/67cf8f63-47d7-459d-b400-054361864094" />

created_at: 2025-03-03T10:26:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,56 @@
+from typing import Literal
+
+from playwright.sync_api import Locator, Page
+
+from tests.e2e.pages.page_base import PageBase
+
+
+class CreateGrantPage(PageBase):
+    is_welsh: Locator
+    grant_name: Locator
+    grant_short_name: Locator
+    application_name: Locator
+    grant_description: Locator
+    grant_type: Locator
+    ggis_field: Locator
+    save_and_continue: Locator
+    cancel: Locator
+    save_and_return_home: Locator
+
+    def __init__(self, page: Page, base_url: str = None):
+        super().__init__(page, base_url)
+
+    def when_goto_create_grant_page(self):
+        if self.base_url:
+            self.page.goto(f"{self.base_url}/grants/create")
+
+    def then_load_create_grant_page(self):
+        self.is_welsh = self.page.get_by_role("group", name="Is this grant available in Welsh?")
+        self.grant_name = self.page.get_by_role("textbox", name="Grant name")
path: tests/e2e/pages/create_grant_page.py
user: login: nuwan-samarasinghe
body: It was passing in my local let me check again is your browser has JS enabled ?
created_at: 2025-03-03T10:37:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,32 @@
+from playwright.sync_api import Page
+
+from tests.e2e.pages.create_grant_page import CreateGrantPage
+from tests.e2e.pages.dashboard_page import DashboardPage
+from tests.e2e.pages.grants_page import GrantsPage
+
+
+# TC-002-[1]: Add new grant and back to dashboard
+def test_add_new_grant_from_dashboard_and_back_to_dashboard(page: Page, domains: str):
+    dashboard = DashboardPage(page, domains)
+    dashboard.when_goto_dashboard()
+    dashboard.then_load_dashboard()
+    dashboard.then_click_add_a_new_grant()
+    create_grant = CreateGrantPage(page)
+    create_grant.then_load_create_grant_page()
+    create_grant.then_fill_non_welsh_competitive_grant_details()
+    create_grant.then_click_save_and_return_home()
+    dashboard.finally_validate_success_message()
path: tests/e2e/features/test_e2e_add_new_grant_and_back_to_dashboard.py
user: login: nuwan-samarasinghe
body: fair comment let me change it 
created_at: 2025-03-03T10:38:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,56 @@
+from typing import Literal
+
+from playwright.sync_api import Locator, Page
+
+from tests.e2e.pages.page_base import PageBase
+
+
+class CreateGrantPage(PageBase):
+    is_welsh: Locator
+    grant_name: Locator
+    grant_short_name: Locator
+    application_name: Locator
+    grant_description: Locator
+    grant_type: Locator
+    ggis_field: Locator
+    save_and_continue: Locator
+    cancel: Locator
+    save_and_return_home: Locator
+
+    def __init__(self, page: Page, base_url: str = None):
+        super().__init__(page, base_url)
+
+    def when_goto_create_grant_page(self):
+        if self.base_url:
+            self.page.goto(f"{self.base_url}/grants/create")
+
+    def then_load_create_grant_page(self):
+        self.is_welsh = self.page.get_by_role("group", name="Is this grant available in Welsh?")
+        self.grant_name = self.page.get_by_role("textbox", name="Grant name")
path: tests/e2e/pages/create_grant_page.py
user: login: samuelhwilliams
body: Yes, it's a normal browser running like normal playwright.

I think that you can get away with adding `exact=True` to the grant_name attribute, since we know that two inputs have slightly overlapping labels.
created_at: 2025-03-03T10:40:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,32 @@
+from playwright.sync_api import Page
+
+from tests.e2e.pages.create_grant_page import CreateGrantPage
+from tests.e2e.pages.dashboard_page import DashboardPage
+from tests.e2e.pages.grants_page import GrantsPage
+
+
+# TC-002-[1]: Add new grant and back to dashboard
+def test_add_new_grant_from_dashboard_and_back_to_dashboard(page: Page, domains: str):
+    dashboard = DashboardPage(page, domains)
+    dashboard.when_goto_dashboard()
+    dashboard.then_load_dashboard()
+    dashboard.then_click_add_a_new_grant()
+    create_grant = CreateGrantPage(page)
+    create_grant.then_load_create_grant_page()
+    create_grant.then_fill_non_welsh_competitive_grant_details()
path: tests/e2e/features/test_e2e_add_new_grant_and_back_to_dashboard.py
user: login: nuwan-samarasinghe
body: We don't load the grant page we just pick the elements on that method let me rename that method which represent work it does  
created_at: 2025-03-03T10:40:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,56 @@
+from typing import Literal
+
+from playwright.sync_api import Locator, Page
+
+from tests.e2e.pages.page_base import PageBase
+
+
+class CreateGrantPage(PageBase):
+    is_welsh: Locator
+    grant_name: Locator
+    grant_short_name: Locator
+    application_name: Locator
+    grant_description: Locator
+    grant_type: Locator
+    ggis_field: Locator
+    save_and_continue: Locator
+    cancel: Locator
+    save_and_return_home: Locator
+
+    def __init__(self, page: Page, base_url: str = None):
+        super().__init__(page, base_url)
+
+    def when_goto_create_grant_page(self):
+        if self.base_url:
+            self.page.goto(f"{self.base_url}/grants/create")
+
+    def then_load_create_grant_page(self):
+        self.is_welsh = self.page.get_by_role("group", name="Is this grant available in Welsh?")
+        self.grant_name = self.page.get_by_role("textbox", name="Grant name")
path: tests/e2e/pages/create_grant_page.py
user: login: nuwan-samarasinghe
body: Fixed
created_at: 2025-03-04T09:24:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+from playwright.sync_api import Page, expect
+
+from tests.e2e.pages.page_base import PageBase
+
+
+class DashboardPage(PageBase):
+    def __init__(self, page: Page, base_url: str = None):
+        super().__init__(page, base_url)
+        # Initialize locators
+        self.add_a_new_grant = self.page.get_by_role("link", name="1. Add a new grant")
+        self.setup_a_new_application = self.page.get_by_role("link", name="2. Set up a new application")
+        self.design_your_application = self.page.get_by_role("link", name="3. Design your application")
+        self.view_and_create_template = self.page.get_by_role("link", name="View and create templates")
+
+    def when_goto_dashboard(self):
+        """Navigates to the Dashboard page and waits for it to load."""
+        if self.base_url:
+            self.page.goto(f"{self.base_url}/dashboard")
+            self.page.wait_for_load_state("networkidle")
path: tests/e2e/pages/dashboard_page.py
user: login: samuelhwilliams
body: I don't _think_ we need these, but there's also probably no harm?

Did you hit some errors that made you add this, or is it just a belt-and-braces approach?
created_at: 2025-03-04T10:19:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,58 @@
+from typing import Literal
+
+from playwright.sync_api import Locator, Page
+
+from tests.e2e.pages.page_base import PageBase
+
+
+class CreateGrantPage(PageBase):
+    def __init__(self, page: Page, base_url: str = None):
+        super().__init__(page, base_url)
+        # Initialize locators
+        self.is_welsh: Locator = self.page.get_by_role("group", name="Is this grant available in Welsh?")
+        self.grant_name: Locator = self.page.get_by_role("textbox", name="Grant name", exact=True)
+        self.grant_short_name: Locator = self.page.get_by_role("textbox", name="Grant short name")
+        self.application_name: Locator = self.page.get_by_role("textbox", name="Application name")
path: tests/e2e/pages/create_grant_page.py
user: login: samuelhwilliams
body: It may be useful to add the `exact=True` here again, as I think there's an `Application name (Welsh)` field - so this text overlaps?
created_at: 2025-03-04T10:20:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+from playwright.sync_api import Page, expect
+
+from tests.e2e.pages.page_base import PageBase
+
+
+class DashboardPage(PageBase):
+    def __init__(self, page: Page, base_url: str = None):
+        super().__init__(page, base_url)
+        # Initialize locators
+        self.add_a_new_grant = self.page.get_by_role("link", name="1. Add a new grant")
+        self.setup_a_new_application = self.page.get_by_role("link", name="2. Set up a new application")
+        self.design_your_application = self.page.get_by_role("link", name="3. Design your application")
+        self.view_and_create_template = self.page.get_by_role("link", name="View and create templates")
+
+    def when_goto_dashboard(self):
+        """Navigates to the Dashboard page and waits for it to load."""
+        if self.base_url:
+            self.page.goto(f"{self.base_url}/dashboard")
+            self.page.wait_for_load_state("networkidle")
path: tests/e2e/pages/dashboard_page.py
user: login: nuwan-samarasinghe
body: no I saw in the documentation https://playwright.dev/docs/api/class-page seems I added it instead of further reading it 

`'networkidle' - DISCOURAGED consider operation to be finished when there are no network connections for at least 500 ms. Don't use this method for testing, rely on web assertions to assess readiness instead.`

let me remove 
created_at: 2025-03-04T10:22:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,58 @@
+from typing import Literal
+
+from playwright.sync_api import Locator, Page
+
+from tests.e2e.pages.page_base import PageBase
+
+
+class CreateGrantPage(PageBase):
+    def __init__(self, page: Page, base_url: str = None):
+        super().__init__(page, base_url)
+        # Initialize locators
+        self.is_welsh: Locator = self.page.get_by_role("group", name="Is this grant available in Welsh?")
+        self.grant_name: Locator = self.page.get_by_role("textbox", name="Grant name", exact=True)
+        self.grant_short_name: Locator = self.page.get_by_role("textbox", name="Grant short name")
+        self.application_name: Locator = self.page.get_by_role("textbox", name="Application name")
path: tests/e2e/pages/create_grant_page.py
user: login: nuwan-samarasinghe
body: Fixed
created_at: 2025-03-04T10:25:03Z
author_association: CONTRIBUTOR

PR comments:
user: login: nuwan-samarasinghe
created_at: 2025-03-03T10:37:19Z
body: @samuelhwilliams yes it runs against dev but few changes needed to do I have not added authentication so let me do it but it runs against dev 

@samuelhwilliams have you logged into the dev aws profile ?

<img width="1167" alt="image" src="https://github.com/user-attachments/assets/9f188e49-9049-43c9-8dea-4774d1adeb7a" />

again their is an issue while runnign need to add auth let me do it first then accordingly address comments  
author_association: CONTRIBUTOR
user: login: samuelhwilliams
created_at: 2025-03-03T10:39:29Z
body: Cool, once you've made the auth changes and it works, let me know :)

Yes, I ran `uv run pytest --e2e --e2e-env dev --e2e-aws-vault-profile fsd-dev`. I get the aws-vault prompt so that part all seems fine. The tests start but end up hanging at this page:

<img width="1280" alt="image" src="https://github.com/user-attachments/assets/22e66482-14ae-4d91-af51-5b1b08d543b6" />

So yes, I think you need to pull in the auth workarounds 👍 
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-03-04T10:25:31Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=communitiesuk_funding-service-design-fund-application-builder&pullRequest=305) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=communitiesuk_funding-service-design-fund-application-builder&pullRequest=305&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=communitiesuk_funding-service-design-fund-application-builder&pullRequest=305&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=communitiesuk_funding-service-design-fund-application-builder&pullRequest=305&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=communitiesuk_funding-service-design-fund-application-builder&pullRequest=305&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=communitiesuk_funding-service-design-fund-application-builder&pullRequest=305&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=communitiesuk_funding-service-design-fund-application-builder&pullRequest=305)


author_association: NONE