.
This commit is contained in:
7
config/hosts.yaml
Normal file
7
config/hosts.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
hosts:
|
||||
- id: desktop
|
||||
host: 192.168.50.185
|
||||
cdp_port: 9223
|
||||
- id: laptop
|
||||
host: 192.168.50.152
|
||||
cdp_port: 9222
|
||||
0
src/guide/__init__.py
Normal file
0
src/guide/__init__.py
Normal file
0
src/guide/app/__init__.py
Normal file
0
src/guide/app/__init__.py
Normal file
20
src/guide/app/actions/__init__.py
Normal file
20
src/guide/app/actions/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from guide.app.actions.base import ActionRegistry, DemoAction
|
||||
from guide.app.actions.intake import FillIntakeBasicAction
|
||||
from guide.app.actions.sourcing import AddThreeSuppliersAction
|
||||
|
||||
|
||||
def default_registry() -> ActionRegistry:
|
||||
actions: list[DemoAction] = [
|
||||
FillIntakeBasicAction(),
|
||||
AddThreeSuppliersAction(),
|
||||
]
|
||||
return ActionRegistry(actions)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ActionRegistry",
|
||||
"DemoAction",
|
||||
"default_registry",
|
||||
"FillIntakeBasicAction",
|
||||
"AddThreeSuppliersAction",
|
||||
]
|
||||
31
src/guide/app/actions/base.py
Normal file
31
src/guide/app/actions/base.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from typing import Iterable, Protocol
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from guide.app.domain.models import ActionContext, ActionMetadata, ActionResult
|
||||
|
||||
|
||||
class DemoAction(Protocol):
|
||||
id: str
|
||||
description: str
|
||||
category: str
|
||||
|
||||
async def run(self, page: Page, context: ActionContext) -> ActionResult: ...
|
||||
|
||||
|
||||
class ActionRegistry:
|
||||
"""Simple mapping of action id -> action instance."""
|
||||
|
||||
def __init__(self, actions: Iterable[DemoAction]):
|
||||
self._actions: dict[str, DemoAction] = {action.id: action for action in actions}
|
||||
|
||||
def get(self, action_id: str) -> DemoAction:
|
||||
if action_id not in self._actions:
|
||||
raise KeyError(f"Unknown action '{action_id}'")
|
||||
return self._actions[action_id]
|
||||
|
||||
def list_metadata(self) -> list[ActionMetadata]:
|
||||
return [
|
||||
ActionMetadata(id=action.id, description=action.description, category=action.category)
|
||||
for action in self._actions.values()
|
||||
]
|
||||
3
src/guide/app/actions/intake/__init__.py
Normal file
3
src/guide/app/actions/intake/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from guide.app.actions.intake.basic import FillIntakeBasicAction
|
||||
|
||||
__all__ = ["FillIntakeBasicAction"]
|
||||
16
src/guide/app/actions/intake/basic.py
Normal file
16
src/guide/app/actions/intake/basic.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from playwright.async_api import Page
|
||||
|
||||
from guide.app.actions.base import DemoAction
|
||||
from guide.app.domain.models import ActionContext, ActionResult
|
||||
from guide.app.strings import demo_texts, selectors
|
||||
|
||||
|
||||
class FillIntakeBasicAction(DemoAction):
|
||||
id = "fill-intake-basic"
|
||||
description = "Fill the intake description and advance to the next step."
|
||||
category = "intake"
|
||||
|
||||
async def run(self, page: Page, context: ActionContext) -> ActionResult:
|
||||
await page.fill(selectors.INTAKE.DESCRIPTION_FIELD, demo_texts.INTAKE.CONVEYOR_BELT_REQUEST)
|
||||
await page.click(selectors.INTAKE.NEXT_BUTTON)
|
||||
return ActionResult(details={"message": "Intake filled"})
|
||||
3
src/guide/app/actions/sourcing/__init__.py
Normal file
3
src/guide/app/actions/sourcing/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from guide.app.actions.sourcing.add_suppliers import AddThreeSuppliersAction
|
||||
|
||||
__all__ = ["AddThreeSuppliersAction"]
|
||||
17
src/guide/app/actions/sourcing/add_suppliers.py
Normal file
17
src/guide/app/actions/sourcing/add_suppliers.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from playwright.async_api import Page
|
||||
|
||||
from guide.app.actions.base import DemoAction
|
||||
from guide.app.domain.models import ActionContext, ActionResult
|
||||
from guide.app.strings import demo_texts, selectors
|
||||
|
||||
|
||||
class AddThreeSuppliersAction(DemoAction):
|
||||
id = "add-three-suppliers"
|
||||
description = "Adds three default suppliers to the sourcing event."
|
||||
category = "sourcing"
|
||||
|
||||
async def run(self, page: Page, context: ActionContext) -> ActionResult:
|
||||
for supplier in demo_texts.SUPPLIERS.DEFAULT_TRIO:
|
||||
await page.fill(selectors.SOURCING.SUPPLIER_SEARCH_INPUT, supplier)
|
||||
await page.click(selectors.SOURCING.ADD_SUPPLIER_BUTTON)
|
||||
return ActionResult(details={"added_suppliers": demo_texts.SUPPLIERS.DEFAULT_TRIO})
|
||||
10
src/guide/app/api/__init__.py
Normal file
10
src/guide/app/api/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from guide.app.api.routes import actions, config, health
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(health.router)
|
||||
router.include_router(actions.router)
|
||||
router.include_router(config.router)
|
||||
|
||||
__all__ = ["router"]
|
||||
3
src/guide/app/api/routes/__init__.py
Normal file
3
src/guide/app/api/routes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from guide.app.api.routes import actions, config, health
|
||||
|
||||
__all__ = ["actions", "config", "health"]
|
||||
62
src/guide/app/api/routes/actions.py
Normal file
62
src/guide/app/api/routes/actions.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
from guide.app.actions import ActionRegistry
|
||||
from guide.app.browser.client import BrowserClient
|
||||
from guide.app.domain.models import ActionContext, ActionRequest, ActionResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _registry(request: Request) -> ActionRegistry:
|
||||
return request.app.state.action_registry
|
||||
|
||||
|
||||
def _browser_client(request: Request) -> BrowserClient:
|
||||
return request.app.state.browser_client
|
||||
|
||||
|
||||
@router.get("/actions")
|
||||
async def list_actions(registry: ActionRegistry = Depends(_registry)):
|
||||
return registry.list_metadata()
|
||||
|
||||
|
||||
@router.post("/actions/{action_id}/execute", response_model=ActionResponse)
|
||||
async def execute_action(
|
||||
action_id: str,
|
||||
payload: ActionRequest,
|
||||
registry: ActionRegistry = Depends(_registry),
|
||||
browser_client: BrowserClient = Depends(_browser_client),
|
||||
):
|
||||
try:
|
||||
action = registry.get(action_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Unknown action '{action_id}'",
|
||||
) from exc
|
||||
|
||||
context = ActionContext(
|
||||
browser_host_id=payload.browser_host_id or browser_client.settings.default_browser_host_id,
|
||||
params=payload.params or {},
|
||||
)
|
||||
|
||||
try:
|
||||
async with browser_client.open_page(context.browser_host_id) as page:
|
||||
result = await action.run(page, context)
|
||||
except Exception as exc:
|
||||
return ActionResponse(
|
||||
status="error",
|
||||
action_id=action_id,
|
||||
browser_host_id=context.browser_host_id,
|
||||
correlation_id=context.correlation_id,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
return ActionResponse(
|
||||
status=result.status,
|
||||
action_id=action_id,
|
||||
browser_host_id=context.browser_host_id,
|
||||
correlation_id=context.correlation_id,
|
||||
details=result.details,
|
||||
error=result.error,
|
||||
)
|
||||
17
src/guide/app/api/routes/config.py
Normal file
17
src/guide/app/api/routes/config.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from guide.app.domain.models import BrowserHostsResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _settings(request: Request):
|
||||
return request.app.state.settings
|
||||
|
||||
|
||||
@router.get("/config/browser-hosts", response_model=BrowserHostsResponse)
|
||||
async def list_browser_hosts(settings=Depends(_settings)) -> BrowserHostsResponse: # type: ignore[override]
|
||||
return BrowserHostsResponse(
|
||||
default_browser_host_id=settings.default_browser_host_id,
|
||||
browser_hosts=settings.browser_hosts,
|
||||
)
|
||||
8
src/guide/app/api/routes/health.py
Normal file
8
src/guide/app/api/routes/health.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/healthz")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
0
src/guide/app/browser/__init__.py
Normal file
0
src/guide/app/browser/__init__.py
Normal file
52
src/guide/app/browser/client.py
Normal file
52
src/guide/app/browser/client.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import contextlib
|
||||
from typing import AsyncIterator
|
||||
|
||||
from playwright.async_api import Browser, Page, async_playwright
|
||||
|
||||
from guide.app.core.config import AppSettings, BrowserHost
|
||||
|
||||
|
||||
class BrowserClient:
|
||||
"""CDP-aware connector that returns the active Raindrop page."""
|
||||
|
||||
def __init__(self, settings: AppSettings) -> None:
|
||||
self.settings = settings
|
||||
|
||||
def _resolve_host(self, host_id: str | None) -> BrowserHost:
|
||||
resolved_id = host_id or self.settings.default_browser_host_id
|
||||
host = self.settings.browser_hosts.get(resolved_id)
|
||||
if not host:
|
||||
known = ", ".join(self.settings.browser_hosts.keys()) or "<none>"
|
||||
raise ValueError(f"Unknown browser host '{resolved_id}'. Known: {known}")
|
||||
return host
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def open_page(self, host_id: str | None = None) -> AsyncIterator[Page]:
|
||||
host = self._resolve_host(host_id)
|
||||
cdp_url = f"http://{host.host}:{host.cdp_port}"
|
||||
playwright = await async_playwright().start()
|
||||
browser: Browser | None = None
|
||||
try:
|
||||
browser = await playwright.chromium.connect_over_cdp(cdp_url)
|
||||
page = self._pick_raindrop_page(browser)
|
||||
if not page:
|
||||
raise RuntimeError("No Raindrop page found in connected browser.")
|
||||
yield page
|
||||
finally:
|
||||
# Avoid killing the remote browser; just drop the connection.
|
||||
with contextlib.suppress(Exception):
|
||||
if browser:
|
||||
browser.disconnect()
|
||||
with contextlib.suppress(Exception):
|
||||
await playwright.stop()
|
||||
|
||||
def _pick_raindrop_page(self, browser: Browser) -> Page | None:
|
||||
target_substr = self.settings.raindrop_base_url
|
||||
pages = []
|
||||
for context in browser.contexts:
|
||||
pages.extend(context.pages)
|
||||
pages = pages or [page for page in browser.contexts[0].pages] if browser.contexts else []
|
||||
for page in reversed(pages):
|
||||
if target_substr in (page.url or ""):
|
||||
return page
|
||||
return pages[-1] if pages else None
|
||||
0
src/guide/app/core/__init__.py
Normal file
0
src/guide/app/core/__init__.py
Normal file
69
src/guide/app/core/config.py
Normal file
69
src/guide/app/core/config.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
CONFIG_DIR = Path(__file__).resolve().parents[4] / "config"
|
||||
HOSTS_FILE = CONFIG_DIR / "hosts.yaml"
|
||||
|
||||
|
||||
class BrowserHost(BaseModel):
|
||||
id: str
|
||||
host: str
|
||||
cdp_port: int
|
||||
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="RAINDROP_DEMO_")
|
||||
|
||||
raindrop_base_url: str = "https://app.raindrop.com"
|
||||
default_browser_host_id: str = "desktop"
|
||||
browser_hosts: dict[str, BrowserHost] = Field(default_factory=dict)
|
||||
|
||||
|
||||
def _load_hosts_file(path: Path) -> dict[str, BrowserHost]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"hosts.yaml found but PyYAML is not installed. "
|
||||
"Add 'pyyaml' to dependencies or remove hosts.yaml."
|
||||
) from exc
|
||||
|
||||
data = yaml.safe_load(path.read_text()) or {}
|
||||
hosts_raw = data.get("hosts") or data
|
||||
hosts: dict[str, BrowserHost] = {}
|
||||
for item in hosts_raw:
|
||||
host = BrowserHost.model_validate(item)
|
||||
hosts[host.id] = host
|
||||
return hosts
|
||||
|
||||
|
||||
def _parse_hosts_json(value: str) -> dict[str, BrowserHost]:
|
||||
decoded = json.loads(value)
|
||||
hosts: dict[str, BrowserHost] = {}
|
||||
for item in decoded:
|
||||
host = BrowserHost.model_validate(item)
|
||||
hosts[host.id] = host
|
||||
return hosts
|
||||
|
||||
|
||||
def load_settings() -> AppSettings:
|
||||
settings = AppSettings()
|
||||
merged_hosts: dict[str, BrowserHost] = {}
|
||||
|
||||
from_file = _load_hosts_file(HOSTS_FILE)
|
||||
merged_hosts.update(from_file)
|
||||
|
||||
if env_json := os.environ.get("RAINDROP_DEMO_BROWSER_HOSTS_JSON"):
|
||||
merged_hosts.update(_parse_hosts_json(env_json))
|
||||
|
||||
if merged_hosts:
|
||||
settings = settings.model_copy(update={"browser_hosts": merged_hosts})
|
||||
|
||||
return settings
|
||||
11
src/guide/app/core/logging.py
Normal file
11
src/guide/app/core/logging.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def configure_logging(level: int | str = logging.INFO, correlation_id: Optional[str] = None) -> None:
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
if correlation_id:
|
||||
logging.LoggerAdapter(logging.getLogger(), {"correlation_id": correlation_id})
|
||||
0
src/guide/app/domain/__init__.py
Normal file
0
src/guide/app/domain/__init__.py
Normal file
43
src/guide/app/domain/models.py
Normal file
43
src/guide/app/domain/models.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import uuid
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from guide.app.core.config import BrowserHost
|
||||
|
||||
|
||||
class ActionRequest(BaseModel):
|
||||
browser_host_id: Optional[str] = None
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ActionContext(BaseModel):
|
||||
browser_host_id: str
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
|
||||
|
||||
class ActionResult(BaseModel):
|
||||
status: Literal["ok", "error"] = "ok"
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ActionMetadata(BaseModel):
|
||||
id: str
|
||||
description: str
|
||||
category: str
|
||||
|
||||
|
||||
class ActionResponse(BaseModel):
|
||||
status: Literal["ok", "error"]
|
||||
action_id: str
|
||||
browser_host_id: str
|
||||
correlation_id: Optional[str] = None
|
||||
details: dict[str, Any] | None = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class BrowserHostsResponse(BaseModel):
|
||||
default_browser_host_id: str
|
||||
browser_hosts: dict[str, BrowserHost]
|
||||
26
src/guide/app/main.py
Normal file
26
src/guide/app/main.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from guide.app.actions import default_registry
|
||||
from guide.app.browser.client import BrowserClient
|
||||
from guide.app.core.config import load_settings
|
||||
from guide.app.core.logging import configure_logging
|
||||
from guide.app.api import router as api_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
configure_logging()
|
||||
|
||||
settings = load_settings()
|
||||
registry = default_registry()
|
||||
browser_client = BrowserClient(settings)
|
||||
|
||||
app = FastAPI(title="Raindrop Demo Automation", version="0.1.0")
|
||||
app.state.settings = settings
|
||||
app.state.action_registry = registry
|
||||
app.state.browser_client = browser_client
|
||||
|
||||
app.include_router(api_router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
12
src/guide/app/strings/__init__.py
Normal file
12
src/guide/app/strings/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from guide.app.strings.demo_texts import DemoTexts, texts
|
||||
from guide.app.strings.labels import Labels, labels
|
||||
from guide.app.strings.selectors import Selectors, selectors
|
||||
|
||||
__all__ = [
|
||||
"DemoTexts",
|
||||
"Labels",
|
||||
"Selectors",
|
||||
"texts",
|
||||
"labels",
|
||||
"selectors",
|
||||
]
|
||||
14
src/guide/app/strings/demo_texts/__init__.py
Normal file
14
src/guide/app/strings/demo_texts/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from guide.app.strings.demo_texts.events import EventTexts
|
||||
from guide.app.strings.demo_texts.intake import IntakeTexts
|
||||
from guide.app.strings.demo_texts.suppliers import SupplierTexts
|
||||
|
||||
|
||||
class DemoTexts:
|
||||
INTAKE = IntakeTexts
|
||||
SUPPLIERS = SupplierTexts
|
||||
EVENTS = EventTexts
|
||||
|
||||
|
||||
texts = DemoTexts()
|
||||
|
||||
__all__ = ["DemoTexts", "texts", "IntakeTexts", "SupplierTexts", "EventTexts"]
|
||||
2
src/guide/app/strings/demo_texts/events.py
Normal file
2
src/guide/app/strings/demo_texts/events.py
Normal file
@@ -0,0 +1,2 @@
|
||||
class EventTexts:
|
||||
THREE_BIDS_EVENT_NAME = "Three Bids and a Buy – Demo"
|
||||
5
src/guide/app/strings/demo_texts/intake.py
Normal file
5
src/guide/app/strings/demo_texts/intake.py
Normal file
@@ -0,0 +1,5 @@
|
||||
class IntakeTexts:
|
||||
CONVEYOR_BELT_REQUEST = (
|
||||
"Requesting 500 tons of replacement conveyor belts for Q4 maintenance window."
|
||||
)
|
||||
ALT_REQUEST = "Intake for rapid supplier onboarding and risk review."
|
||||
7
src/guide/app/strings/demo_texts/suppliers.py
Normal file
7
src/guide/app/strings/demo_texts/suppliers.py
Normal file
@@ -0,0 +1,7 @@
|
||||
class SupplierTexts:
|
||||
DEFAULT_TRIO = [
|
||||
"Demo Supplier A",
|
||||
"Demo Supplier B",
|
||||
"Demo Supplier C",
|
||||
]
|
||||
NOTES = "Sourced automatically via demo action."
|
||||
13
src/guide/app/strings/labels/__init__.py
Normal file
13
src/guide/app/strings/labels/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from guide.app.strings.labels.intake import IntakeLabels
|
||||
from guide.app.strings.labels.sourcing import SourcingLabels
|
||||
|
||||
|
||||
class Labels:
|
||||
INTAKE = IntakeLabels
|
||||
SOURCING = SourcingLabels
|
||||
NEXT_BUTTON = "Next"
|
||||
|
||||
|
||||
labels = Labels()
|
||||
|
||||
__all__ = ["Labels", "labels", "IntakeLabels", "SourcingLabels"]
|
||||
6
src/guide/app/strings/labels/intake.py
Normal file
6
src/guide/app/strings/labels/intake.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""User-visible labels for intake."""
|
||||
|
||||
|
||||
class IntakeLabels:
|
||||
DESCRIPTION_PLACEHOLDER = "Describe what you need"
|
||||
NEXT_BUTTON = "Next"
|
||||
6
src/guide/app/strings/labels/sourcing.py
Normal file
6
src/guide/app/strings/labels/sourcing.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Labels for sourcing screens."""
|
||||
|
||||
|
||||
class SourcingLabels:
|
||||
SUPPLIERS_TAB = "Suppliers"
|
||||
ADD_BUTTON = "Add"
|
||||
14
src/guide/app/strings/selectors/__init__.py
Normal file
14
src/guide/app/strings/selectors/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from guide.app.strings.selectors.intake import IntakeSelectors
|
||||
from guide.app.strings.selectors.navigation import NavigationSelectors
|
||||
from guide.app.strings.selectors.sourcing import SourcingSelectors
|
||||
|
||||
|
||||
class Selectors:
|
||||
INTAKE = IntakeSelectors
|
||||
SOURCING = SourcingSelectors
|
||||
NAVIGATION = NavigationSelectors
|
||||
|
||||
|
||||
selectors = Selectors()
|
||||
|
||||
__all__ = ["Selectors", "selectors", "IntakeSelectors", "SourcingSelectors", "NavigationSelectors"]
|
||||
6
src/guide/app/strings/selectors/intake.py
Normal file
6
src/guide/app/strings/selectors/intake.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Selectors for the intake flow."""
|
||||
|
||||
|
||||
class IntakeSelectors:
|
||||
DESCRIPTION_FIELD = '[data-test="intake-description"]'
|
||||
NEXT_BUTTON = '[data-test="intake-next"]'
|
||||
6
src/guide/app/strings/selectors/navigation.py
Normal file
6
src/guide/app/strings/selectors/navigation.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Selectors used for simple navigation helpers."""
|
||||
|
||||
|
||||
class NavigationSelectors:
|
||||
GLOBAL_SEARCH = '[data-test="global-search"]'
|
||||
FIRST_RESULT = '[data-test="search-result"]:first-child'
|
||||
7
src/guide/app/strings/selectors/sourcing.py
Normal file
7
src/guide/app/strings/selectors/sourcing.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Selectors for sourcing event setup."""
|
||||
|
||||
|
||||
class SourcingSelectors:
|
||||
SUPPLIER_SEARCH_INPUT = '[data-test="supplier-search"]'
|
||||
ADD_SUPPLIER_BUTTON = '[data-test="add-supplier"]'
|
||||
SUPPLIER_ROW = '[data-test="supplier-row"]'
|
||||
Reference in New Issue
Block a user