26 changed files with 499 additions and 797 deletions
-3
View File
@@ -1,9 +1,6 @@
# Virtual environments
venv/
.venv/
.venv-playwright/
reports/playwright/
test-results/
# Python cache
__pycache__/
-82
View File
@@ -1,82 +0,0 @@
# 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.
+75 -6
View File
@@ -11,6 +11,80 @@ cd "C:\Selenium Test"
Edit `config.json` before running.
Common URL, login credentials, login waits, and step delay stay at the top level.
`setup.run_session` and `setup.run_token` are defaults for every enabled product.
`setup.session` provides common session timing defaults.
`products` contains separate `IDAS`, `RTM`, `UPC`, `RA`, and `REC` sections.
Each product has its own `me_instance`, optional `session` timing overrides, and
`token` settings (codes, delivery offset, expiry, and time sessions).
RTM currently runs token creation only (`run_session: false`, `run_token: true`).
If enabled again, session creation uses ME instance
`52` (`52:RTM`). It uses today's date plus a 7-minute start offset and today's
`20:30` end time, matching the current IDAS session end-time default. Change
`products.RTM.session.start_offset_minutes` and `products.RTM.session.end_time`
to configure RTM independently. The shared login, navigation, and Save steps apply
to every product. RTM token automation opens Token Generator,
selects `NATIONAL`, product code `RTM`, and generation mode `RTM`, then clicks
Add New Token. Product Type is populated automatically. It fills delivery dates
with today's `00:00` and `23:59`, session start with current time plus 5 minutes,
and session end with `20:00`, then clicks Save Token. All four timings are
configured under `products.RTM.token` as shown below. `delivery_offset_days: 0`
means today. RTM activation is not part of this stage.
Run RTM token creation through Save Token:
```powershell
pytest tests\setup -k RTM -s
```
Existing IDAS settings are preserved. The remaining product templates are disabled until
you fill in their actual ME instance/token codes and set `enabled` to `true`.
Additional products can be added using the same structure.
To override the defaults for one product, add flags directly inside that product:
```json
"RTM": {
"enabled": true,
"run_session": false,
"run_token": true,
"me_instance": "52",
"session": {
"start_offset_minutes": 7,
"end_time": "20:30"
},
"token": {
"market_code": "NATIONAL",
"product_type": "RTM",
"product_codes": ["RTM"],
"generation_mode": "RTM",
"delivery_offset_days": 0,
"delivery_from_time": "00:00",
"delivery_to_time": "23:59",
"session_start_offset_minutes": 5,
"session_end_time": "20:00"
}
}
```
Remove a product's `run_session` or `run_token` key to inherit that common flag
again. Explicit `false` overrides a common `true`; explicit `true` overrides a
common `false`. Both false skips that product; `enabled: false` always skips it.
Session timing overrides inherit any fields omitted from `setup.session`.
Token timing settings belong to each product independently.
Use `pytest tests\setup -s` to run all enabled products. Each product runs in its
own browser: both flags true runs session then the product's token flow in one login; otherwise
only its enabled action runs. Test/report names include the product name.
Screenshot and log folders use the collected product and action, such as
`IDAS_SESSION_TOKEN_001`, `RTM_TOKEN_001`, or `RTM_SESSION_001`.
The shared HTML report for `tests\setup` uses `PXIL_SETUP_###.html`.
Existing report folders retain their original names.
The session and token subfolder commands below select only products configured
for session-only and token-only respectively; use the full setup command for a
mixture of modes.
Session and token together in one login:
```json
@@ -69,7 +143,7 @@ Run only token setup:
pytest tests\setup\tokens -s --html=reports/html/token_run.html --self-contained-html
```
Use this when `run_token` is `true`. It will create one token or multiple tokens based on `trading_token_idas.product_codes`.
Use this when `run_token` is `true`. It will create one token or multiple tokens based on `products.<product>.token.product_codes`.
Run only session setup:
@@ -384,8 +458,3 @@ 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.
+88 -24
View File
@@ -1,45 +1,109 @@
{
"url": "https://utilseastern.pxil.in/Pxil/",
"url": "https://utilsuat.pxil.in/Pxil/",
"stagingurl": "https://utilseastern.pxil.in/Pxil/",
"uaturl": "https://utilsuat.pxil.in/Pxil/",
"login": {
"username": "Tejas",
"password": "Pxil@123"
},
"product": {
"me_instance": "55"
},
"login_captcha_wait_seconds": 10,
"login_post_submit_wait_seconds": 10,
"step_delay_seconds": 1,
"setup": {
"run_session": false,
"run_session": true,
"run_token": true,
"session": {
"start_offset_minutes": 7,
"end_time": "20:30"
}
},
"trading_token_idas": {
"market_code": "NATIONAL",
"product_type": "IDAS",
"product_code": "IDAS4",
"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": "22:30",
"time_sessions": {
"ORDER_NOR": {
"start_offset_minutes": 5,
"end_time": "21:30"
"products": {
"IDAS": {
"enabled": true,
"me_instance": "55",
"session": {},
"token": {
"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",
"delivery_offset_days": 1,
"expiry_time": "20:30",
"time_sessions": {
"ORDER_NOR": {
"start_offset_minutes": 7,
"end_time": "19:30"
},
"ORDER_EXCH": {
"start_time": "19:30",
"end_time": "19:45"
},
"TRADE": {
"start_time": "19:45",
"end_time": "19:50"
}
}
}
},
"RTM": {
"enabled": true,
"run_session": false,
"run_token": true,
"me_instance": "52",
"session": {
"start_offset_minutes": 7,
"end_time": "20:30"
},
"ORDER_EXCH": {
"start_time": "21:30",
"end_time": "22:45"
},
"TRADE": {
"start_time": "22:45",
"end_time": "22:50"
"token": {
"market_code": "NATIONAL",
"product_type": "RTM",
"product_codes": ["RTM"],
"generation_mode": "RTM",
"delivery_offset_days": 0,
"delivery_from_time": "00:00",
"delivery_to_time": "23:59",
"session_start_offset_minutes": 5,
"session_end_time": "20:00"
}
},
"UPC": {
"enabled": false,
"me_instance": "",
"session": {},
"token": {
"market_code": "NATIONAL",
"product_type": "UPC",
"product_codes": [],
"delivery_offset_days": 1,
"expiry_time": "20:30",
"time_sessions": {}
}
},
"RA": {
"enabled": false,
"me_instance": "",
"session": {},
"token": {
"market_code": "NATIONAL",
"product_type": "RA",
"product_codes": [],
"delivery_offset_days": 1,
"expiry_time": "20:30",
"time_sessions": {}
}
},
"REC": {
"enabled": false,
"me_instance": "",
"session": {},
"token": {
"market_code": "NATIONAL",
"product_type": "REC",
"product_codes": [],
"delivery_offset_days": 1,
"expiry_time": "20:30",
"time_sessions": {}
}
}
}
-7
View File
@@ -1,7 +0,0 @@
[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
-156
View File
@@ -1,156 +0,0 @@
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 &amp; 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
-107
View File
@@ -1,107 +0,0 @@
# 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`.
View File
-74
View File
@@ -1,74 +0,0 @@
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
]
-6
View File
@@ -1,6 +0,0 @@
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")
-52
View File
@@ -1,52 +0,0 @@
"""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
-209
View File
@@ -1,209 +0,0 @@
"""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")
-10
View File
@@ -1,10 +0,0 @@
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
View File
@@ -1,5 +1,4 @@
[pytest]
norecursedirs = .* venv .venv-playwright playwright_tests playwright_checks
markers =
session: session creation
trading_token: trading token creation
-4
View File
@@ -1,4 +0,0 @@
pytest==8.4.1
pytest-html==4.2.0
playwright==1.62.0
pytest-playwright==0.9.0
+56
View File
@@ -1,4 +1,5 @@
import json
from copy import deepcopy
from pathlib import Path
@@ -11,6 +12,61 @@ def load_config():
return json.load(f)
def get_product_config(product_name, config=None):
"""Resolve a product's setup flags and session defaults without mutating input."""
config = deepcopy(load_config() if config is None else config)
if "products" not in config:
if product_name != "IDAS":
raise ValueError(f"Unknown setup product: {product_name}")
config["token"] = config.get("trading_token_idas", {})
else:
product = config["products"][product_name]
setup = config.setdefault("setup", {})
for flag in ("run_session", "run_token"):
setup[flag] = product.get(flag, setup.get(flag, True))
if not isinstance(setup[flag], bool):
raise ValueError(f"products.{product_name}.{flag} must be true or false")
setup["session"] = {**setup.get("session", {}), **product.get("session", {})}
config["product"] = {"me_instance": product.get("me_instance")}
config["token"] = deepcopy(product.get("token", {}))
config["product_name"] = product_name
return config
def get_setup_configs(mode, config=None):
"""Partition products so a full setup run never creates a product twice."""
if mode not in ("combined", "session", "token"):
raise ValueError(f"Unknown setup mode: {mode}")
config = load_config() if config is None else config
products = config.get("products", {"IDAS": {}})
selected = []
for name, product in products.items():
if not product.get("enabled", True):
continue
resolved = get_product_config(name, config)
setup = resolved.get("setup", {})
session = setup.get("run_session", True)
token = setup.get("run_token", True)
matches = {
"combined": session and token,
"session": session and not token,
"token": token and not session,
}
if not matches[mode]:
continue
if session and not resolved.get("product", {}).get("me_instance"):
raise ValueError(f"products.{name}.me_instance is required for session creation")
if token:
settings = resolved["token"]
if not settings.get("market_code") or not (
parse_product_codes(settings.get("product_codes"))
or parse_product_codes(settings.get("product_code"))
):
raise ValueError(f"products.{name}.token requires market_code and product_code(s)")
selected.append(resolved)
return selected
def get_step_delay_seconds(default=5):
config = load_config()
+32 -9
View File
@@ -5,15 +5,15 @@ import json
from tests.browser import create_chrome_driver
def get_run_id(config):
def get_run_id(config, prefix=None):
run_id = getattr(config, "_pxil_run_id", None)
if run_id:
return run_id
run_prefix = prefix or _get_run_prefix(config)
run_ids = getattr(config, "_pxil_run_ids", {})
if run_prefix in run_ids:
return run_ids[run_prefix]
base_folder = os.path.join("reports", "screenshots")
os.makedirs(base_folder, exist_ok=True)
run_prefix = _get_run_prefix(config)
existing = [
name for name in os.listdir(base_folder)
@@ -28,10 +28,11 @@ def get_run_id(config):
for name in existing
if name.split("_")[-1].isdigit()
]
next_number = max(numbers) + 1
next_number = max(numbers, default=0) + 1
run_id = f"{run_prefix}_{next_number:03d}"
config._pxil_run_id = run_id
run_ids[run_prefix] = run_id
config._pxil_run_ids = run_ids
return run_id
@@ -66,7 +67,7 @@ def driver():
@pytest.fixture
def test_context(request):
test_id = get_run_id(request.config)
test_id = get_run_id(request.config, _setup_item_prefix(request.node))
test_name = request.node.name
folder = os.path.abspath(
@@ -201,9 +202,28 @@ def _build_steps_html(steps):
)
def _setup_item_prefix(item):
"""Use the collected product and flow, including when -k selects a subset."""
nodeid = item.nodeid.replace("\\", "/")
if "tests/setup/" not in nodeid:
return None
callspec = getattr(item, "callspec", None)
product_config = callspec.params.get("config") if callspec else None
if not isinstance(product_config, dict):
return None
product = product_config["product_name"].upper()
if "test_combined_setup.py" in nodeid:
return f"{product}_SESSION_TOKEN"
if "/sessions/" in nodeid:
return f"{product}_SESSION"
if "/tokens/" in nodeid:
return f"{product}_TOKEN"
return f"{product}_SETUP"
def _get_run_prefix(config):
invocation_args = " ".join(str(arg) for arg in config.invocation_params.args).lower()
invocation_args = " ".join(str(arg) for arg in config.invocation_params.args).replace("\\", "/").lower()
if "tests/setup/sessions" in invocation_args or "test_session.py" in invocation_args:
return "PXIL_SESSION"
@@ -211,6 +231,9 @@ def _get_run_prefix(config):
if "tests/setup/tokens" in invocation_args or "test_trading_token.py" in invocation_args:
return "PXIL_TOKEN"
if "tests/setup" in invocation_args or "test_combined_setup.py" in invocation_args:
return "PXIL_SETUP"
runtime_path = os.path.abspath(os.path.join("config", "runtime.json"))
if os.path.exists(runtime_path):
try:
+9 -3
View File
@@ -367,7 +367,7 @@ def type_into_field(driver, locator, value):
field.send_keys(value)
def select_bootstrap_dropdown_by_id(driver, element_id, value):
def select_bootstrap_dropdown_by_id(driver, element_id, value, exact=False):
wait = WebDriverWait(driver, 20)
@@ -411,12 +411,18 @@ def select_bootstrap_dropdown_by_id(driver, element_id, value):
search_box.clear()
search_box.send_keys(value)
option_xpath = (
f"//div[@id='{element_id}']//div[contains(@class,'ui-select-choices-row')]"
f"[normalize-space()='{value}']"
if exact else
"//div[contains(@class,'ui-select-choices-row')]"
f"//*[normalize-space()='{value}' or starts-with(normalize-space(),'{value}')]"
)
option = wait.until(
EC.element_to_be_clickable(
(
By.XPATH,
"//div[contains(@class,'ui-select-choices-row')]"
f"//*[normalize-space()='{value}' or starts-with(normalize-space(),'{value}')]"
option_xpath
)
)
)
+8
View File
@@ -11,6 +11,14 @@ class Locator(object):
tradingmaintenance="//span[normalize-space()='TRADING MAINTENANCE']"
tradingsession="//a[normalize-space()='Trading Session Master']"
tokengeneration="//a[@href='#/tradingMaintenance/generateToken' or normalize-space()='Token Generation']"
tokengenerator = "//a[@href='#/tradingMaintenance/tokenGenerator']"
generatormarketcode = "marketCode"
generatorproductcode = "productCode"
generatorgenerationmode = "tokenMstr.genMode"
rtmdeliveryfromdate = "//div[@ng-show='showgeneration4']//input[@placeholder='Delivery From Date']"
rtmdeliverytodate = "//div[@ng-show='showgeneration4']//input[@placeholder='Delivery To Date']"
rtmsessionstarttime = "//div[@ng-show='showgeneration4']//input[@placeholder='Session Start Time']"
rtmsessionendtime = "//div[@ng-show='showgeneration4']//input[@placeholder='Session End Time']"
tokenandtimesessionview="//a[@href='#/tradingMaintenance/tokenTimeSessionMap' or normalize-space()='Token and Time Session View']"
addsessionbtn="//button[contains(text(),'Add')]"
addnewtokenbtn="//button[@data-ng-click='addToken()' and normalize-space()='Add New Token']"
+3 -10
View File
@@ -6,16 +6,10 @@ import pytest
from tests.step_actions import StepActions
from tests.helpers import login, select_ui_dropdown
from tests.config_reader import is_combined_setup_enabled, is_setup_session_enabled, load_config
from tests.config_reader import get_setup_configs
from tests.locators import Locator
pytestmark = pytest.mark.skipif(
is_combined_setup_enabled() or not is_setup_session_enabled(),
reason="Standalone session setup is disabled or runs inside combined setup",
)
def get_session_window(config):
session_config = config.get("setup", {}).get("session", {})
@@ -50,9 +44,8 @@ def create_session(driver, step, config):
@pytest.mark.session
def test_session(driver, test_context):
config = load_config()
@pytest.mark.parametrize("config", get_setup_configs("session"), ids=lambda c: c["product_name"])
def test_session(driver, test_context, config):
wait = WebDriverWait(driver, 20)
+6 -13
View File
@@ -1,26 +1,19 @@
import pytest
from selenium.webdriver.support.ui import WebDriverWait
from tests.config_reader import is_combined_setup_enabled, load_config
from tests.config_reader import get_setup_configs
from tests.helpers import login, logout, save_session_backup
from tests.setup.sessions.test_session import create_session
from tests.setup.tokens.test_trading_token import create_and_activate_token, get_product_codes
from tests.setup.tokens.test_trading_token import run_token_setup, get_product_codes
from tests.step_actions import StepActions
pytestmark = pytest.mark.skipif(
not is_combined_setup_enabled(),
reason="Combined setup requires setup.run_session and setup.run_token to both be true",
)
@pytest.mark.session
@pytest.mark.trading_token
def test_session_and_token_setup(driver, test_context):
@pytest.mark.parametrize("config", get_setup_configs("combined"), ids=lambda c: c["product_name"])
def test_session_and_token_setup(driver, test_context, config):
config = load_config()
token = config.get("trading_token_idas")
assert token, "Add trading_token_idas in config.json before running token setup."
token = config["token"]
wait = WebDriverWait(driver, 20)
step = StepActions(driver, wait, test_context)
@@ -41,6 +34,6 @@ def test_session_and_token_setup(driver, test_context):
for product_code in get_product_codes(token):
product_token = dict(token)
product_token["product_code"] = product_code
create_and_activate_token(driver, step, product_token)
run_token_setup(driver, step, product_token)
logout(driver)
+61 -18
View File
@@ -4,7 +4,7 @@ import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from tests.config_reader import is_combined_setup_enabled, is_setup_token_enabled, load_config, parse_product_codes
from tests.config_reader import get_setup_configs, parse_product_codes
from tests.helpers import (
click_visible_menu_item,
click_first_grid_button,
@@ -23,20 +23,10 @@ from tests.locators import Locator
from tests.step_actions import StepActions
pytestmark = pytest.mark.skipif(
is_combined_setup_enabled() or not is_setup_token_enabled(),
reason="Standalone token setup is disabled or runs inside combined setup",
)
def get_token_context(driver, test_context):
config = load_config()
def get_token_context(driver, test_context, config):
wait = WebDriverWait(driver, 20)
step = StepActions(driver, wait, test_context)
token = config.get("trading_token_idas")
assert token, "Add trading_token_idas in config.json before running this test."
token = config["token"]
return config, step, token
@@ -88,7 +78,7 @@ def get_product_codes(token):
return product_codes
product_code = token.get("product_code")
assert product_code, "Add product_code or product_codes in config.json trading_token_idas."
assert product_code, "Add product_code or product_codes in config.json products.<product>.token."
return parse_product_codes(product_code)
@@ -190,6 +180,58 @@ def complete_time_session_edits_from_saved_token(driver, step, token):
pause_after_step()
def open_rtm_new_token(driver, step, token):
"""Prepare the RTM generator through Add New Token."""
open_trading_maintenance_page(driver, Locator.tokengenerator)
handle_transient_popups(driver)
step.capture("open_token_generator_rtm")
for element_id, value, name in (
(Locator.generatormarketcode, token["market_code"], "market_code"),
(Locator.generatorproductcode, token["product_code"], "product_code"),
(Locator.generatorgenerationmode, token["generation_mode"], "generation_mode"),
):
select_bootstrap_dropdown_by_id(driver, element_id, value, exact=True)
step.capture(f"select_{name}_rtm")
# Product Type is populated by the application after selecting Product Code.
step.click((By.XPATH, Locator.addnewtokenbtn), "click_add_new_token_rtm")
def get_rtm_token_times(token, now=None):
now = datetime.now() if now is None else now
delivery_date = (now + timedelta(days=token.get("delivery_offset_days", 0))).strftime("%d/%m/%Y")
return (
f"{delivery_date} {token.get('delivery_from_time', '00:00')}",
f"{delivery_date} {token.get('delivery_to_time', '23:59')}",
(now + timedelta(minutes=token.get("session_start_offset_minutes", 5))).strftime("%H:%M"),
token.get("session_end_time", "20:00"),
)
def create_rtm_token(driver, step, token):
open_rtm_new_token(driver, step, token)
values = get_rtm_token_times(token)
fields = (
(Locator.rtmdeliveryfromdate, "delivery_from_date"),
(Locator.rtmdeliverytodate, "delivery_to_date"),
(Locator.rtmsessionstarttime, "session_start_time"),
(Locator.rtmsessionendtime, "session_end_time"),
)
for (locator, name), value in zip(fields, values):
type_first_visible_input(driver, locator, value)
step.capture(f"enter_{name}_rtm")
step.click((By.XPATH, Locator.savetokenbtn), "save_token_rtm")
handle_transient_popups(driver)
def run_token_setup(driver, step, token):
if token.get("product_type", "").upper() == "RTM":
create_rtm_token(driver, step, token)
else:
create_and_activate_token(driver, step, token)
def create_and_activate_token(driver, step, token):
product_code = token["product_code"]
@@ -224,16 +266,17 @@ def create_and_activate_token(driver, step, token):
@pytest.mark.trading_token
def test_trading_token_creation_idas(driver, test_context):
@pytest.mark.parametrize("config", get_setup_configs("token"), ids=lambda c: c["product_name"])
def test_trading_token_creation(driver, test_context, config):
config, step, token = get_token_context(driver, test_context)
config, step, token = get_token_context(driver, test_context, config)
open_app_and_login(driver, step, config)
save_session_backup(driver, test_context, "login_session_backup")
for product_code in get_product_codes(token):
product_token = dict(token)
product_token["product_code"] = product_code
create_and_activate_token(driver, step, product_token)
run_token_setup(driver, step, product_token)
logout(driver)
@@ -241,7 +284,7 @@ def test_trading_token_creation_idas(driver, test_context):
# @pytest.mark.trading_token
# def test_trading_token_time_session_edit_idas(driver, test_context):
#
# config, step, token = get_token_context(driver, test_context)
# config, step, token = get_token_context(driver, test_context, config)
# open_app_and_login(driver, step, config)
#
# next_day, _ = get_delivery_date_and_expiry(token)
+3 -3
View File
@@ -5,7 +5,7 @@ import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from .config_reader import load_config
from .config_reader import get_product_config
from .helpers import (
clear_pending_console_input,
click_page_body,
@@ -23,7 +23,7 @@ from .step_actions import StepActions
def _bounce_token_generation(driver, step, config):
token = config["trading_token_idas"]
token = config["token"]
interactions = random.randint(2, 4)
open_trading_maintenance_page(driver, Locator.tokengeneration)
@@ -67,7 +67,7 @@ def _bounce_trading_session(driver, step, config):
@pytest.mark.fake_activity
def test_random_fake_activity_no_save(driver, test_context):
config = load_config()
config = get_product_config("IDAS")
wait = WebDriverWait(driver, 20)
step = StepActions(driver, wait, test_context)
+33
View File
@@ -0,0 +1,33 @@
from types import SimpleNamespace
import pytest
from tests.conftest import _get_run_prefix, _setup_item_prefix, get_run_id
@pytest.mark.parametrize("path,product,expected", [
("tests/setup/test_combined_setup.py", "IDAS", "IDAS_SESSION_TOKEN"),
("tests/setup/test_combined_setup.py", "RTM", "RTM_SESSION_TOKEN"),
("tests/setup/tokens/test_trading_token.py", "RTM", "RTM_TOKEN"),
("tests/setup/sessions/test_session.py", "RTM", "RTM_SESSION"),
])
def test_setup_artifacts_use_collected_product_and_flow(path, product, expected):
item = SimpleNamespace(nodeid=f"{path}::test[{product}]", callspec=SimpleNamespace(
params={"config": {"product_name": product}},
))
assert _setup_item_prefix(item) == expected
@pytest.mark.parametrize("path", ["tests/setup", "tests\\setup", "tests/setup/test_combined_setup.py"])
def test_setup_report_does_not_use_regression_config(path):
config = SimpleNamespace(invocation_params=SimpleNamespace(args=(path, "-k", "RTM")))
assert _get_run_prefix(config) == "PXIL_SETUP"
def test_mixed_run_ids_are_cached_separately(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "reports/screenshots/RTM_TOKEN_004").mkdir(parents=True)
config = SimpleNamespace()
assert get_run_id(config, "RTM_TOKEN") == "RTM_TOKEN_005"
assert get_run_id(config, "IDAS_SESSION_TOKEN") == "IDAS_SESSION_TOKEN_001"
assert get_run_id(config, "RTM_TOKEN") == "RTM_TOKEN_005"
+54
View File
@@ -0,0 +1,54 @@
from copy import deepcopy
import pytest
from tests.config_reader import get_product_config, get_setup_configs
@pytest.mark.parametrize("common_session", [False, True])
@pytest.mark.parametrize("common_token", [False, True])
@pytest.mark.parametrize("session_override", [None, False, True])
@pytest.mark.parametrize("token_override", [None, False, True])
def test_product_flags_inherit_or_override(common_session, common_token, session_override, token_override):
product = {"me_instance": "55", "token": {"market_code": "NATIONAL", "product_codes": "TEST"}}
if session_override is not None:
product["run_session"] = session_override
if token_override is not None:
product["run_token"] = token_override
config = {"setup": {"run_session": common_session, "run_token": common_token}, "products": {"RTM": product}}
session = common_session if session_override is None else session_override
token = common_token if token_override is None else token_override
expected = "combined" if session and token else "session" if session else "token" if token else None
for mode in ("combined", "session", "token"):
assert len(get_setup_configs(mode, config)) == int(mode == expected)
def test_mixed_products_defaults_and_no_mutation():
config = {
"setup": {"run_session": True, "run_token": False, "session": {"start_offset_minutes": 7, "end_time": "20:30"}},
"products": {
"IDAS": {"me_instance": "55", "session": {"end_time": "21:00"}},
"RTM": {"run_session": False, "run_token": True, "token": {"market_code": "NATIONAL", "product_codes": ["RTM1"]}},
"REC": {"enabled": False},
"RA": {"run_session": False},
},
}
original = deepcopy(config)
assert [c["product_name"] for c in get_setup_configs("session", config)] == ["IDAS"]
assert [c["product_name"] for c in get_setup_configs("token", config)] == ["RTM"]
assert get_setup_configs("combined", config) == []
resolved = get_product_config("IDAS", config)
assert resolved["setup"]["session"] == {"start_offset_minutes": 7, "end_time": "21:00"}
assert config == original
def test_missing_required_settings_fail_before_browser_start():
with pytest.raises(ValueError, match="me_instance"):
get_setup_configs("session", {"setup": {"run_token": False}, "products": {"REC": {}}})
with pytest.raises(ValueError, match="market_code"):
get_setup_configs("token", {"setup": {"run_session": False}, "products": {"REC": {}}})
def test_legacy_config_remains_supported():
config = {"product": {"me_instance": "55"}, "trading_token_idas": {"market_code": "NATIONAL", "product_code": "IDAS4"}}
assert get_setup_configs("combined", config)[0]["token"] == config["trading_token_idas"]
+71
View File
@@ -0,0 +1,71 @@
from unittest.mock import Mock
from datetime import datetime
from tests.locators import Locator
from tests.setup.tokens import test_trading_token as actions
def test_rtm_saves_generator_token_without_idas_activation(monkeypatch):
events = []
monkeypatch.setattr(actions, "open_trading_maintenance_page", lambda driver, menu: events.append(menu))
monkeypatch.setattr(actions, "handle_transient_popups", lambda driver: None)
monkeypatch.setattr(
actions, "select_bootstrap_dropdown_by_id",
lambda driver, field, value, exact=False: events.append((field, value, exact)),
)
idas_flow = Mock()
monkeypatch.setattr(actions, "create_and_activate_token", idas_flow)
step = Mock()
step.click.side_effect = lambda locator, name: events.append(locator[1])
monkeypatch.setattr(actions, "get_rtm_token_times", lambda token: (
"23/09/2026 00:00", "23/09/2026 23:59", "10:15", "20:00",
))
monkeypatch.setattr(actions, "type_first_visible_input", lambda driver, locator, value: events.append((locator, value)))
actions.run_token_setup(Mock(), step, {
"product_type": "RTM", "market_code": "NATIONAL",
"product_code": "RTM", "generation_mode": "RTM",
})
assert events == [
Locator.tokengenerator,
("marketCode", "NATIONAL", True),
("productCode", "RTM", True),
("tokenMstr.genMode", "RTM", True),
Locator.addnewtokenbtn,
(Locator.rtmdeliveryfromdate, "23/09/2026 00:00"),
(Locator.rtmdeliverytodate, "23/09/2026 23:59"),
(Locator.rtmsessionstarttime, "10:15"),
(Locator.rtmsessionendtime, "20:00"),
Locator.savetokenbtn,
]
step.type.assert_not_called()
idas_flow.assert_not_called()
def test_idas_keeps_existing_creation_and_activation_flow(monkeypatch):
idas_flow = Mock()
rtm_flow = Mock()
monkeypatch.setattr(actions, "create_and_activate_token", idas_flow)
monkeypatch.setattr(actions, "open_rtm_new_token", rtm_flow)
driver, step = Mock(), Mock()
token = {"product_type": "IDAS", "product_code": "IDAS4"}
actions.run_token_setup(driver, step, token)
idas_flow.assert_called_once_with(driver, step, token)
rtm_flow.assert_not_called()
def test_rtm_token_timing_defaults_and_overrides():
now = datetime(2026, 9, 23, 10, 58)
assert actions.get_rtm_token_times({}, now) == (
"23/09/2026 00:00", "23/09/2026 23:59", "11:03", "20:00",
)
assert actions.get_rtm_token_times({
"delivery_offset_days": 1,
"delivery_from_time": "01:30", "delivery_to_time": "22:00",
"session_start_offset_minutes": 12, "session_end_time": "21:15",
}, now) == (
"24/09/2026 01:30", "24/09/2026 22:00", "11:10", "21:15",
)