Author SHA1 Message Date
codexuser 37e0ddae3d Add separate Playwright session and token setup suite 2026-09-10 23:08:02 +05:30
15 changed files with 725 additions and 9 deletions
+3
View File
@@ -1,6 +1,9 @@
# Virtual environments
venv/
.venv/
.venv-playwright/
reports/playwright/
test-results/
# Python cache
__pycache__/
+82
View File
@@ -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.
+5
View File
@@ -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
View File
@@ -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"
}
}
}
+7
View File
@@ -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
+156
View File
@@ -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 &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
@@ -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`.
View File
+74
View File
@@ -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
]
+6
View File
@@ -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")
+52
View File
@@ -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
+209
View File
@@ -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")
+10
View File
@@ -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
View File
@@ -1,4 +1,5 @@
[pytest]
norecursedirs = .* venv .venv-playwright playwright_tests playwright_checks
markers =
session: session creation
trading_token: trading token creation
+4
View File
@@ -0,0 +1,4 @@
pytest==8.4.1
pytest-html==4.2.0
playwright==1.62.0
pytest-playwright==0.9.0