Add separate Playwright session and token setup suite

This commit is contained in:
2026-09-10 23:08:02 +05:30
parent 4749efe45f
commit 37e0ddae3d
15 changed files with 725 additions and 9 deletions
+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()