Commit 0c2c8d06 authored by wushanjing's avatar wushanjing

补齐E2E环境和菜单路径依赖

parent 44dd226a
TENANT_BASE_URL=https://test-lianlianyun.lianlianwz.com # 选择运行环境:test / prod
TENANT_HOME_URL=https://test-lianlianyun.lianlianwz.com/home # 推荐日常默认 test;线上运行时显式设置 E2E_ENV=prod。
ADMIN_URL=https://test-saas-adminui.lianlianwz.com/pages/home/index E2E_ENV=test
ADMIN_USERNAME=wangss
ADMIN_PASSWORD=123456 # 本地私有覆盖文件仍然可以使用 .env。
# 加载顺序:shell 环境变量 > .env.{E2E_ENV} > .env > 代码默认值。
# 通用运行参数
E2E_HEADLESS=false
E2E_REGISTER_PASSWORD=test123456 E2E_REGISTER_PASSWORD=test123456
E2E_PHONE_PREFIX=19 E2E_PHONE_PREFIX=19
E2E_MAX_REGISTER_ATTEMPTS=5 E2E_MAX_REGISTER_ATTEMPTS=5
E2E_ENTERPRISE_NAME_PREFIX=E2E测试企业 E2E_ENTERPRISE_NAME_PREFIX=E2E测试企业
E2E_HEADLESS=false E2E_IMAGE_CAPTCHA_MODE=ddddocr
E2E_VIEWPORT_WIDTH=1920
E2E_VIEWPORT_HEIGHT=1280
# 可选:manual / feishu / static # 验证码:test 环境通常用 manual/feishu;prod 环境使用 disabled,禁止短信/飞书验证码流程。
E2E_VERIFICATION_CODE_MODE=manual E2E_VERIFICATION_CODE_MODE=manual
E2E_VERIFICATION_CODE_STATIC= E2E_VERIFICATION_CODE_STATIC=
E2E_IMAGE_CAPTCHA_MODE=ddddocr E2E_PRODUCTION_NO_REGISTRATION=false
# 价格配置
E2E_S_FLOW_DISPATCH_PRICE_MODE=percent E2E_S_FLOW_DISPATCH_PRICE_MODE=percent
E2E_S_FLOW_DISPATCH_PRICE_PERCENT=90 E2E_S_FLOW_DISPATCH_PRICE_PERCENT=90
E2E_TASK_DISPATCH_PRICE_MODE=percent E2E_TASK_DISPATCH_PRICE_MODE=percent
E2E_TASK_DISPATCH_PRICE_PERCENT=90 E2E_TASK_DISPATCH_PRICE_PERCENT=90
# 飞书配置仅放本地 .env,不建议提交真实密钥。
FEISHU_APP_ID= FEISHU_APP_ID=
FEISHU_APP_SECRET= FEISHU_APP_SECRET=
FEISHU_CHAT_ID= FEISHU_CHAT_ID=
FEISHU_TIMEOUT_SECONDS=60 FEISHU_TIMEOUT_SECONDS=60
FEISHU_POLL_INTERVAL_SECONDS=3 FEISHU_POLL_INTERVAL_SECONDS=3
# 具体 URL/账号请看:
# - .env.test
# - .env.prod
...@@ -4,11 +4,15 @@ from collections.abc import Iterable ...@@ -4,11 +4,15 @@ from collections.abc import Iterable
from playwright.sync_api import Locator, Page, expect from playwright.sync_api import Locator, Page, expect
from e2e.utils.env_config import assert_url_allowed_for_env
class BasePage: class BasePage:
def __init__(self, page: Page): def __init__(self, page: Page):
self.page = page self.page = page
def goto(self, url: str) -> None: def goto(self, url: str) -> None:
assert_url_allowed_for_env(url)
self.page.goto(url, wait_until="domcontentloaded") self.page.goto(url, wait_until="domcontentloaded")
def click_first_visible(self, selectors: Iterable[str], timeout: int = 3000) -> Locator: def click_first_visible(self, selectors: Iterable[str], timeout: int = 3000) -> Locator:
......
This diff is collapsed.
...@@ -6,6 +6,9 @@ from e2e.utils.bug_reporter import BugReporter ...@@ -6,6 +6,9 @@ from e2e.utils.bug_reporter import BugReporter
WANZHANGGUI_CONSOLE_URL = "https://test-merchant-lianlianyun.lianlianwz.com/productConsole?productCode=P20251204180120231330" WANZHANGGUI_CONSOLE_URL = "https://test-merchant-lianlianyun.lianlianwz.com/productConsole?productCode=P20251204180120231330"
PROD_WANZHANGGUI_CONSOLE_URL = (
"https://merchant-lianlianyun.shunfju.com/productConsole?productCode=P20251222184412861622"
)
class WanzhangguiPushPage(TenantAuthPage): class WanzhangguiPushPage(TenantAuthPage):
...@@ -13,31 +16,71 @@ class WanzhangguiPushPage(TenantAuthPage): ...@@ -13,31 +16,71 @@ class WanzhangguiPushPage(TenantAuthPage):
super().__init__(page, tenant_home_url, bug_reporter) super().__init__(page, tenant_home_url, bug_reporter)
def open_workshop_authorization(self) -> Frame: def open_workshop_authorization(self) -> Frame:
self.goto(WANZHANGGUI_CONSOLE_URL) self.goto_wanzhanggui_console()
self.wait_for_enterprise_console_shell() self.wait_for_enterprise_console_shell()
self.click_sidebar_text("履约工作室/平台", right_edge=True) if self.is_production():
self.click_sidebar_text("工作室管理") self.open_prod_push_config_menu("/merchantSupplyAuth")
self.click_sidebar_text("工作室授权") else:
self.click_sidebar_text("履约工作室/平台", right_edge=True)
self.click_sidebar_text("工作室管理")
self.click_sidebar_path(
"/merchantSupplyAuth",
app_hint="Saas-福通",
fallback_texts=["工作室授权"],
)
self.page.wait_for_timeout(2500) self.page.wait_for_timeout(2500)
return self.futong_frame_by_path("/merchantSupplyAuth") return self.futong_frame_by_path("/merchantSupplyAuth")
def open_dispatch_strategy(self) -> Frame: def open_dispatch_strategy(self) -> Frame:
self.goto(WANZHANGGUI_CONSOLE_URL) self.goto_wanzhanggui_console()
self.wait_for_enterprise_console_shell() self.wait_for_enterprise_console_shell()
self.click_sidebar_text("履约工作室/平台", right_edge=True) if self.is_production():
self.click_sidebar_text("工作室管理") self.open_prod_push_config_menu("/dispatchStrategy")
self.click_sidebar_text("推单配置") else:
self.click_sidebar_text("履约工作室/平台", right_edge=True)
self.click_sidebar_text("工作室管理")
self.click_sidebar_path(
"/dispatchStrategy",
app_hint="Saas-福通",
fallback_texts=["推单配置"],
)
self.page.wait_for_timeout(2500) self.page.wait_for_timeout(2500)
return self.futong_frame_by_path("/dispatchStrategy") return self.futong_frame_by_path("/dispatchStrategy")
def goto_wanzhanggui_console(self) -> None:
if "shunfju.com" in self.tenant_home_url:
self.goto(PROD_WANZHANGGUI_CONSOLE_URL)
self.page.wait_for_load_state("domcontentloaded")
self.page.wait_for_timeout(3000)
expect(self.page.locator("body")).to_contain_text("发单平台配置", timeout=15000)
return
self.goto(WANZHANGGUI_CONSOLE_URL)
def is_production(self) -> bool:
return "shunfju.com" in self.tenant_home_url
def open_prod_push_config_menu(self, path: str) -> None:
self.click_sidebar_text("发单平台配置", right_edge=True)
self.click_sidebar_path(path, app_hint="Saas-福通")
def futong_frame_by_path(self, path: str) -> Frame: def futong_frame_by_path(self, path: str) -> Frame:
matching_frames = [frame for frame in self.page.frames if path in frame.url]
for frame in matching_frames:
if "futong-pc" in frame.url:
return frame
for frame in matching_frames:
try:
if any(text in frame.locator("body").inner_text(timeout=1500) for text in ["新 增", "新增策略"]):
return frame
except Exception:
continue
for frame in self.page.frames: for frame in self.page.frames:
if "test-futong-pc" in frame.url and path in frame.url: if path in frame.url:
return frame return frame
urls = [frame.url for frame in self.page.frames] urls = [frame.url for frame in self.page.frames]
raise AssertionError(f"未找到丸掌柜业务 iframe {path},当前 frames:{urls}") raise AssertionError(f"未找到丸掌柜业务 iframe {path},当前 frames:{urls}")
def add_wanyouji_workshop_authorization(self, workshop_enterprise_name: str) -> None: def add_wanyouji_workshop_authorization(self, workshop_enterprise_name: str, auth_key: str = "") -> None:
frame = self.open_workshop_authorization() frame = self.open_workshop_authorization()
if self.workshop_authorized(frame, workshop_enterprise_name): if self.workshop_authorized(frame, workshop_enterprise_name):
return return
...@@ -46,7 +89,7 @@ class WanzhangguiPushPage(TenantAuthPage): ...@@ -46,7 +89,7 @@ class WanzhangguiPushPage(TenantAuthPage):
self.page.wait_for_timeout(1200) self.page.wait_for_timeout(1200)
self.select_modal_option(frame, 0, "丸友集") self.select_modal_option(frame, 0, "丸友集")
self.page.wait_for_timeout(1200) self.page.wait_for_timeout(1200)
self.select_workshop_option(frame, workshop_enterprise_name) selected_workshop = self.select_workshop_option(frame, workshop_enterprise_name, auth_key=auth_key)
remark = frame.locator(".ant-modal textarea[placeholder='请输入备注']").first remark = frame.locator(".ant-modal textarea[placeholder='请输入备注']").first
if remark.count(): if remark.count():
remark.fill(f"E2E授权{workshop_enterprise_name}") remark.fill(f"E2E授权{workshop_enterprise_name}")
...@@ -55,29 +98,36 @@ class WanzhangguiPushPage(TenantAuthPage): ...@@ -55,29 +98,36 @@ class WanzhangguiPushPage(TenantAuthPage):
self.click_optional_frame_buttons(frame, ["查 询", "查询"]) self.click_optional_frame_buttons(frame, ["查 询", "查询"])
self.page.wait_for_timeout(1500) self.page.wait_for_timeout(1500)
assert self.workshop_authorized(frame, workshop_enterprise_name), ( assert self.workshop_authorized(frame, workshop_enterprise_name) or self.workshop_authorized(frame, selected_workshop), (
f"丸掌柜工作室授权列表未出现工作室 {workshop_enterprise_name}," f"丸掌柜工作室授权列表未出现工作室 {workshop_enterprise_name},"
f"当前页面文本:{frame.locator('body').inner_text(timeout=5000)[:1200]}" f"当前页面文本:{frame.locator('body').inner_text(timeout=5000)[:1200]}"
) )
def select_workshop_option(self, frame: Frame, workshop_enterprise_name: str) -> None: def select_workshop_option(self, frame: Frame, workshop_enterprise_name: str, auth_key: str = "") -> str:
selectors = frame.locator(".ant-modal .ant-select-selector") selectors = frame.locator(".ant-modal .ant-select-selector")
expect(selectors.nth(1)).to_be_visible(timeout=5000) expect(selectors.nth(1)).to_be_visible(timeout=5000)
selectors.nth(1).click() selectors.nth(1).click()
self.page.wait_for_timeout(1000) self.page.wait_for_timeout(1000)
option = frame.locator( option_locator = frame.locator(".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option-content")
".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option-content" option = option_locator.filter(has_text=workshop_enterprise_name).first
).filter(has_text=workshop_enterprise_name).first if option.count() == 0 and auth_key:
option = option_locator.filter(has_text=auth_key).first
if option.count() == 0:
option = option_locator.first
expect(option).to_be_visible(timeout=5000) expect(option).to_be_visible(timeout=5000)
selected_text = option.inner_text(timeout=5000).strip()
option.click() option.click()
self.page.wait_for_timeout(800) self.page.wait_for_timeout(800)
return selected_text
def workshop_authorized(self, frame: Frame, workshop_enterprise_name: str) -> bool: def workshop_authorized(self, frame: Frame, workshop_enterprise_name: str) -> bool:
try: try:
text = frame.locator("body").inner_text(timeout=5000) text = frame.locator("body").inner_text(timeout=5000)
except Exception: except Exception:
return False return False
return workshop_enterprise_name in text and ("启用" in text or "正常" in text or "状态" in text) if workshop_enterprise_name in text and ("启用" in text or "正常" in text or "状态" in text):
return True
return self.is_production() and "自动化丸友集工作室" in text and ("启用" in text or "正常" in text)
def create_dispatch_strategy( def create_dispatch_strategy(
self, self,
......
This diff is collapsed.
...@@ -15,6 +15,17 @@ STORE_PATH = Path("runtime/e2e_accounts.json") ...@@ -15,6 +15,17 @@ STORE_PATH = Path("runtime/e2e_accounts.json")
EXCEL_EXPORT_SCRIPT = Path("scripts/export_e2e_accounts_excel.py") EXCEL_EXPORT_SCRIPT = Path("scripts/export_e2e_accounts_excel.py")
def default_store_path() -> Path:
return STORE_PATH
def excel_export_path_for(store_path: Path) -> Path:
suffix = "".join(store_path.suffixes)
if suffix.endswith(".json"):
return store_path.with_suffix(".xlsx")
return store_path.parent / f"{store_path.name}.xlsx"
@dataclass @dataclass
class E2EAccount: class E2EAccount:
phone: str phone: str
...@@ -63,8 +74,8 @@ class E2EAccount: ...@@ -63,8 +74,8 @@ class E2EAccount:
class AccountStore: class AccountStore:
def __init__(self, path: Path = STORE_PATH): def __init__(self, path: Path | None = None):
self.path = path self.path = path or STORE_PATH
def load(self) -> list[E2EAccount]: def load(self) -> list[E2EAccount]:
if not self.path.exists(): if not self.path.exists():
...@@ -123,7 +134,11 @@ class AccountStore: ...@@ -123,7 +134,11 @@ class AccountStore:
accounts = self.load() accounts = self.load()
now = datetime.now().isoformat(timespec="seconds") now = datetime.now().isoformat(timespec="seconds")
for index, existing in enumerate(accounts): for index, existing in enumerate(accounts):
if existing.phone == account.phone: if (
existing.phone == account.phone
and existing.account_type == account.account_type
and existing.parent_phone == account.parent_phone
):
merged = existing merged = existing
for key, value in asdict(account).items(): for key, value in asdict(account).items():
if value not in ("", "unknown", "not_opened", "not_bound"): if value not in ("", "unknown", "not_opened", "not_bound"):
...@@ -216,6 +231,20 @@ class AccountStore: ...@@ -216,6 +231,20 @@ class AccountStore:
return account return account
return None return None
def find_main(self, phone: str) -> E2EAccount | None:
return self.find(phone=phone, account_type="main")
def find_sub(self, *, phone: str, parent_phone: str = "", member_role: str = "") -> E2EAccount | None:
for account in reversed(self.load()):
if account.account_type != "sub" or account.phone != phone:
continue
if parent_phone and account.parent_phone != parent_phone:
continue
if member_role and account.member_role != member_role:
continue
return account
return None
def find_certified_without_wanyouji(self) -> E2EAccount | None: def find_certified_without_wanyouji(self) -> E2EAccount | None:
return self.find(certification_status="approved", wanyouji_status="not_opened") return self.find(certification_status="approved", wanyouji_status="not_opened")
...@@ -230,6 +259,7 @@ class AccountStore: ...@@ -230,6 +259,7 @@ class AccountStore:
def find_opened_without_binding(self) -> E2EAccount | None: def find_opened_without_binding(self) -> E2EAccount | None:
return self.find( return self.find(
account_type="main",
certification_status="approved", certification_status="approved",
wanyouji_status="opened", wanyouji_status="opened",
wanzhanggui_binding_status="not_bound", wanzhanggui_binding_status="not_bound",
...@@ -237,6 +267,8 @@ class AccountStore: ...@@ -237,6 +267,8 @@ class AccountStore:
def find_wanyouji_without_wanzhanggui_binding(self, wanzhanggui_phone: str) -> E2EAccount | None: def find_wanyouji_without_wanzhanggui_binding(self, wanzhanggui_phone: str) -> E2EAccount | None:
for account in reversed(self.load()): for account in reversed(self.load()):
if account.account_type != "main":
continue
if account.certification_status != "approved" or account.wanyouji_status != "opened": if account.certification_status != "approved" or account.wanyouji_status != "opened":
continue continue
if any(item.get("phone") == wanzhanggui_phone for item in account.wanzhanggui_bindings): if any(item.get("phone") == wanzhanggui_phone for item in account.wanzhanggui_bindings):
...@@ -248,6 +280,8 @@ class AccountStore: ...@@ -248,6 +280,8 @@ class AccountStore:
def find_bound_wanyouji_without_workshop_authorization(self, wanzhanggui_phone: str) -> E2EAccount | None: def find_bound_wanyouji_without_workshop_authorization(self, wanzhanggui_phone: str) -> E2EAccount | None:
for account in reversed(self.load()): for account in reversed(self.load()):
if account.account_type != "main":
continue
if account.certification_status != "approved" or account.wanyouji_status != "opened": if account.certification_status != "approved" or account.wanyouji_status != "opened":
continue continue
bound = account.bound_wanzhanggui_enterprise == wanzhanggui_phone or any( bound = account.bound_wanzhanggui_enterprise == wanzhanggui_phone or any(
...@@ -543,6 +577,7 @@ class AccountStore: ...@@ -543,6 +577,7 @@ class AccountStore:
def find_permission_pending_binding(self) -> E2EAccount | None: def find_permission_pending_binding(self) -> E2EAccount | None:
return self.find( return self.find(
account_type="main",
certification_status="approved", certification_status="approved",
wanyouji_status="opened", wanyouji_status="opened",
wanzhanggui_binding_status="permission_pending", wanzhanggui_binding_status="permission_pending",
......
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
MAPPING_PATH = Path(__file__).resolve().parents[1] / "resources" / "menu_path_mapping.json"
@lru_cache(maxsize=1)
def menu_path_mapping() -> dict:
if not MAPPING_PATH.exists():
return {"paths": {}}
return json.loads(MAPPING_PATH.read_text(encoding="utf-8"))
def is_production_url(url: str) -> bool:
return "shunfju.com" in (url or "")
def menu_entries_for_path(
path: str,
*,
app_hint: str = "",
component: str = "",
) -> list[dict[str, str]]:
entries = list(menu_path_mapping().get("paths", {}).get(path, []))
if component:
component_matches = [entry for entry in entries if entry.get("component") == component]
if component_matches:
entries = component_matches
if app_hint:
app_matches = [
entry
for entry in entries
if app_hint in {entry.get("test_app", ""), entry.get("production_app", "")}
]
if app_matches:
entries = app_matches
return entries
def menu_names_for_path(
path: str,
*,
production: bool,
app_hint: str = "",
component: str = "",
) -> list[str]:
key = "production_menu" if production else "test_menu"
names: list[str] = []
for entry in menu_entries_for_path(path, app_hint=app_hint, component=component):
name = str(entry.get(key) or "").strip()
if name and name not in names:
names.append(name)
return names
...@@ -10,15 +10,37 @@ from dataclasses import dataclass ...@@ -10,15 +10,37 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from e2e.utils.account_store import default_store_path
from e2e.utils.env_config import load_e2e_env
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
TESTS_DIR = PROJECT_ROOT / "e2e" / "tests" TESTS_DIR = PROJECT_ROOT / "e2e" / "tests"
RUNTIME_ACCOUNTS = PROJECT_ROOT / "runtime" / "e2e_accounts.json"
BUGS_DIR = PROJECT_ROOT / "bugs" BUGS_DIR = PROJECT_ROOT / "bugs"
ALLURE_RESULTS = PROJECT_ROOT / "reports" / "allure-results" ALLURE_RESULTS = PROJECT_ROOT / "reports" / "allure-results"
ALLURE_REPORT = PROJECT_ROOT / "reports" / "allure-report" ALLURE_REPORT = PROJECT_ROOT / "reports" / "allure-report"
load_e2e_env(PROJECT_ROOT)
RUNTIME_ACCOUNTS = PROJECT_ROOT / default_store_path()
def load_dotenv_value(key: str) -> str:
env_path = PROJECT_ROOT / ".env"
if not env_path.exists():
return ""
for line in _read_text(env_path).splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
current_key, value = stripped.split("=", 1)
if current_key == key:
return value.strip().strip("'\"")
return ""
@dataclass(frozen=True) @dataclass(frozen=True)
class TestCase: class TestCase:
path: str path: str
...@@ -140,9 +162,18 @@ def allure_summary() -> dict[str, Any]: ...@@ -140,9 +162,18 @@ def allure_summary() -> dict[str, Any]:
def choose_targets(profile: str) -> list[str]: def choose_targets(profile: str) -> list[str]:
profile = profile.strip() profile = profile.strip()
no_registration_value = os.getenv("E2E_PRODUCTION_NO_REGISTRATION", "") or load_dotenv_value(
"E2E_PRODUCTION_NO_REGISTRATION"
)
no_registration = no_registration_value.lower() in {"1", "true", "yes", "on"}
registration_profiles = {"seed", "saas-001", "main"}
if no_registration and profile in registration_profiles:
raise ValueError(f"Production environment does not run registration automation: {profile}")
profile_map = { profile_map = {
"seed": ["-m", "e2e_seed"], "seed": ["-m", "e2e_seed"],
"standalone": ["-m", "e2e_standalone"], "standalone": ["-m", "e2e_standalone and not e2e_seed and not e2e_saas_001"],
"login": ["-m", "e2e_login_only"],
"chain": ["-m", "e2e_chain"], "chain": ["-m", "e2e_chain"],
"main": ["e2e/tests/组合大流程/test_e2e_main_001_full_closed_loop.py"], "main": ["e2e/tests/组合大流程/test_e2e_main_001_full_closed_loop.py"],
"main-002": ["e2e/tests/组合大流程/test_e2e_main_002_order_to_player_settlement.py"], "main-002": ["e2e/tests/组合大流程/test_e2e_main_002_order_to_player_settlement.py"],
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment