Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37e0ddae3d |
@@ -1,6 +1,9 @@
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
.venv-playwright/
|
||||
reports/playwright/
|
||||
test-results/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
+9
-9
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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('''<button onclick="setTimeout(() => {
|
||||
document.querySelector('#result').innerHTML = '<div class=alert-success>Saved successfully</div>';
|
||||
}, 150)">Save</button><div id=result></div>''')
|
||||
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('''<button onclick="alert('Invalid session')">Save</button>''')
|
||||
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('''<button onclick="alert('Token activated successfully')">Save</button>''')
|
||||
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("<button>Save</button>")
|
||||
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('''
|
||||
<div class=ui-grid-row><span>ORDER_NOR</span><button onclick="window.selected='normal'">Details</button></div>
|
||||
<div class=ui-grid-row><span>ORDER_EXCH</span><button onclick="window.selected='exchange'">Details</button></div>
|
||||
''')
|
||||
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='''
|
||||
<input placeholder="User ID"><input placeholder="Password" type=password>
|
||||
<input name=captcha ng-model="login.captcha">
|
||||
<button value=Login onclick="window.clickedValue=document.querySelector('[name=captcha]').value;
|
||||
document.querySelector('#dashboard').hidden=false;">Sign in</button>
|
||||
<span id=dashboard hidden>TRADING MAINTENANCE</span>
|
||||
<script>
|
||||
setTimeout(() => document.querySelector('[name=captcha]').value='12345', 100);
|
||||
setTimeout(() => document.querySelector('[name=captcha]').value='123456', 300);
|
||||
</script>
|
||||
''', 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('''<button onclick="window.opened=true">Token Details</button>''')
|
||||
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'''<div class=ui-grid-row><span>{displayed}_OTHER</span>
|
||||
<button onclick="window.selected='wrong'">Details</button></div>
|
||||
<div class=ui-grid-row><span>{displayed}</span>
|
||||
<button onclick="window.selected='correct'">Details</button></div>''')
|
||||
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('<button id=market onclick="window.clicked=true">NATIONAL</button>')
|
||||
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='''
|
||||
<div class=alert-success>Previous token saved successfully</div>
|
||||
<button data-ng-click="updateSession()" onclick="fetch('/tokenCtrl/updateTimeSessionMap', {method:'POST'})">Save</button>
|
||||
'''))
|
||||
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='''
|
||||
<div class=ui-grid-row><div role=gridcell>TOKEN123</div><div role=gridcell>ABC</div><div role=gridcell id=active>I</div></div>
|
||||
<button data-ng-click="saveTokenDetails('T')" onclick="fetch('/tokenCtrl/updateTokenMstr', {method:'POST'}).then(() => document.querySelector('#active').textContent='T')">Save & Activate</button>
|
||||
'''))
|
||||
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
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -1,4 +1,5 @@
|
||||
[pytest]
|
||||
norecursedirs = .* venv .venv-playwright playwright_tests playwright_checks
|
||||
markers =
|
||||
session: session creation
|
||||
trading_token: trading token creation
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
pytest==8.4.1
|
||||
pytest-html==4.2.0
|
||||
playwright==1.62.0
|
||||
pytest-playwright==0.9.0
|
||||
Reference in New Issue
Block a user