Skip to content

Commit 06a61f4

Browse files
authored
feat(qw): onboard QW site (LM來財娛樂城) with P0 smoke + visual regression (#58)
- pages/qw/{login_page,home_page}.py: Nuxt/Vue site with /auth standalone login flow, popup-mask dismiss loop, hover-triggered avatar dropdown (logout via JS dispatchEvent to avoid mouseleave race) - tests/qw/test_p0_smoke.py: 4 P0 cases (login_success / login_invalid / logout / home_loads) - 4/4 pass in 1:05 - tests/qw/feature/visual/test_visual_regression.py: 3 VR cases (home_shell / auth_page / navbar) - 3/3 pass - pages/factory.py: append 'qw' to LoginPage + HomePage registries - pytest.ini: register 'qw' marker - .env.example: rename QW section to "LM來財娛樂城(現金版)" - CLAUDE.md: Architecture / Markers / Visual Regression sections updated Site characteristics (Nuxt + multi-locale): - Login at /auth (not /login - /login is Nuxt 404) - Submit button has no `type` HTML attribute, requires class selector only - Avatar dropdown is hover-triggered; logout requires dispatchEvent - popup-mask intercepts pointer events, dismiss loop until hidden Verification: - QW P0 smoke: 4/4 pass (1:05) via CDP - QW VR: 3/3 pass, screenshots saved to screenshots/qw/vr_reference/ - Existing 4 sites (rc/lt/re/rd) P0 smoke: 37/37 executed pass, 5 skipped (LT known skips), 6:10 - factory.py append-only change verified safe
1 parent 6b25286 commit 06a61f4

14 files changed

Lines changed: 516 additions & 4 deletions

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ SITE_RC_COMPANYCODE=drc
196196
# SITE_KS_COMPANYCODE=dks
197197

198198
# -----------------------------------------------
199-
# QW 站點 (qw) — 現金版(暫時未使用,預先加入
199+
# QW 站點 (qw) — LM來財娛樂城(現金版
200200
# -----------------------------------------------
201201
# 前台
202202
# SITE_QW_URL=https://<your-qw-domain>/

CLAUDE.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ pages/rc/ — rc site Page Objects (LoginPage, HomePage) —
8888
pages/lt/ — lt site Page Objects (LoginPage, HomePage) — LT來財
8989
pages/re/ — re site Page Objects (LoginPage, HomePage) — BeWin
9090
pages/rd/ — rd site Page Objects (LoginPage, HomePage) — 狗狗娛樂城
91+
pages/qw/ — qw site Page Objects (LoginPage, HomePage) — LM來財娛樂城(Nuxt/Vue,多語系)
9192
pages/dashboard/<site_id>/ — backend dashboard page objects (DashboardLoginPage, ManagementPage); per dashboard factory registry
9293
tests/api/<site_id>/ — API-layer tests (requests only, no browser, no pages/* import); per-site conftest
9394
tests/dashboard/<site_id>/ — backend dashboard tests; state-mutating tests should be reversible (rollback / teardown compensation)
@@ -99,6 +100,8 @@ tests/re/ — re site tests (test_p0_smoke.py p0, feature/<nam
99100
tests/re/conftest.py — re-specific overrides: site_config=re, go_home
100101
tests/rd/ — rd site tests (test_p0_smoke.py p0, feature/<name>/ p1: announcement_popup, i18n, navigation)
101102
tests/rd/conftest.py — rd-specific overrides: site_config=rd, go_home
103+
tests/qw/ — qw site tests (test_p0_smoke.py p0, feature/visual/ p2)
104+
tests/qw/conftest.py — qw-specific overrides: site_config=qw, go_home (+ dismiss popup-mask)
102105
utils/locale_helper.py — set_locale(): injects i18n_locale cookie for lt site
103106
utils/dialog_helper.py — helpers: dismiss server error popups, wait for loading animation
104107
utils/screenshot_helper.py — element-highlight screenshot system, auto README.md generation
@@ -119,7 +122,7 @@ dev-notes/ — personal developer notes (gitignored except REA
119122
- `auto_screenshot` (autouse) — attaches `ScreenshotHelper` to page; auto-categorizes tests into `smoke/` or `feature/` subfolder; generates `screenshots/<site_id>/<timestamp>/<category>/<test_name>/README.md` after each test
120123
- `auto_logout_after_test` (autouse) — logs out after each smoke test (`page` fixture only)
121124

122-
**Markers** (pytest.ini): `p0`, `p1`, `p2`, `login`, `home`, `member`, `wallet`, `i18n`, `language`, `copy`, `visual`, `visual_regression`, `locale_layout`, `docker_only`, `api`, `dashboard`, `game`, `flaky`, `lt`, `rc`, `re`, `rd`
125+
**Markers** (pytest.ini): `p0`, `p1`, `p2`, `login`, `home`, `member`, `wallet`, `i18n`, `language`, `copy`, `visual`, `visual_regression`, `locale_layout`, `docker_only`, `api`, `dashboard`, `game`, `flaky`, `lt`, `rc`, `re`, `rd`, `qw`
123126

124127
## Multi-site Factory Pattern
125128

@@ -204,14 +207,15 @@ This repo has **two distinct documentation folders** with different purposes and
204207

205208
若某份 `dev-notes/` 的筆記後來成熟並獲得團隊共識,請**升級**移到 `docs/` 並調整內容為正式文件。反之,若 `docs/` 中某份文件變成僅個人觀點的 WIP 清單,應移到 `dev-notes/`
206209

207-
## Visual Regression (lt / rc)
210+
## Visual Regression (lt / rc / qw)
208211

209-
LT 與 RC 皆採用 **reference screenshot** 策略:存檔供人工確認,不做 pixel 比對(跨環境解析度不穩定)。
212+
LT、RC、QW 皆採用 **reference screenshot** 策略:存檔供人工確認,不做 pixel 比對(跨環境解析度不穩定)。
210213

211214
```bash
212215
# VR reference 截圖(輸出至 screenshots/<site_id>/vr_reference/)
213216
.venv/bin/pytest tests/lt/feature/visual/test_visual_regression.py -m visual_regression
214217
.venv/bin/pytest tests/rc/feature/visual/test_visual_regression.py -m visual_regression
218+
.venv/bin/pytest tests/qw/feature/visual/test_visual_regression.py -m visual_regression
215219

216220
# DOM 層視覺健康度(非截圖)
217221
.venv/bin/pytest -m visual

pages/factory.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ def _import_class(module_path: str, class_name: str):
2323
'lt': ('pages.lt.login_page', 'LoginPage'),
2424
're': ('pages.re.login_page', 'LoginPage'),
2525
'rd': ('pages.rd.login_page', 'LoginPage'),
26+
'qw': ('pages.qw.login_page', 'LoginPage'),
2627
}
2728

2829
_HOME_PAGE_REGISTRY = {
2930
'rc': ('pages.rc.home_page', 'HomePage'),
3031
'lt': ('pages.lt.home_page', 'HomePage'),
3132
're': ('pages.re.home_page', 'HomePage'),
3233
'rd': ('pages.rd.home_page', 'HomePage'),
34+
'qw': ('pages.qw.home_page', 'HomePage'),
3335
}
3436

3537

pages/qw/__init__.py

Whitespace-only changes.

pages/qw/home_page.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""
2+
首頁 Page Object — qw 站點(LM來財娛樂城)
3+
登入成功後的首頁驗證、彈窗清理、登出
4+
5+
probe 結果(2026-05-22):
6+
- Avatar:img[alt="avatar"](與 RC 同 alt,巧合)
7+
- Avatar 容器:.avatar-trigger(cursor-pointer)
8+
- Username 顯示:.avatar-trigger p
9+
- Dropdown:hover 觸發(非 click!Vue/Nuxt behavior)
10+
- 登出按鈕:button.avatar-menu__logout
11+
- 公告彈窗:.popup-mask(全屏 mask)+ .popup-close(X 按鈕)
12+
- TOTP 提示:button,text 含「下次再說」(多語系:用 has_text 配合 try-except)
13+
14+
多語系注意:QW 多語系站(LaiBetLanguage cookie),不綁文案 selector。
15+
登出按鈕用 CSS class(.avatar-menu__logout)而非文案。
16+
"""
17+
18+
from playwright.sync_api import Page, expect, TimeoutError as PlaywrightTimeoutError
19+
from utils.screenshot_helper import get_screenshotter
20+
21+
22+
class HomePage:
23+
24+
def __init__(self, page: Page):
25+
self.page = page
26+
27+
# 登入後主要元素
28+
self.avatar = page.locator('img[alt="avatar"]')
29+
self.avatar_trigger = page.locator('.avatar-trigger')
30+
31+
# 未登入狀態:登入入口按鈕(多語系:用 CSS class)
32+
# probe 確認 class 為 .outline-btn-shared 或 .active-btn-shadow
33+
# 以 .outline-btn-shared 為主(較明確區分 login CTA)
34+
self.login_entry_btn = page.locator('button.outline-btn-shared').first
35+
36+
# ------------------------------------------------------------------
37+
# 登入狀態驗證
38+
# ------------------------------------------------------------------
39+
40+
def is_logged_in(self) -> bool:
41+
"""判斷目前是否已登入(avatar 可見)"""
42+
try:
43+
return self.avatar.is_visible(timeout=3000)
44+
except Exception:
45+
return False
46+
47+
def verify_logged_in(self):
48+
"""輕量驗證:avatar 可見即代表已登入。無副作用。
49+
fixture 與單純確認登入狀態時使用。
50+
"""
51+
sh = get_screenshotter(self.page)
52+
expect(self.avatar).to_be_visible(timeout=10000)
53+
self.avatar.scroll_into_view_if_needed()
54+
if sh: sh.capture(self.avatar, "verify_已登入_頭像")
55+
56+
def verify_login_success(self, username: str):
57+
"""驗證登入成功:avatar 可見 + .avatar-trigger 區塊含 username 文字。
58+
E2E 登入 TC 使用(test_login_success)。
59+
60+
斷言策略:avatar visible + avatar_trigger 整體文字含 username。
61+
不對特定 p 元素斷言(avatar_trigger 內有 3 個 p:username、VIP 等級、餘額,
62+
會 strict mode violation;且各 p 的顏色 class fragile)。
63+
"""
64+
sh = get_screenshotter(self.page)
65+
66+
expect(self.avatar).to_be_visible(timeout=10000)
67+
self.avatar.scroll_into_view_if_needed()
68+
if sh: sh.capture(self.avatar, "verify_avatar_visible")
69+
70+
self.avatar_trigger.scroll_into_view_if_needed()
71+
if sh: sh.capture(self.avatar_trigger, f"verify_帳號顯示_{username}")
72+
expect(self.avatar_trigger).to_contain_text(username, timeout=5000)
73+
74+
def verify_logged_out(self):
75+
"""驗證已登出:首頁登入入口按鈕重新出現。"""
76+
sh = get_screenshotter(self.page)
77+
expect(self.login_entry_btn).to_be_visible(timeout=10000)
78+
self.login_entry_btn.scroll_into_view_if_needed()
79+
if sh: sh.capture(self.login_entry_btn, "verify_已登出_登入按鈕出現")
80+
81+
# ------------------------------------------------------------------
82+
# 彈窗清理
83+
# ------------------------------------------------------------------
84+
85+
def dismiss_any_popups(self):
86+
"""清除首頁可能出現的彈窗(公告 + TOTP 提示)。
87+
88+
QW 兩種彈窗都用 `.popup-mask` 包覆並會 intercept pointer events:
89+
1. 公告彈窗(首頁)— `.popup-close` X 按鈕
90+
2. TOTP 安全中心提示(登入後)— button text=「下次再說」
91+
92+
策略:loop 最多 3 輪 dismiss,直到 `.popup-mask` 完全消失或 timeout。
93+
TOTP 提示可能在公告 popup 關掉後才 render,所以單次 dismiss 不夠。
94+
"""
95+
for _ in range(3):
96+
# 1. 公告彈窗
97+
try:
98+
popup_close = self.page.locator('.popup-close').first
99+
popup_close.wait_for(state="visible", timeout=1500)
100+
popup_close.click()
101+
except PlaywrightTimeoutError:
102+
pass
103+
104+
# 2. TOTP 提示(「下次再說」;多語系站台僅繁中模式下 text 如此)
105+
# TODO: 待主 context probe 確認其他語系的文案後,補充對應文案或改 CSS selector
106+
try:
107+
totp_dismiss = self.page.locator('button', has_text="下次再說")
108+
totp_dismiss.wait_for(state="visible", timeout=1500)
109+
totp_dismiss.click()
110+
except PlaywrightTimeoutError:
111+
pass
112+
113+
# 檢查所有 .popup-mask 是否都已隱藏,若是則跳出 loop
114+
try:
115+
self.page.locator('.popup-mask').first.wait_for(state="hidden", timeout=1000)
116+
break
117+
except PlaywrightTimeoutError:
118+
continue
119+
120+
# ------------------------------------------------------------------
121+
# 登出
122+
# ------------------------------------------------------------------
123+
124+
def logout(self):
125+
"""登出:hover avatar_trigger 展開 dropdown → click 登出按鈕 → 驗證登出。
126+
127+
QW avatar dropdown 由 hover 觸發(非 click,Vue/Nuxt behavior)。
128+
probe 確認 click 不展開,必須用 hover。
129+
"""
130+
sh = get_screenshotter(self.page)
131+
132+
# logout 之前再清一次彈窗(dismiss_any_popups loop 結束後 TOTP 提示可能才淡入完成)
133+
self.dismiss_any_popups()
134+
135+
# 展開 dropdown:用 JS evaluate 派發 mouseenter/mouseover
136+
# 不用 Playwright hover()(即使 force=True,Vue 的 @mouseleave 仍可能立即觸發收起 dropdown;
137+
# JS dispatch 不會觸發後續 mouseleave,dropdown 維持 open 狀態)
138+
if sh: sh.capture(self.avatar_trigger, "hover_avatar_trigger")
139+
self.avatar_trigger.evaluate("""el => {
140+
const rect = el.getBoundingClientRect();
141+
const opts = { bubbles: true, cancelable: true,
142+
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 };
143+
el.dispatchEvent(new MouseEvent('mouseenter', opts));
144+
el.dispatchEvent(new MouseEvent('mouseover', opts));
145+
}""")
146+
147+
# 等待 dropdown panel 出現
148+
avatar_menu_panel = self.page.locator('.avatar-menu__panel')
149+
expect(avatar_menu_panel).to_be_visible(timeout=5000)
150+
if sh: sh.capture(avatar_menu_panel, "verify_dropdown_opened")
151+
152+
# 點擊登出按鈕(用 CSS class,locale-agnostic)
153+
# 注意:dropdown 由 hover 維持顯示;scroll_into_view 或一般 click 會打斷 hover
154+
# 導致 dropdown 收起 → element detach。改用 dispatch_event 直接派發 click DOM event。
155+
logout_btn = self.page.locator('button.avatar-menu__logout')
156+
if sh: sh.capture(logout_btn, "click_登出")
157+
logout_btn.dispatch_event("click")
158+
159+
# 驗證登出成功:登入入口按鈕重新出現
160+
expect(self.login_entry_btn).to_be_visible(timeout=10000)
161+
self.login_entry_btn.scroll_into_view_if_needed()
162+
if sh: sh.capture(self.login_entry_btn, "verify_登出成功")

pages/qw/login_page.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""
2+
登入頁面 Page Object — qw 站點(LM來財娛樂城)
3+
框架:Nuxt (Vue);selector 來源:Chrome DevTools MCP probe(2026-05-22)
4+
5+
登入流程:
6+
首頁 → 點「登入」按鈕 → 跳轉 /auth → 填帳密 → submit → 回首頁 /
7+
8+
注意:
9+
- /login 是 Nuxt 404,正確 auth 頁是 /auth
10+
- username/password 的 placeholder 相同,必須用 type 屬性區分
11+
- 多語系站台:selector 全部 CSS-based,不綁文案
12+
- wait_until="networkidle" 保護 Nuxt hydration(與 LT 策略對齊)
13+
"""
14+
15+
from playwright.sync_api import Page, expect, TimeoutError as PlaywrightTimeoutError
16+
from utils.screenshot_helper import get_screenshotter
17+
18+
19+
class LoginPage:
20+
21+
def __init__(self, page: Page, base_url: str):
22+
self.page = page
23+
self.base_url = base_url
24+
self.auth_url = base_url.rstrip("/") + "/auth"
25+
26+
# /auth 頁面 selectors(locale-agnostic;用 type 屬性區分帳密)
27+
# 兩個 input 的 placeholder 相同,不可用 placeholder 區分
28+
self.username_input = page.locator("input.auth-input__field[type='text']")
29+
self.password_input = page.locator("input.auth-input__field[type='password']")
30+
31+
# submit 按鈕:用 class 精準命中「立即登入」送出按鈕
32+
# 注意:button DOM 上沒有 `type` attribute(HTMLButtonElement 預設 type 是 submit
33+
# 但屬性未寫),所以 [type='submit'] selector 命不中。直接靠 class 組合區分:
34+
# - 立即登入:.solid-btn-shared.auth-btn
35+
# - 先去逛逛:.outline-btn-shared.auth-btn(不含 solid)
36+
self.submit_button = page.locator("button.solid-btn-shared.auth-btn")
37+
38+
def goto(self):
39+
"""直接導向 /auth(Nuxt:等 networkidle 確保 hydration 完成再 fill)"""
40+
self.page.goto(self.auth_url, wait_until="networkidle")
41+
self.username_input.wait_for(state="visible", timeout=10000)
42+
43+
def goto_and_login(self, username: str, password: str):
44+
"""完整登入流程:導向 /auth → 填帳密 → 送出 → 等跳轉回首頁
45+
46+
wait_until="networkidle" 策略:
47+
- QW 為 Nuxt,hydration 前 fill 可能造成 submit 無效(與 LT React SPA 相同症狀)
48+
- 保守選擇 networkidle;若實跑發現過慢,主 context 可切換為 domcontentloaded + retry
49+
"""
50+
sh = get_screenshotter(self.page)
51+
52+
self.goto()
53+
54+
# 填入帳號
55+
self.username_input.scroll_into_view_if_needed()
56+
if sh: sh.capture(self.username_input, "fill_username")
57+
self.username_input.fill(username)
58+
59+
# 填入密碼
60+
self.password_input.scroll_into_view_if_needed()
61+
if sh: sh.capture(self.password_input, "fill_password")
62+
self.password_input.fill(password)
63+
64+
# 點擊送出按鈕
65+
self.submit_button.scroll_into_view_if_needed()
66+
if sh: sh.capture(self.submit_button, "click_login_submit")
67+
self.submit_button.click()
68+
69+
# 等待跳轉回首頁(URL 離開 /auth)
70+
try:
71+
self.page.wait_for_url(
72+
lambda url: "/auth" not in url,
73+
timeout=15000
74+
)
75+
except PlaywrightTimeoutError:
76+
# URL 未離開 /auth:可能登入失敗(錯誤帳密),留給 test 驗證
77+
pass
78+
79+
if sh: sh.full_page("verify_login_success")

pytest.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ markers =
2929
rc: rc 站點(王老吉娛樂城)專屬測試
3030
re: re 站點(BeWin)專屬測試
3131
rd: rd 站點(狗狗娛樂城)專屬測試
32+
qw: qw 站點(LM來財娛樂城)專屬測試
3233
i18n: 多語系文案驗證
3334
copy: 文案一致性驗證
3435
visual: 視覺健康度驗證(DOM metrics)

tests/qw/__init__.py

Whitespace-only changes.

tests/qw/conftest.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""
2+
qw 站點測試專用 conftest(LM來財娛樂城)
3+
- 覆寫 site_config fixture,讓 tests/qw/ 下的測試不需加 --site=qw 即可執行
4+
- 不覆寫 page fixture:QW 無 toast-confirm-btn MutationObserver 需求;
5+
但全域 conftest.py 注入的 observer 對 QW 是 no-op(selector 不匹配),可沿用。
6+
- 覆寫 go_home fixture:每個 functional test 前回首頁並清掉彈窗
7+
"""
8+
9+
import pytest
10+
from config.settings import get_site_config
11+
from pages.factory import get_home_page_class
12+
13+
14+
@pytest.fixture(scope="session")
15+
def site_config():
16+
"""固定使用 qw 站設定"""
17+
return get_site_config("qw")
18+
19+
20+
@pytest.fixture(scope="function")
21+
def go_home(class_logged_in_page, site_config):
22+
"""
23+
[QW 覆寫] 每個測試前回到首頁並清理彈窗。
24+
使用 HomePage.dismiss_any_popups() 清除公告彈窗與 TOTP 提示。
25+
"""
26+
pg = class_logged_in_page
27+
pg.goto(site_config.url, wait_until="networkidle")
28+
29+
HomeCls = get_home_page_class("qw")
30+
home = HomeCls(pg)
31+
home.dismiss_any_popups()
32+
33+
yield

tests/qw/feature/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)