pyplaykit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
core/__init__.py ADDED
File without changes
core/base_page.py ADDED
@@ -0,0 +1,94 @@
1
+ """Base page object with reusable browser interactions and waits."""
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any
5
+
6
+ from playwright.sync_api import Locator, Page, expect
7
+
8
+ from resilience.locator_recovery import resolve_visible_locator, write_resilience_audit_event
9
+
10
+
11
+ class BasePage:
12
+ """Base class for all page objects."""
13
+
14
+ def __init__(self, page: Page, resilience_policy: dict[str, Any] | None = None) -> None:
15
+ self.page = page
16
+ self.resilience_policy = resilience_policy or {}
17
+
18
+ def navigate(self, url: str) -> None:
19
+ """Navigate to a URL and wait for DOM readiness."""
20
+ self.page.goto(url, wait_until="domcontentloaded")
21
+
22
+ def wait_for_url_contains(self, url_part: str) -> None:
23
+ """Wait until current page URL contains expected partial value."""
24
+ expect(self.page).to_have_url(lambda current_url: url_part in current_url)
25
+
26
+ def wait_for_visible(
27
+ self,
28
+ selector: str,
29
+ fallback_selectors: list[str] | None = None,
30
+ dom_retry_count: int | None = None,
31
+ ) -> Locator:
32
+ """Wait for selector visibility with optional fallback chain and retry-based DOM re-evaluation."""
33
+ policy = self.resilience_policy
34
+ policy_enabled = bool(policy.get("enabled", False))
35
+
36
+ if dom_retry_count is None:
37
+ dom_retry_count = int(policy.get("dom_retry_count", 0)) if policy_enabled else 0
38
+ if fallback_selectors is None:
39
+ fallback_selectors = list(policy.get("fallback_selectors", [])) if policy_enabled else []
40
+
41
+ if not fallback_selectors and int(dom_retry_count) <= 0:
42
+ locator = self.page.locator(selector)
43
+ expect(locator).to_be_visible()
44
+ return locator
45
+
46
+ locator, resolved_selector, resolved_attempt, recovery_event = resolve_visible_locator(
47
+ locator_getter=self.page.locator,
48
+ visible_assertion=lambda candidate: expect(candidate).to_be_visible(),
49
+ primary_selector=selector,
50
+ fallback_selectors=fallback_selectors,
51
+ dom_retry_count=int(dom_retry_count),
52
+ confidence_decay_per_hop=float(policy.get("confidence_decay_per_hop", 0.15)),
53
+ confidence_decay_per_retry=float(policy.get("confidence_decay_per_retry", 0.1)),
54
+ )
55
+
56
+ if recovery_event.get("used_fallback") or recovery_event.get("used_retry"):
57
+ audit_event = {
58
+ "event_type": "locator_recovery",
59
+ "timestamp_utc": datetime.now(timezone.utc).isoformat(),
60
+ "primary_selector": selector,
61
+ "resolved_selector": resolved_selector,
62
+ "resolved_attempt": resolved_attempt,
63
+ **recovery_event,
64
+ }
65
+ self.resilience_policy["last_recovery_event"] = audit_event
66
+ if bool(policy.get("audit_enabled", False)) and policy.get("audit_log_path"):
67
+ write_resilience_audit_event(str(policy.get("audit_log_path")), audit_event)
68
+
69
+ return locator
70
+
71
+ def click(self, selector: str) -> None:
72
+ """Click an element after waiting for visibility."""
73
+ self.wait_for_visible(selector).click()
74
+
75
+ def fill(self, selector: str, value: str) -> None:
76
+ """Fill field after waiting for visibility."""
77
+ self.wait_for_visible(selector).fill(value)
78
+
79
+ def text(self, selector: str) -> str:
80
+ """Return inner text after waiting for visibility."""
81
+ return self.wait_for_visible(selector).inner_text().strip()
82
+
83
+ def is_visible(self, selector: str, timeout: float = 5000) -> bool:
84
+ """Return visibility state for selector, auto-waiting for it to be visible."""
85
+ locator = self.page.locator(selector)
86
+ try:
87
+ expect(locator).to_be_visible(timeout=timeout)
88
+ return True
89
+ except AssertionError:
90
+ return False
91
+
92
+ def get_title(self) -> str:
93
+ """Return current page title."""
94
+ return self.page.title()
core/base_test.py ADDED
@@ -0,0 +1,27 @@
1
+ """Base test abstractions for common runtime metadata and assertions."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from core.base_page import BasePage
6
+
7
+
8
+ @dataclass
9
+ class BaseTestContext:
10
+ """Container for common test runtime metadata."""
11
+
12
+ environment: str
13
+ browser: str
14
+ headless: bool
15
+
16
+
17
+ class BaseTest:
18
+ """Base test utility methods shared across test modules."""
19
+
20
+ @staticmethod
21
+ def assert_page_title_contains(page_object: BasePage, expected_text: str) -> None:
22
+ """Assert that page title contains expected text."""
23
+ actual_title = page_object.get_title()
24
+ if expected_text not in actual_title:
25
+ raise AssertionError(
26
+ f"Expected title to contain '{expected_text}', actual title: '{actual_title}'."
27
+ )
@@ -0,0 +1,30 @@
1
+ """Browser factory for cross-browser launch control."""
2
+
3
+ from playwright.sync_api import Browser, BrowserType, Playwright
4
+
5
+
6
+ class BrowserFactory:
7
+ """Create Playwright browser instances based on configuration."""
8
+
9
+ _SUPPORTED_BROWSERS = {
10
+ "chromium": "chromium",
11
+ "firefox": "firefox",
12
+ "webkit": "webkit",
13
+ }
14
+
15
+ @classmethod
16
+ def create_browser(
17
+ cls,
18
+ playwright: Playwright,
19
+ browser_name: str,
20
+ headless: bool = True,
21
+ slow_mo_ms: int = 0,
22
+ ) -> Browser:
23
+ """Launch and return a browser instance for the given browser name."""
24
+ normalized_name = browser_name.strip().lower()
25
+ if normalized_name not in cls._SUPPORTED_BROWSERS:
26
+ supported = ", ".join(sorted(cls._SUPPORTED_BROWSERS.keys()))
27
+ raise ValueError(f"Unsupported browser '{browser_name}'. Supported: {supported}")
28
+
29
+ browser_type: BrowserType = getattr(playwright, cls._SUPPORTED_BROWSERS[normalized_name])
30
+ return browser_type.launch(headless=headless, slow_mo=slow_mo_ms)
@@ -0,0 +1,40 @@
1
+ """Playwright lifecycle manager for centralized runtime control."""
2
+
3
+ from contextlib import AbstractContextManager
4
+ from typing import Optional
5
+
6
+ from playwright.sync_api import Playwright, sync_playwright
7
+
8
+
9
+ class PlaywrightManager(AbstractContextManager):
10
+ """Manage Playwright process startup and shutdown for the test session."""
11
+
12
+ def __init__(self) -> None:
13
+ self._playwright: Optional[Playwright] = None
14
+
15
+ @property
16
+ def instance(self) -> Playwright:
17
+ """Return active Playwright instance."""
18
+ if self._playwright is None:
19
+ raise RuntimeError("Playwright is not started. Call start() first.")
20
+ return self._playwright
21
+
22
+ def start(self) -> Playwright:
23
+ """Start Playwright if not active and return the current instance."""
24
+ if self._playwright is not None:
25
+ return self._playwright
26
+ self._playwright = sync_playwright().start()
27
+ return self._playwright
28
+
29
+ def stop(self) -> None:
30
+ """Stop Playwright if active."""
31
+ if self._playwright is not None:
32
+ self._playwright.stop()
33
+ self._playwright = None
34
+
35
+ def __enter__(self) -> "PlaywrightManager":
36
+ self.start()
37
+ return self
38
+
39
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
40
+ self.stop()
dist_pkg/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Internal distribution packaging helpers."""
2
+
3
+ from .distribution import build_distribution_plan, normalize_distribution_name
4
+
5
+ __all__ = ["normalize_distribution_name", "build_distribution_plan"]
@@ -0,0 +1,51 @@
1
+ """Helpers for packaging the framework for internal distribution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, Mapping
7
+
8
+
9
+ def normalize_distribution_name(framework_name: str) -> str:
10
+ """Normalize display name into a package-safe distribution name."""
11
+ normalized = re.sub(r"[^a-z0-9]+", "-", framework_name.strip().lower())
12
+ normalized = re.sub(r"-+", "-", normalized).strip("-")
13
+ if not normalized:
14
+ raise ValueError("framework_name must contain at least one alphanumeric character")
15
+ return normalized
16
+
17
+
18
+ def build_distribution_plan(
19
+ framework_config: Mapping[str, Any],
20
+ *,
21
+ version: str,
22
+ output_dir: str = "dist",
23
+ ) -> dict[str, Any]:
24
+ """Build an internal distribution plan for wheel/sdist artifacts."""
25
+ if not version or not version.strip():
26
+ raise ValueError("version must be a non-empty string")
27
+
28
+ framework = framework_config.get("framework", {}) if isinstance(framework_config, Mapping) else {}
29
+ framework_name = str(framework.get("name", "PyPlayKit"))
30
+ distribution_name = normalize_distribution_name(framework_name)
31
+
32
+ command = [
33
+ "python",
34
+ "-m",
35
+ "build",
36
+ "--sdist",
37
+ "--wheel",
38
+ "--outdir",
39
+ output_dir,
40
+ ]
41
+ artifact_patterns = [
42
+ f"{output_dir}/{distribution_name}-{version}.tar.gz",
43
+ f"{output_dir}/{distribution_name}-{version}-*.whl",
44
+ ]
45
+
46
+ return {
47
+ "distribution_name": distribution_name,
48
+ "version": version,
49
+ "build_command": command,
50
+ "artifact_patterns": artifact_patterns,
51
+ }
@@ -0,0 +1,21 @@
1
+ """Integration adapters for external enterprise systems."""
2
+
3
+ from .adapters import (
4
+ ApiJiraAdapter,
5
+ ApiTestManagementAdapter,
6
+ build_api_adapters,
7
+ FileJiraAdapter,
8
+ FileTestManagementAdapter,
9
+ IntegrationAdapter,
10
+ build_file_adapters,
11
+ )
12
+
13
+ __all__ = [
14
+ "IntegrationAdapter",
15
+ "FileJiraAdapter",
16
+ "FileTestManagementAdapter",
17
+ "ApiJiraAdapter",
18
+ "ApiTestManagementAdapter",
19
+ "build_file_adapters",
20
+ "build_api_adapters",
21
+ ]
@@ -0,0 +1,155 @@
1
+ """Integration adapter interfaces and file-based adapter implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from abc import ABC, abstractmethod
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Mapping
10
+
11
+ from utils.api_client import ApiClient
12
+
13
+
14
+ class IntegrationAdapter(ABC):
15
+ """Contract for exporting execution results to enterprise systems."""
16
+
17
+ system_name: str
18
+
19
+ @abstractmethod
20
+ def export(self, payload: Mapping[str, Any]) -> str:
21
+ """Export execution payload and return written artifact path."""
22
+
23
+
24
+ class _FileIntegrationAdapter(IntegrationAdapter):
25
+ """Common file export behavior for integration adapters."""
26
+
27
+ def __init__(self, output_path: str, run_metadata: Mapping[str, Any] | None = None) -> None:
28
+ if not output_path or not output_path.strip():
29
+ raise ValueError("output_path must be a non-empty string")
30
+ self.output_path = output_path
31
+ self.run_metadata = dict(run_metadata or {})
32
+
33
+ def export(self, payload: Mapping[str, Any]) -> str:
34
+ if not isinstance(payload, Mapping):
35
+ raise TypeError("payload must be a mapping")
36
+
37
+ output = Path(self.output_path)
38
+ output.parent.mkdir(parents=True, exist_ok=True)
39
+ envelope = {
40
+ "target_system": self.system_name,
41
+ "generated_at_utc": datetime.now(timezone.utc).isoformat(),
42
+ "run_metadata": self.run_metadata,
43
+ "payload": dict(payload),
44
+ }
45
+ output.write_text(json.dumps(envelope, indent=2), encoding="utf-8")
46
+ return str(output.resolve())
47
+
48
+
49
+ class FileJiraAdapter(_FileIntegrationAdapter):
50
+ """File-based export adapter for Jira ingestion pipelines."""
51
+
52
+ system_name = "jira"
53
+
54
+
55
+ class FileTestManagementAdapter(_FileIntegrationAdapter):
56
+ """File-based export adapter for test management ingestion pipelines."""
57
+
58
+ system_name = "test_management"
59
+
60
+
61
+ class _ApiIntegrationAdapter(IntegrationAdapter):
62
+ """Common API export behavior for integration adapters."""
63
+
64
+ def __init__(
65
+ self,
66
+ api_client: ApiClient,
67
+ endpoint: str,
68
+ run_metadata: Mapping[str, Any] | None = None,
69
+ ) -> None:
70
+ if not endpoint or not endpoint.strip():
71
+ raise ValueError("endpoint must be a non-empty string")
72
+ self.api_client = api_client
73
+ self.endpoint = endpoint
74
+ self.run_metadata = dict(run_metadata or {})
75
+
76
+ def export(self, payload: Mapping[str, Any]) -> str:
77
+ if not isinstance(payload, Mapping):
78
+ raise TypeError("payload must be a mapping")
79
+
80
+ envelope = {
81
+ "target_system": self.system_name,
82
+ "generated_at_utc": datetime.now(timezone.utc).isoformat(),
83
+ "run_metadata": self.run_metadata,
84
+ "payload": dict(payload),
85
+ }
86
+ response = self.api_client.post(self.endpoint, json_body=envelope)
87
+ if response.status_code >= 400:
88
+ raise RuntimeError(
89
+ f"{self.system_name} integration export failed with status {response.status_code}"
90
+ )
91
+ return f"api:{self.system_name}:{response.status_code}"
92
+
93
+
94
+ class ApiJiraAdapter(_ApiIntegrationAdapter):
95
+ """API-based export adapter for Jira integrations."""
96
+
97
+ system_name = "jira"
98
+
99
+
100
+ class ApiTestManagementAdapter(_ApiIntegrationAdapter):
101
+ """API-based export adapter for test management integrations."""
102
+
103
+ system_name = "test_management"
104
+
105
+
106
+ def build_file_adapters(config: Mapping[str, Any], run_metadata: Mapping[str, Any] | None = None) -> dict[str, IntegrationAdapter]:
107
+ """Build file-based integration adapters from config."""
108
+ if not isinstance(config, Mapping):
109
+ return {}
110
+
111
+ if not bool(config.get("enabled", False)):
112
+ return {}
113
+
114
+ jira_path = str(config.get("jira_export_path", "")).strip()
115
+ tms_path = str(config.get("test_management_export_path", "")).strip()
116
+
117
+ if not jira_path:
118
+ raise ValueError("integrations.jira_export_path is required when integrations.enabled=true")
119
+ if not tms_path:
120
+ raise ValueError("integrations.test_management_export_path is required when integrations.enabled=true")
121
+
122
+ return {
123
+ "jira": FileJiraAdapter(jira_path, run_metadata=run_metadata),
124
+ "test_management": FileTestManagementAdapter(tms_path, run_metadata=run_metadata),
125
+ }
126
+
127
+
128
+ def build_api_adapters(
129
+ config: Mapping[str, Any],
130
+ api_client: ApiClient,
131
+ run_metadata: Mapping[str, Any] | None = None,
132
+ ) -> dict[str, IntegrationAdapter]:
133
+ """Build API-based integration adapters from config."""
134
+ if not isinstance(config, Mapping):
135
+ return {}
136
+
137
+ if not bool(config.get("api_enabled", False)):
138
+ return {}
139
+
140
+ jira_endpoint = str(config.get("jira_api_endpoint", "")).strip()
141
+ tms_endpoint = str(config.get("test_management_api_endpoint", "")).strip()
142
+
143
+ if not jira_endpoint:
144
+ raise ValueError("integrations.jira_api_endpoint is required when integrations.api_enabled=true")
145
+ if not tms_endpoint:
146
+ raise ValueError("integrations.test_management_api_endpoint is required when integrations.api_enabled=true")
147
+
148
+ return {
149
+ "jira_api": ApiJiraAdapter(api_client=api_client, endpoint=jira_endpoint, run_metadata=run_metadata),
150
+ "test_management_api": ApiTestManagementAdapter(
151
+ api_client=api_client,
152
+ endpoint=tms_endpoint,
153
+ run_metadata=run_metadata,
154
+ ),
155
+ }
@@ -0,0 +1,5 @@
1
+ """Orchestration exports for dependency-aware execution planning."""
2
+
3
+ from .planner import build_orchestration_plan
4
+
5
+ __all__ = ["build_orchestration_plan"]
@@ -0,0 +1,137 @@
1
+ """Suite orchestration planner with dependency-aware execution ordering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from typing import Any, Mapping
7
+
8
+
9
+ def build_orchestration_plan(config: Mapping[str, Any]) -> dict[str, Any]:
10
+ """Build orchestration plan from config with dependency and pipeline validation."""
11
+ if not isinstance(config, Mapping) or not bool(config.get("enabled", False)):
12
+ return {
13
+ "enabled": False,
14
+ "dependency_graph": {},
15
+ "suite_order": [],
16
+ "pipelines": [],
17
+ }
18
+
19
+ suites = config.get("suites", [])
20
+ if not isinstance(suites, list):
21
+ raise ValueError("orchestration.suites must be a list")
22
+
23
+ dependency_graph: dict[str, list[str]] = {}
24
+ for suite in suites:
25
+ if not isinstance(suite, Mapping):
26
+ raise ValueError("Each orchestration suite entry must be a mapping")
27
+ suite_id = str(suite.get("id", "")).strip()
28
+ depends_on = suite.get("depends_on", [])
29
+ if not suite_id:
30
+ raise ValueError("Each orchestration suite must define a non-empty id")
31
+ if not isinstance(depends_on, list):
32
+ raise ValueError(f"orchestration suite '{suite_id}' depends_on must be a list")
33
+ if suite_id in dependency_graph:
34
+ raise ValueError(f"Duplicate orchestration suite id: {suite_id}")
35
+ dependency_graph[suite_id] = [str(dep).strip() for dep in depends_on]
36
+
37
+ for suite_id, dependencies in dependency_graph.items():
38
+ for dependency in dependencies:
39
+ if dependency not in dependency_graph:
40
+ raise ValueError(f"Suite '{suite_id}' depends on unknown suite '{dependency}'")
41
+
42
+ suite_order = _resolve_suite_order(dependency_graph)
43
+ pipelines = _normalize_pipelines(config.get("pipelines", []), dependency_graph)
44
+
45
+ return {
46
+ "enabled": True,
47
+ "dependency_graph": dependency_graph,
48
+ "suite_order": suite_order,
49
+ "pipelines": pipelines,
50
+ }
51
+
52
+
53
+ def _resolve_suite_order(dependency_graph: Mapping[str, list[str]]) -> list[str]:
54
+ dependents_by_suite: dict[str, list[str]] = {suite_id: [] for suite_id in dependency_graph}
55
+ remaining_dependencies: dict[str, int] = {
56
+ suite_id: len(dependencies)
57
+ for suite_id, dependencies in dependency_graph.items()
58
+ }
59
+ for suite_id, dependencies in dependency_graph.items():
60
+ for dependency in dependencies:
61
+ dependents_by_suite[dependency].append(suite_id)
62
+
63
+ ready = deque([suite_id for suite_id in dependency_graph if remaining_dependencies[suite_id] == 0])
64
+ ordered: list[str] = []
65
+
66
+ while ready:
67
+ suite_id = ready.popleft()
68
+ ordered.append(suite_id)
69
+ for dependent in dependents_by_suite[suite_id]:
70
+ remaining_dependencies[dependent] -= 1
71
+ if remaining_dependencies[dependent] == 0:
72
+ ready.append(dependent)
73
+
74
+ if len(ordered) != len(dependency_graph):
75
+ raise ValueError("Orchestration dependency graph contains a cycle")
76
+ return ordered
77
+
78
+
79
+ def _normalize_pipelines(
80
+ pipelines_config: Any,
81
+ dependency_graph: Mapping[str, list[str]],
82
+ ) -> list[dict[str, Any]]:
83
+ if not pipelines_config:
84
+ return []
85
+ if not isinstance(pipelines_config, list):
86
+ raise ValueError("orchestration.pipelines must be a list")
87
+
88
+ normalized: list[dict[str, Any]] = []
89
+ known_suites = set(dependency_graph.keys())
90
+
91
+ for pipeline in pipelines_config:
92
+ if not isinstance(pipeline, Mapping):
93
+ raise ValueError("Each orchestration pipeline entry must be a mapping")
94
+ pipeline_name = str(pipeline.get("name", "")).strip()
95
+ stages = pipeline.get("stages", [])
96
+ if not pipeline_name:
97
+ raise ValueError("Each orchestration pipeline must define a non-empty name")
98
+ if not isinstance(stages, list):
99
+ raise ValueError(f"orchestration pipeline '{pipeline_name}' stages must be a list")
100
+
101
+ stage_index_by_suite: dict[str, int] = {}
102
+ normalized_stages: list[dict[str, Any]] = []
103
+ for index, stage in enumerate(stages):
104
+ if not isinstance(stage, Mapping):
105
+ raise ValueError(f"Pipeline '{pipeline_name}' stage entry must be a mapping")
106
+ stage_name = str(stage.get("name", "")).strip()
107
+ suites = stage.get("suites", [])
108
+ if not stage_name:
109
+ raise ValueError(f"Pipeline '{pipeline_name}' stage must define a non-empty name")
110
+ if not isinstance(suites, list):
111
+ raise ValueError(f"Pipeline '{pipeline_name}' stage '{stage_name}' suites must be a list")
112
+
113
+ normalized_suites: list[str] = []
114
+ for suite_name in suites:
115
+ suite_id = str(suite_name).strip()
116
+ if suite_id not in known_suites:
117
+ raise ValueError(f"Pipeline '{pipeline_name}' references unknown suite '{suite_id}'")
118
+ if suite_id in stage_index_by_suite:
119
+ raise ValueError(f"Pipeline '{pipeline_name}' includes suite '{suite_id}' multiple times")
120
+ stage_index_by_suite[suite_id] = index
121
+ normalized_suites.append(suite_id)
122
+ normalized_stages.append({"name": stage_name, "suites": normalized_suites})
123
+
124
+ for suite_id, dependencies in dependency_graph.items():
125
+ if suite_id not in stage_index_by_suite:
126
+ continue
127
+ for dependency in dependencies:
128
+ if dependency not in stage_index_by_suite:
129
+ continue
130
+ if stage_index_by_suite[dependency] > stage_index_by_suite[suite_id]:
131
+ raise ValueError(
132
+ f"Pipeline '{pipeline_name}' places dependency '{dependency}' after '{suite_id}'"
133
+ )
134
+
135
+ normalized.append({"name": pipeline_name, "stages": normalized_stages})
136
+
137
+ return normalized
pages/__init__.py ADDED
File without changes
@@ -0,0 +1,24 @@
1
+ """Dashboard page object for SauceDemo inventory page."""
2
+
3
+ from core.base_page import BasePage
4
+
5
+
6
+ class DashboardPage(BasePage):
7
+ """Page object representing dashboard page operations."""
8
+
9
+ PAGE_TITLE = "[data-test='title']"
10
+ INVENTORY_ITEMS = ".inventory_item"
11
+ CART_LINK = "[data-test='shopping-cart-link']"
12
+
13
+ def is_loaded(self) -> bool:
14
+ """Return true when dashboard page key elements are visible."""
15
+ return self.is_visible(self.PAGE_TITLE) and self.is_visible(self.CART_LINK)
16
+
17
+ def get_inventory_count(self) -> int:
18
+ """Return number of inventory cards displayed on dashboard."""
19
+ self.wait_for_visible(self.PAGE_TITLE)
20
+ return self.page.locator(self.INVENTORY_ITEMS).count()
21
+
22
+ def open_cart(self) -> None:
23
+ """Open cart page from dashboard."""
24
+ self.click(self.CART_LINK)
pages/login_page.py ADDED
@@ -0,0 +1,31 @@
1
+ """Login page object for SauceDemo sample application."""
2
+
3
+ from core.base_page import BasePage
4
+
5
+
6
+ class LoginPage(BasePage):
7
+ """Page object representing login page operations."""
8
+
9
+ USERNAME_INPUT = "#user-name"
10
+ PASSWORD_INPUT = "#password"
11
+ LOGIN_BUTTON = "#login-button"
12
+ ERROR_BANNER = "[data-test='error']"
13
+
14
+ def open(self, base_url: str) -> None:
15
+ """Navigate to login page."""
16
+ self.navigate(base_url)
17
+ self.wait_for_visible(self.USERNAME_INPUT)
18
+
19
+ def login(self, username: str, password: str) -> None:
20
+ """Perform login action with provided credentials."""
21
+ self.fill(self.USERNAME_INPUT, username)
22
+ self.fill(self.PASSWORD_INPUT, password)
23
+ self.click(self.LOGIN_BUTTON)
24
+
25
+ def get_error_message(self) -> str:
26
+ """Return login failure message."""
27
+ return self.text(self.ERROR_BANNER)
28
+
29
+ def is_loaded(self) -> bool:
30
+ """Return true when login page fields are visible."""
31
+ return self.is_visible(self.USERNAME_INPUT) and self.is_visible(self.LOGIN_BUTTON)