diff --git a/.gitignore b/.gitignore index 245807d..e255e4b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Virtual environments venv/ .venv/ +.venv-playwright/ +reports/playwright/ +test-results/ # Python cache __pycache__/ diff --git a/PLAYWRIGHT_MIGRATION.md b/PLAYWRIGHT_MIGRATION.md new file mode 100644 index 0000000..da360af --- /dev/null +++ b/PLAYWRIGHT_MIGRATION.md @@ -0,0 +1,82 @@ +# Playwright migration review + +## Preserved Selenium baseline + +- Reviewed on 2026-09-08; working tree was clean before this document was added. +- Current branch: `development`. +- Backup branch: `backup/selenium-working-2026-09-08`, created locally and pushed to `origin`. +- Baseline commit: `4749efe45f47587e6d6aec228a4818576cb666d9`. +- HEAD, the local backup, and its remote-tracking reference were verified to match. +- The backup preserves tracked source, configuration, and committed input files. Ignored virtual environments and generated reports are not included. +- No browser tests were executed during this review; working behavior is the user's reported baseline. + +To return to the baseline, first commit or stash any migration changes, then use: + +```powershell +git switch backup/selenium-working-2026-09-08 +``` + +Keep the backup branch unchanged. Start implementation on a separate branch such as `migration/playwright-python`, created from `development`. + +## Recommended stack + +Keep Python, pytest, pytest-html, existing markers, and JSON/Excel inputs. Use Playwright's synchronous Python API with the official pytest-playwright plugin. This retains the existing business logic and test runner while changing the browser layer. A TypeScript rewrite would also require porting the data readers, configuration, and reporting, without a demonstrated need here. + +Start with headed Chromium because exchange and trading-member logins currently require manual CAPTCHA input. Select a compatible dependency set, verify it in a separate virtual environment, and pin the validated versions. Install Chromium through Playwright as well as installing the Python packages. + +References: [installation](https://playwright.dev/python/docs/intro), [pytest fixtures and options](https://playwright.dev/python/docs/test-runners). + +## Repository-specific changes + +| Existing area | Recommended migration | +| --- | --- | +| `tests/browser.py`, `tests/conftest.py` | Replace driver creation with plugin-managed browser/context/page fixtures for migrated tests. Keep Selenium's driver fixture while both suites coexist. Start with one isolated context per test. | +| `tests/step_actions.py`, `tests/reporting.py` | Preserve step names and report metadata; use `page.screenshot(path=...)`. Adapt the failure hook, which currently only looks for `driver`. Add failure traces and include their paths in reports. | +| `tests/helpers.py`, `tests/regression/auth.py` | Port setup, exchange, trading-member, and MCO login separately. Wait for a successful authenticated page. Replace fixed CAPTCHA sleep with a bounded wait for manual entry and then verify login. Playwright does not remove the CAPTCHA requirement. | +| `tests/locators.py`, `trading_locators.py` | Keep stable IDs initially. Convert Selenium By tuples into Playwright locators. Scope broad XPath matches to the active form, modal, or grid to avoid ambiguous matches. | +| `tests/regression/ui_helpers.py` | Rewrite Bootstrap and Select2 interactions around the visible dropdown and exact option. Select2 is a custom widget; native `select_option()` is appropriate only for actual select elements. Preserve code-to-label matching deliberately. | +| `bidding/grid_actions.py` | Port the existing Handsontable integration through `page.evaluate()` if needed. Separate waiting for the instance from mutating it. Validate the complete matrix and submitted outcome; the present helper only checks the first displayed value after populating. | +| `bidding/upload_actions.py`, `folder_upload_actions.py`, `block_bulk_upload_actions.py` | Use `set_input_files()` for file inputs; use directory input support where the actual element supports directory selection. Preserve progress, per-file status, logs, and failure handling. Register expected confirmation-dialog handling before the triggering action. | +| `matching/actions.py` | Replace CDP download configuration and directory polling with an `expect_download()` block around the export action, then save the download with the existing source-specific naming convention. Preserve business-status polling and timeouts. | +| `matching/bid_transfer_actions.py` | Preserve request/status correlation and explicit rejection handling. Browser action readiness is not proof of successful bid transfer. | +| `tests/regression/config_reader.py`, `tests/config_reader.py`, input manifests, JSON/Excel readers | Reuse browser-independent logic. Keep configuration schema and defaults stable during initial migration. | +| `tests/file_generation.py` | Retain payload-generation logic and audit its separate dependency requirements when preparing the new environment. | + +Playwright auto-waits for action readiness and offers retrying assertions. Replace UI sleeps with visible/enabled/value/status assertions, but retain intentional business polling intervals. Avoid routinely replacing JavaScript click fallbacks with forced clicks; inspect overlays and locator scope first. + +References: [auto-waiting](https://playwright.dev/python/docs/actionability), [inputs and uploads](https://playwright.dev/python/docs/input), [downloads](https://playwright.dev/python/docs/downloads). + +## Parallel upload design + +`test_idas_exchange_bidding_flow.py` currently creates separate Chrome drivers in ThreadPoolExecutor workers and synchronizes each batch with a Barrier. It also assigns accounts per TM and staggers logins. + +First establish sequential Playwright upload parity. Do not share a Playwright instance, browser, context, or page across those threads: the Python API is not thread-safe. If retaining threads temporarily, each worker must create and close its own Playwright instance and browser. + +For independent scenarios, consider pytest-xdist processes with separate accounts and data. For coordinated uploads that must begin together, use a dedicated async orchestration flow with separate contexts and an async barrier/event mechanism; ordinary test parallelism does not preserve the existing start barrier. Keep the rest of the suite synchronous. Preserve barrier cancellation when a worker fails. + +Before concurrency, replace the current scan-and-increment report run numbering with collision-resistant run/worker identifiers. Preserve account-to-TM assignment and validate server-side session restrictions; browser-context isolation alone does not prevent account conflicts. + +Reference: [Python library threading guidance](https://playwright.dev/python/docs/library). + +## Suggested implementation sequence + +1. Create the migration branch and an isolated Playwright environment. Add a separate `tests/playwright/` tree with scoped fixtures and explicit run commands so migration tests are not accidentally executed alongside the old suite. Keep shared data utilities browser-independent. +2. Port `test_idas_mco_login.py` as the small pilot: credentials, navigation, dashboard assertion, screenshot reporting, and failure trace. Then port exchange and trading-member login with their manual CAPTCHA steps. +3. Port session creation, token activation, and combined setup. Preserve configuration-controlled skips and verify the created session/token state rather than relying on screenshots alone. Root session/token test files are skip-only compatibility stubs; migrate the implementations under `tests/setup/`. +4. Port one single-bid flow, including Select2 filters, market-specific input matrix, submission, and observable success. Then cover each configured market and both login roles. +5. Port individual file upload, batch upload, folder upload, and block bulk upload sequentially. Compare file counts, progress completion, statuses, and generated logs against the Selenium behavior. +6. Port transfer and matching. Preserve rejection-stop behavior, provisional matching checks, flow generation, and exported files for Green, Conventional, and HPDAM. Validate downloaded content as well as file presence. +7. Add parallel upload support using the design above. Verify distinct sessions, coordinated start if required, failure cleanup, and distinct artifacts. +8. Revisit fake-activity/manual-run utilities, update `RUN_COMMANDS.md`, and remove Selenium dependencies only after required flows pass. Order-book, trade-book, and reports directories contain scaffolding; define new coverage separately from migration parity. + +During coexistence, run each suite using explicit paths. Preserve the existing combined-setup behavior; do not assume pytest test ordering will create prerequisites. Avoid automatic retries for state-changing bid/session/matching actions unless duplicate submissions are handled. + +## Completion criteria + +- Validate both implementations against controlled equivalent test data; running each against the same mutable state back-to-back can itself change the result. +- Confirm session/token state, submitted bid data, upload success and failure outcomes, transfer rejection behavior, matching status, and downloaded content. +- Confirm screenshots, logs, failure traces, manual CAPTCHA flow, and browser cleanup. +- Require repeatable passing runs for every required configured flow before making Playwright the default. +- Keep generated authentication state and traces out of Git; these can contain login/session data. Preserve the frozen backup without editing its historical configuration. + +This document is a migration proposal. No Selenium implementation or dependency files have been changed. diff --git a/RUN_COMMANDS.md b/RUN_COMMANDS.md index d14feb3..492baa5 100644 --- a/RUN_COMMANDS.md +++ b/RUN_COMMANDS.md @@ -384,3 +384,8 @@ pytest tests\regression -m reports -s --html=reports/html/reports_run.html --sel pytest tests\regression -m order_book -s --html=reports/html/order_book_run.html --self-contained-html pytest tests\regression -m trade_book -s --html=reports/html/trade_book_run.html --self-contained-html ``` +# Playwright session/token setup + +See [Playwright setup commands](playwright_tests/README.md) for login, session creation, +token creation/activation, and combined setup on utilseastern. Use `-c playwright.ini` +to select the new suite; commands below continue to describe the Selenium suite. diff --git a/config.json b/config.json index 9811e74..1677e83 100644 --- a/config.json +++ b/config.json @@ -1,5 +1,5 @@ { - "url": "https://utilsuat.pxil.in/Pxil/", + "url": "https://utilseastern.pxil.in/Pxil/", "stagingurl": "https://utilseastern.pxil.in/Pxil/", "uaturl": "https://utilsuat.pxil.in/Pxil/", "login": { @@ -24,22 +24,22 @@ "market_code": "NATIONAL", "product_type": "IDAS", "product_code": "IDAS4", - "product_codes": "IDAS,IDASTEST01,IDASTEST02,IDASTEST03,IDASTEST04,IDASTEST05,IDASTEST06", - "available_codes":",IDASTEST06,IDASTEST07,IDASTEST08,IDASTEST09,IDASTEST10,IDASTEST11,IDASTEST12,IDASTEST13,IDASTEST14,IDASTEST15,IDASTEST16,IDASTEST17,IDASTEST18,IDASTEST19,IDASTEST20,IDASTEST21,IDASTEST22,IDASTEST23,IDASTEST24,IDASTEST25,IDASTEST26,IDASTEST27,IDASTEST28,IDASTEST29,IDASTEST30", + "product_codes": "IDAS3", + "available_codes":",IDASTEST01,IDASTEST02,IDASTEST03,IDASTEST04,IDASTEST05,IDASTEST06,IDASTEST06,IDASTEST07,IDASTEST08,IDASTEST09,IDASTEST10,IDASTEST11,IDASTEST12,IDASTEST13,IDASTEST14,IDASTEST15,IDASTEST16,IDASTEST17,IDASTEST18,IDASTEST19,IDASTEST20,IDASTEST21,IDASTEST22,IDASTEST23,IDASTEST24,IDASTEST25,IDASTEST26,IDASTEST27,IDASTEST28,IDASTEST29,IDASTEST30", "delivery_offset_days": 1, - "expiry_time": "20:30", + "expiry_time": "22:30", "time_sessions": { "ORDER_NOR": { "start_offset_minutes": 5, - "end_time": "19:30" + "end_time": "21:30" }, "ORDER_EXCH": { - "start_time": "19:30", - "end_time": "19:45" + "start_time": "21:30", + "end_time": "22:45" }, "TRADE": { - "start_time": "19:45", - "end_time": "19:50" + "start_time": "22:45", + "end_time": "22:50" } } } diff --git a/playwright.ini b/playwright.ini new file mode 100644 index 0000000..1306830 --- /dev/null +++ b/playwright.ini @@ -0,0 +1,7 @@ +[pytest] +testpaths = playwright_tests +pythonpath = . +addopts = -p playwright_tests.options -s --tb=short --headed --browser chromium --tracing retain-on-failure --screenshot only-on-failure --output reports/playwright/artifacts +markers = + session: session creation + trading_token: token creation and activation diff --git a/playwright_checks/test_setup_helpers.py b/playwright_checks/test_setup_helpers.py new file mode 100644 index 0000000..0391934 --- /dev/null +++ b/playwright_checks/test_setup_helpers.py @@ -0,0 +1,156 @@ +from datetime import datetime + +import pytest + +from playwright_tests.planning import build_plan +from playwright_tests.setup_page import SetupPage + + +@pytest.fixture +def config(): + return { + "setup": {"run_session": True, "run_token": True}, + "trading_token_idas": {"product_codes": "IDAS,IDASTEST01", "market_code": "NATIONAL"}, + } + + +def test_combined_plan_dates_and_products(config): + plan = build_plan(config, now=datetime(2026, 9, 8, 10, 0)) + assert plan["session"] == ("08/09/2026 10:07", "08/09/2026 20:30") + assert [t["product_code"] for t in plan["tokens"]] == ["IDAS", "IDASTEST01"] + assert plan["tokens"][0]["delivery_date"] == "09/09/2026" + assert plan["tokens"][0]["rows"][0] == ("ORDER_NOR", "10:10", "18:30") + + +def test_login_ignores_expired_mutation_settings(config): + assert build_plan(config, "login", now=datetime(2026, 9, 8, 23)) == {"session": None, "tokens": []} + + +def test_token_override_does_not_modify_config(config): + plan = build_plan(config, "token", "IDASTEST02", datetime(2026, 9, 8, 10)) + assert plan["session"] is None + assert [t["product_code"] for t in plan["tokens"]] == ["IDASTEST02"] + assert config["trading_token_idas"]["product_codes"] == "IDAS,IDASTEST01" + + +@pytest.mark.parametrize("flow, message", [("session", "Session end_time"), ("token", "expiry_time")]) +def test_reject_expired_schedule(config, flow, message): + with pytest.raises(ValueError, match=message): + build_plan(config, flow, now=datetime(2026, 9, 8, 23)) + + +def test_reject_inverted_time_session(config): + config["trading_token_idas"]["time_sessions"] = {"ORDER_NOR": {"start_time": "19:00", "end_time": "18:30"}} + with pytest.raises(ValueError, match="ORDER_NOR"): + build_plan(config, "token", now=datetime(2026, 9, 8, 10)) + + +def test_save_waits_for_delayed_confirmation(page, tmp_path): + page.set_content('''
''') + app = SetupPage(page, tmp_path) + app.save("//button", "saved", timeout=2) + assert len(app.screenshots) == 1 + + +def test_save_rejects_error_dialog(page, tmp_path): + page.set_content('''''') + app = SetupPage(page, tmp_path) + with pytest.raises(AssertionError, match="Invalid session"): + app.save("//button", "saved", timeout=2) + assert not app.screenshots + + +def test_save_accepts_success_dialog(page, tmp_path): + page.set_content('''''') + app = SetupPage(page, tmp_path) + app.save("//button", "activated", timeout=2) + assert app.screenshots + + +def test_save_does_not_pass_on_click_alone(page, tmp_path): + page.set_content("") + with pytest.raises(AssertionError, match="no explicit success"): + SetupPage(page, tmp_path).save("//button", "saved", timeout=0.2) + + +def test_grid_button_targets_exact_time_session(page, tmp_path): + page.set_content(''' +
ORDER_NOR
+
ORDER_EXCH
+ ''') + SetupPage(page, tmp_path).grid_button("Details", "ORDER_EXCH") + assert page.evaluate("window.selected") == "exchange" + + +def test_login_clicks_automatically_after_complete_captcha(page, tmp_path): + page.route("https://setup.test/", lambda route: route.fulfill(body=''' + + + + + + ''', content_type="text/html")) + app = SetupPage(page, tmp_path) + app.login({"url": "https://setup.test/", "login": {"username": "sample", "password": "sample"}}, 2000) + assert page.evaluate("window.clickedValue") == "123456" + + +def test_token_details_button_can_be_outside_grid(page, tmp_path): + page.set_content('''''') + SetupPage(page, tmp_path).grid_button("Token Details") + assert page.evaluate("window.opened") is True + + +@pytest.mark.parametrize("configured,displayed", [("ORDER_NOR", "ORDER_NORMAL"), ("ORDER_EXCH", "ORDER_EXCHANGE")]) +def test_time_session_aliases_match_live_labels(page, tmp_path, configured, displayed): + page.set_content(f'''
{displayed}_OTHER +
+
{displayed} +
''') + SetupPage(page, tmp_path).grid_button("Details", configured) + assert page.evaluate("window.selected") == "correct" + + +def test_dropdown_preserves_already_selected_value(page, tmp_path): + page.set_content('') + SetupPage(page, tmp_path).choose(page.locator("#market"), "NATIONAL") + assert page.evaluate("window.clicked === undefined") + + +@pytest.mark.parametrize("message,passes", [("1|Token updated successfully", True), ("0|Invalid session", False)]) +def test_token_save_requires_own_response_despite_old_banner(page, tmp_path, message, passes): + from tests.locators import Locator + page.route("https://setup.test/", lambda route: route.fulfill(content_type="text/html", body=''' +
Previous token saved successfully
+ + ''')) + page.route("**/tokenCtrl/updateTimeSessionMap", lambda route: route.fulfill(json={"message": message})) + page.goto("https://setup.test/") + app = SetupPage(page, tmp_path) + if passes: + app.save(Locator.savetimesessionbtn, "saved", timeout=2) + assert app.screenshots + else: + with pytest.raises(AssertionError, match="Invalid session"): + app.save(Locator.savetimesessionbtn, "saved", timeout=2) + + +def test_activation_rejects_null_message_even_with_zero_status(page, tmp_path): + from tests.locators import Locator + page.route("https://setup.test/", lambda route: route.fulfill(content_type="text/html", body=''' +
TOKEN123
ABC
I
+ + ''')) + page.route("**/tokenCtrl/updateTokenMstr", lambda route: route.fulfill(json={"message": None, "status": 0})) + page.goto("https://setup.test/") + app = SetupPage(page, tmp_path) + app.token_id = "TOKEN123" + with pytest.raises(AssertionError, match="Activation not confirmed for token TOKEN123"): + app.save(Locator.savetokenandactivatebtn, "activated", timeout=2) + assert not app.screenshots diff --git a/playwright_tests/README.md b/playwright_tests/README.md new file mode 100644 index 0000000..f97d7c1 --- /dev/null +++ b/playwright_tests/README.md @@ -0,0 +1,107 @@ +# Session and token setup with Playwright + +This suite uses `config.json` (not `config/runtime.json`) and the existing setup locators. +The setup URL is now `https://utilseastern.pxil.in/Pxil/`. This shared configuration +also changes the URL used by Selenium setup on the migration branch. + +## Install + +The separate `.venv-playwright` environment and Chromium are installed on this machine. +For a fresh checkout, from the repository root in PowerShell: + +```powershell +python -m venv .venv-playwright +.\.venv-playwright\Scripts\python.exe -m pip install -r requirements-playwright.txt +.\.venv-playwright\Scripts\python.exe -m playwright install chromium +``` + +## Run + +All commands below open Chromium. Credentials are filled automatically. Enter the +six-character CAPTCHA in the browser; Playwright then clicks Sign in automatically. +If CAPTCHA length changes, set `playwright.captcha_length` in `config.json`. +The default CAPTCHA timeout is 180 seconds; override with `--captcha-timeout 300`. + +Login and logout only, without creating records: + +```powershell +.\.venv-playwright\Scripts\python.exe -u -m pytest -c playwright.ini --setup-flow login +``` + +Create a session only: + +```powershell +.\.venv-playwright\Scripts\python.exe -u -m pytest -c playwright.ini --setup-flow session +``` + +Create a token, edit ORDER_NOR / ORDER_EXCH / TRADE, and activate it for one product: + +```powershell +.\.venv-playwright\Scripts\python.exe -u -m pytest -c playwright.ini --setup-flow token --token-products IDAS +``` + +Create a session and then a token within the same login: + +```powershell +.\.venv-playwright\Scripts\python.exe -u -m pytest -c playwright.ini --setup-flow both --token-products IDAS +``` + +Follow the existing `setup.run_session` / `setup.run_token` flags and configured product list: + +```powershell +.\.venv-playwright\Scripts\python.exe -u -m pytest -c playwright.ini +``` + +Explicit `--setup-flow` overrides the setup flags. `--token-products` overrides the +product list for that run without editing the configuration. Without that override, +the current configuration selects seven products. A standalone token run assumes a +valid trading session already exists. Combined setup runs session creation first. + +If a token was saved before a later step failed, resume that exact token instead of +creating it again (replace the product and token ID with the saved record): + +```powershell +.\.venv-playwright\Scripts\python.exe -u -m pytest -c playwright.ini --setup-flow token --token-products IDAS3 --resume-token-id 080926083528OXVH +``` + +Resume requires exactly one product and looks up the exact token ID in Time Session +View. It skips Add New Token/save Token, edits the configured time sessions, and +activates the existing token. The configuration names `ORDER_NOR` and `ORDER_EXCH` +also match the application's `ORDER_NORMAL` and `ORDER_EXCHANGE` labels. + +## Schedule and outcome checks + +Before browser startup, the suite validates the session window, token expiry, and +each time-session start/end pair. Update the same fields used by Selenium in +`config.json` when the day's schedule has expired. It does not silently move the +schedule or replace existing records. + +Token saves and time-session updates require a successful response from their +specific server endpoint. Activation requires the application's `1|` success message +and the Save & Activate button to disappear. A null message is a failure, even if +the response status is zero. Session creation uses success notifications/alerts. +Visible errors and missing confirmations fail the test. A missing +confirmation must be investigated using the trace before rerunning, because the +server may already have saved the record. There are no automatic mutation retries. + +Grid selection requires a unique matching button. It fails instead of silently +selecting the first token if the application lists multiple records. + +## Reports and local checks + +Each live run writes a unique folder under `reports/playwright/`, with an HTML report, +embedded step screenshots, and failure artifacts/traces. These files are Git-ignored. +The original Selenium suite does not collect these tests through its default config. + +Run local schedule and browser-helper checks without contacting PXIL: + +```powershell +.\.venv-playwright\Scripts\python.exe -m pytest -c playwright.ini playwright_checks -o addopts= -p no:cacheprovider --browser chromium -q +``` + +Validated on 2026-09-08: live utilseastern login, automatic Sign in after manual +CAPTCHA, dashboard detection, logout, new token creation, and all three time-session +updates. Activation of token `080926084458LQSS` returned a null message from +`updateTokenMstr`; successful activation is not confirmed. Session creation still +needs live validation. Selenium backup remains at +`backup/selenium-working-2026-09-08`. diff --git a/playwright_tests/__init__.py b/playwright_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/playwright_tests/conftest.py b/playwright_tests/conftest.py new file mode 100644 index 0000000..d04a6cb --- /dev/null +++ b/playwright_tests/conftest.py @@ -0,0 +1,74 @@ +from datetime import datetime +from pathlib import Path +from uuid import uuid4 +import base64 + +import pytest + +from tests.config_reader import load_config +from playwright_tests.planning import build_plan +from playwright_tests.setup_page import SetupPage + + +def pytest_configure(config): + run = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid4().hex[:8] + folder = Path(config.rootpath) / "reports" / "playwright" / run + folder.mkdir(parents=True, exist_ok=True) + config._setup_report_folder = folder + config.option.htmlpath = str(folder / "report.html") + config.option.self_contained_html = True + config.option.output = str(folder / "artifacts") + + +@pytest.fixture(scope="session") +def browser_context_args(browser_context_args): + return {**browser_context_args, "viewport": {"width": 1600, "height": 1000}} + + +@pytest.fixture +def setup_config(): + return load_config() + + +@pytest.fixture +def setup_plan(request, setup_config): + plan = build_plan(setup_config, request.config.getoption("--setup-flow"), + request.config.getoption("--token-products")) + resume_id = request.config.getoption("--resume-token-id") + if resume_id and (request.config.getoption("--setup-flow") != "token" or len(plan["tokens"]) != 1): + raise pytest.UsageError("--resume-token-id requires --setup-flow token and exactly one product.") + plan["resume_token_id"] = resume_id + return plan + + +@pytest.fixture +def setup_app(request, setup_plan, setup_config, page): + # setup_plan validates all dates before login or any save operation. + if not request.config.getoption("headed"): + raise pytest.UsageError("Setup login requires manual CAPTCHA: run with --headed.") + page.set_default_timeout(30_000) + folder = request.config._setup_report_folder / request.node.name + folder.mkdir(parents=True, exist_ok=True) + app = SetupPage(page, folder) + request.node._setup_app = app + app.login(setup_config, request.config.getoption("--captcha-timeout") * 1000) + return app + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + report = outcome.get_result() + app = getattr(item, "_setup_app", None) + if app and report.when in ("setup", "call"): + if report.failed and not app.page.is_closed(): + try: + app.capture("failure_state") + except Exception: + pass # Keep the original failure if the browser has disconnected. + if report.when == "call" or report.failed: + from pytest_html import extras + report.extras = list(getattr(report, "extras", [])) + [ + extras.png(base64.b64encode(path.read_bytes()).decode("ascii"), name=name) + for name, path in app.screenshots + ] diff --git a/playwright_tests/options.py b/playwright_tests/options.py new file mode 100644 index 0000000..9e82d35 --- /dev/null +++ b/playwright_tests/options.py @@ -0,0 +1,6 @@ +def pytest_addoption(parser): + group = parser.getgroup("session-token") + group.addoption("--setup-flow", choices=["configured", "login", "session", "token", "both"], default="configured") + group.addoption("--token-products", default=None, help="Comma-separated override; config.json is not modified") + group.addoption("--resume-token-id", default=None, help="Edit and activate this existing token; requires --setup-flow token and one product") + group.addoption("--captcha-timeout", type=int, default=180, help="Seconds allowed for manual CAPTCHA entry before automatic Sign in") diff --git a/playwright_tests/planning.py b/playwright_tests/planning.py new file mode 100644 index 0000000..a10f53a --- /dev/null +++ b/playwright_tests/planning.py @@ -0,0 +1,52 @@ +"""Build and validate the complete setup schedule before changing the application.""" +from datetime import datetime, timedelta + +from tests.config_reader import parse_product_codes + + +def build_plan(config, flow="configured", product_codes=None, now=None): + now = now or datetime.now() + setup = config.get("setup", {}) + session = flow in ("session", "both") or ( + flow == "configured" and setup.get("run_session", True) + ) + token = flow in ("token", "both") or ( + flow == "configured" and setup.get("run_token", True) + ) + result = {"session": None, "tokens": []} + if session: + settings = setup.get("session", {}) + start = now + timedelta(minutes=settings.get("start_offset_minutes", 7)) + end = datetime.combine(now.date(), datetime.strptime(settings.get("end_time", "20:30"), "%H:%M").time()) + if end <= start: + raise ValueError("Session end_time must be later than its calculated start time. Update config.json.") + result["session"] = (start.strftime("%d/%m/%Y %H:%M"), end.strftime("%d/%m/%Y %H:%M")) + if token: + settings = config.get("trading_token_idas") + if not settings: + raise ValueError("Configure trading_token_idas in config.json.") + codes = parse_product_codes(product_codes if product_codes is not None else settings.get("product_codes") or settings.get("product_code")) + if not codes or len(set(codes)) != len(codes): + raise ValueError("Specify nonempty, unique token product codes.") + expiry = datetime.combine(now.date(), datetime.strptime(settings.get("expiry_time", "20:30"), "%H:%M").time()) + if expiry <= now: + raise ValueError("Token expiry_time is already past. Update config.json before creating tokens.") + delivery = (now + timedelta(days=settings.get("delivery_offset_days", 1))).strftime("%d/%m/%Y") + rows = [] + defaults = [("ORDER_NOR", (now + timedelta(minutes=10)).strftime("%H:%M"), "18:30"), + ("ORDER_EXCH", "18:30", "19:00"), ("TRADE", "19:00", "20:00")] + for name, start, end in defaults: + row = settings.get("time_sessions", {}).get(name, {}) + if row.get("start_time") is not None: + start = row["start_time"] + elif row.get("start_offset_minutes") is not None: + start = (now + timedelta(minutes=row["start_offset_minutes"])).strftime("%H:%M") + end = row.get("end_time", end) + if datetime.strptime(end, "%H:%M") <= datetime.strptime(start, "%H:%M"): + raise ValueError(f"{name} end_time must be later than start_time. Update config.json.") + rows.append((name, start, end)) + result["tokens"] = [dict(settings, product_code=code, delivery_date=delivery, + expiry=expiry.strftime("%d/%m/%Y %H:%M"), + session_date=now.strftime("%d/%m/%Y"), rows=rows) + for code in codes] + return result diff --git a/playwright_tests/setup_page.py b/playwright_tests/setup_page.py new file mode 100644 index 0000000..fb749e1 --- /dev/null +++ b/playwright_tests/setup_page.py @@ -0,0 +1,209 @@ +"""Playwright implementation of the existing Angular setup UI.""" +import re +import time +from urllib.parse import urlparse + +from playwright.sync_api import expect +from tests.locators import Locator + + +class SetupPage: + def __init__(self, page, folder): + self.page = page + self.folder = folder + self.screenshots = [] + self.dialogs = [] + self.token_id = None + page.on("dialog", self._dialog) + + def _dialog(self, dialog): + self.dialogs.append(dialog.message) + dialog.accept() + + def visible(self, xpath): + return self.page.locator("xpath=" + xpath).filter(visible=True) + + def capture(self, name): + path = self.folder / f"{len(self.screenshots) + 1:02d}_{name}.png" + self.page.screenshot(path=str(path)) + self.screenshots.append((name, path)) + print(f"STEP {len(self.screenshots)}: {name}") + + def click(self, xpath, name): + self.visible(xpath).click() + self.capture(name) + + def fill(self, xpath, value): + field = self.visible(xpath) + field.fill(value) + field.press("Tab") + expect(field).to_have_value(value) + + def close_notice(self): + # Only the known TAC-expiry notice; never dismiss arbitrary validation dialogs. + notice = self.page.locator(".modal:visible").filter(has_text=re.compile(r"TAC.*expir", re.I | re.S)) + if notice.count() == 1: + notice.locator("button.close").click() + + def login(self, config, timeout): + self.page.goto(config["url"]) + self.visible(Locator.userid).fill(config["login"]["username"]) + self.visible(Locator.password).fill(config["login"]["password"]) + captcha_length = int(config.get("playwright", {}).get("captcha_length", 6)) + if captcha_length < 1: + raise ValueError("playwright.captcha_length must be positive.") + print(f"Credentials filled. Enter the {captcha_length}-character CAPTCHA in the browser; " + f"Sign in will be clicked automatically. Waiting up to {timeout // 1000} seconds...", flush=True) + captcha = self.page.locator('input[name="captcha"][ng-model="login.captcha"]') + expect(captcha).to_have_value(re.compile(r"^\S{" + str(captcha_length) + r"}$"), timeout=timeout) + self.visible(Locator.submitbtn).click() + expect(self.visible(Locator.dashboard_marker)).to_be_visible(timeout=30_000) + self.close_notice() + self.capture("login_success") + + def menu(self, xpath): + self.close_notice() + if not self.visible(xpath).count(): + self.visible(Locator.tradingmaintenance).click() + self.visible(xpath).click() + + def choose(self, toggle, value): + expect(toggle).to_be_visible() + if toggle.inner_text().strip() == value: + return + toggle.click() + search = self.page.locator("input.ui-select-search:visible") + expect(search).to_have_count(1) + search.fill(value) + if toggle.is_visible() and toggle.inner_text().strip() == value: + return + option = self.page.locator(".ui-select-choices-row:visible").filter( + has_text=re.compile(r"^\s*" + re.escape(value) + r"\s*$") + ) + option.click() + expect(self.page.locator(".ui-select-choices-row:visible")).to_have_count(0) + + def grid_button(self, label, row=None): + scope = self.page + if row is not None: + aliases = {"ORDER_NOR": ("ORDER_NOR", "ORDER_NORMAL"), + "ORDER_EXCH": ("ORDER_EXCH", "ORDER_EXCHANGE")} + labels = aliases.get(row, (row,)) + pattern = re.compile(r"^\s*(?:" + "|".join(re.escape(label) for label in labels) + r")\s*$") + scope = self.page.locator(".ui-grid-row:visible").filter(has=self.page.get_by_text(pattern)) + button = scope.get_by_role("button", name=label, exact=True) + # Fail on multiple matches instead of silently editing a different token. + expect(button).to_have_count(1, timeout=30_000) + button.click() + + def save(self, xpath, name, timeout=30): + """Require an explicit success message; a click alone is not a pass.""" + button = self.visible(xpath) + if xpath in (Locator.savetokenbtn, Locator.savetimesessionbtn, Locator.savetokenandactivatebtn): + endpoint = {Locator.savetokenbtn: "saveTokenDetails", + Locator.savetimesessionbtn: "updateTimeSessionMap", + Locator.savetokenandactivatebtn: "updateTokenMstr"}[xpath] + with self.page.expect_response( + lambda response: response.request.method == "POST" + and urlparse(response.url).path.endswith("/tokenCtrl/" + endpoint), + timeout=timeout * 1000, + ) as pending: + button.click() + response = pending.value + assert response.ok, f"{name}: server returned HTTP {response.status}" + result = response.json() + message = result.get("message") or "" + if xpath == Locator.savetokenandactivatebtn: + assert message.startswith("1|"), ( + f"Activation not confirmed for token {self.token_id}: updateTokenMstr " + f"returned {result}. The application requires a '1|' success message. " + "The token and time sessions are already saved; inspect activation before rerunning." + ) + # The application hides this button after a successful update; it + # does not refresh the Token Master grid at this point. + expect(button).to_have_count(0, timeout=30_000) + else: + assert message.startswith("1|"), f"{name}: {message or 'Missing success response'}" + if xpath == Locator.savetokenbtn: + match = re.search(r"token Id\s*:\s*([^|\s]+)", message, re.I) + assert match, "Save response did not identify the generated token" + self.token_id = match.group(1) + self.capture(name) + return + previous_dialogs = len(self.dialogs) + success = self.page.locator(".alert-success:visible, .toast-success:visible, [role='alert']:visible").filter( + has_text=re.compile(r"success|saved|activated|updated", re.I) + ) + # An earlier notification must disappear before the next save can be verified. + expect(success).to_have_count(0, timeout=30_000) + button.click() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + messages = self.dialogs[previous_dialogs:] + errors = self.page.locator(".alert-danger:visible, .toast-error:visible") + if errors.count(): + raise AssertionError(f"{name}: {'; '.join(errors.all_text_contents())}") + if any(re.search(r"fail|error|invalid|unable|already exists", text, re.I) for text in messages): + raise AssertionError(f"{name}: {'; '.join(messages)}") + if success.count() or any(re.search(r"success|saved|activated|updated", text, re.I) for text in messages): + self.capture(name) + return + # Poll business confirmation while servicing Playwright dialog events. + self.page.wait_for_timeout(100) + raise AssertionError(f"{name}: no explicit success confirmation within {timeout}s. " + "Inspect the trace for the application's response/notification selector.") + + def create_session(self, config, window): + self.menu(Locator.tradingsession) + self.click(Locator.addsessionbtn, "add_session") + self.choose(self.page.locator('[aria-label="meInstanceId activate"]'), config["product"]["me_instance"]) + self.fill(Locator.inputstartdate, window[0]) + self.fill(Locator.inputenddate, window[1]) + self.capture("session_values") + self.save(Locator.sessionsavebtn, "session_saved") + + def create_token(self, token): + code = token["product_code"] + self.menu(Locator.tokengeneration) + self.choose(self.page.locator(f"#{Locator.tokenmarketcode} .ui-select-toggle"), token["market_code"]) + self.choose(self.visible("//label[normalize-space()='PRODUCT CODE']/following::span[contains(@class,'ui-select-toggle')][1]"), code) + self.click(Locator.addnewtokenbtn, "add_token") + self.fill(Locator.tokendeliverystartdate, token["delivery_date"]) + self.fill(Locator.tokendeliveryenddate, token["delivery_date"]) + self.fill(Locator.tokenexpirydate, token["expiry"]) + self.save(Locator.savetokenbtn, "token_saved") + self.visible(Locator.tokentimesessionbtn).click() + self.grid_button("Time Session Details") + self.edit_and_activate_token(token) + print(f"Created and activated token for {code}") + + def resume_token(self, token, token_id): + self.token_id = token_id + self.menu("//a[@href='#/tradingMaintenance/timeSessionView']") + for element_id, value in (("mmMarketCode", token["market_code"]), + ("mmProductType", token["product_type"]), + ("mmProductCode", token["product_code"])): + self.choose(self.page.locator(f"#{element_id} .ui-select-toggle"), value) + self.visible("//button[@ng-click='getTokenDetailsList()' or @data-ng-click='getTokenDetailsList()']").click() + self.grid_button("Time Session Details", token_id) + self.capture("resume_saved_token") + self.edit_and_activate_token(token) + print(f"Updated and activated existing token {token_id}") + + def edit_and_activate_token(self, token): + for name, start, end in token["rows"]: + self.grid_button("Details", name) + self.visible(Locator.edittimesessionbtn).click() + self.fill(Locator.timesessionstartdate, token["session_date"]) + self.fill(Locator.timesessionenddate, token["session_date"]) + self.fill(Locator.timesessionstarttime, start) + self.fill(Locator.timesessionendtime, end) + self.save(Locator.savetimesessionbtn, f"{name.lower()}_saved") + self.grid_button("Token Details") + self.save(Locator.savetokenandactivatebtn, "token_activated") + + def logout(self): + self.visible(Locator.usermenutoggle).click() + self.visible(Locator.logoutlink).click() + expect(self.visible(Locator.userid)).to_be_visible() + self.capture("logout") diff --git a/playwright_tests/test_setup.py b/playwright_tests/test_setup.py new file mode 100644 index 0000000..e49c092 --- /dev/null +++ b/playwright_tests/test_setup.py @@ -0,0 +1,10 @@ +def test_session_token_setup(setup_app, setup_config, setup_plan): + """Run selected setup steps in order, within a single authenticated session.""" + if setup_plan["session"]: + setup_app.create_session(setup_config, setup_plan["session"]) + for token in setup_plan["tokens"]: + if setup_plan["resume_token_id"]: + setup_app.resume_token(token, setup_plan["resume_token_id"]) + else: + setup_app.create_token(token) + setup_app.logout() diff --git a/pytest.ini b/pytest.ini index e984976..cb1d9f0 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,5 @@ [pytest] +norecursedirs = .* venv .venv-playwright playwright_tests playwright_checks markers = session: session creation trading_token: trading token creation diff --git a/requirements-playwright.txt b/requirements-playwright.txt new file mode 100644 index 0000000..b38befd --- /dev/null +++ b/requirements-playwright.txt @@ -0,0 +1,4 @@ +pytest==8.4.1 +pytest-html==4.2.0 +playwright==1.62.0 +pytest-playwright==0.9.0