diff --git a/pyproject.toml b/pyproject.toml index 67fd15b78d..815377dfd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -174,6 +174,7 @@ test = [ "requests>=2.32.2", "requests-toolbelt>=1.0.0", "pycryptodomex==3.20.0", + "pytest-playwright>=0.7.2", "codecov>=2.1.13", ] @@ -211,9 +212,12 @@ python_classes = ["Test*"] python_functions = ["test_*"] markers = [ + "p0: critical priority test cases", "p1: high priority test cases", "p2: medium priority test cases", "p3: low priority test cases", + "smoke: smoke test cases", + "auth: authentication UI tests", ] # Test collection and runtime configuration diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/benchmark/test_docs/dv.json b/test/benchmark/test_docs/dv.json new file mode 100644 index 0000000000..acf294c013 --- /dev/null +++ b/test/benchmark/test_docs/dv.json @@ -0,0 +1,108 @@ +{ + "graph": { + "nodes": [ + { + "data": { + "form": { + "mode": "conversational", + "prologue": "Hi! I'm your assistant. What can I do for you?" + }, + "label": "Begin", + "name": "begin" + }, + "id": "begin", + "position": { "x": 50, "y": 200 }, + "sourcePosition": "left", + "targetPosition": "right", + "type": "beginNode", + "measured": { "width": 200, "height": 82 } + }, + { + "id": "Agent:DryBottlesUnite", + "type": "agentNode", + "position": { "x": 426.80683432048755, "y": 186.8225437237188 }, + "data": { + "label": "Agent", + "name": "Agent_0", + "form": { + "temperatureEnabled": false, + "topPEnabled": false, + "presencePenaltyEnabled": false, + "frequencyPenaltyEnabled": false, + "maxTokensEnabled": false, + "temperature": 0.1, + "top_p": 0.3, + "frequency_penalty": 0.7, + "presence_penalty": 0.4, + "max_tokens": 256, + "description": "", + "user_prompt": "", + "sys_prompt": "\n \n You are a helpful assistant, an AI assistant specialized in problem-solving for the user.\n If a specific domain is provided, adapt your expertise to that domain; otherwise, operate as a generalist.\n \n \n 1. Understand the user’s request.\n 2. Decompose it into logical subtasks.\n 3. Execute each subtask step by step, reasoning transparently.\n 4. Validate accuracy and consistency.\n 5. Summarize the final result clearly.\n ", + "prompts": [{ "role": "user", "content": "{sys.query}" }], + "message_history_window_size": 12, + "max_retries": 3, + "delay_after_error": 1, + "visual_files_var": "", + "max_rounds": 1, + "exception_method": "", + "exception_goto": [], + "exception_default_value": "", + "tools": [], + "mcp": [], + "cite": true, + "showStructuredOutput": false, + "outputs": { "content": { "type": "string", "value": "" } }, + "llm_id": "glm-4-flash@ZHIPU-AI" + } + }, + "sourcePosition": "right", + "targetPosition": "left", + "measured": { "width": 200, "height": 90 }, + "selected": false, + "dragging": false + }, + { + "id": "Message:DarkPlanetsTalk", + "type": "messageNode", + "position": { "x": 752.3381558557825, "y": 193.4112718618594 }, + "data": { + "label": "Message", + "name": "Message_0", + "form": { "content": ["{Agent:DryBottlesUnite@content}"] } + }, + "sourcePosition": "right", + "targetPosition": "left", + "measured": { "width": 200, "height": 86 }, + "selected": true, + "dragging": false + } + ], + "edges": [ + { + "source": "Agent:DryBottlesUnite", + "target": "Message:DarkPlanetsTalk", + "sourceHandle": "start", + "targetHandle": "end", + "id": "xy-edge__Agent:DryBottlesUnitestart-Message:DarkPlanetsTalkend", + "data": { "isHovered": false } + }, + { + "type": "buttonEdge", + "markerEnd": "logo", + "zIndex": 1001, + "source": "begin", + "sourceHandle": "start", + "target": "Agent:DryBottlesUnite", + "targetHandle": "end", + "id": "xy-edge__beginstart-Agent:DryBottlesUniteend" + } + ] + }, + "globals": { + "sys.conversation_turns": 0, + "sys.files": [], + "sys.query": "", + "sys.user_id": "" + }, + "variables": [] +} diff --git a/test/playwright/.gitignore b/test/playwright/.gitignore new file mode 100644 index 0000000000..466e1fc6ce --- /dev/null +++ b/test/playwright/.gitignore @@ -0,0 +1,3 @@ +artifacts/ +.auth +.pytest_cache \ No newline at end of file diff --git a/test/playwright/README.md b/test/playwright/README.md new file mode 100644 index 0000000000..94c0f1885d --- /dev/null +++ b/test/playwright/README.md @@ -0,0 +1,122 @@ +# Playwright auth UI tests + +## Quick start + +Smoke test (always runs at least one test): + +```bash +pytest -q test/playwright -m smoke +``` + +Run all auth UI tests: + +```bash +pytest -q test/playwright -m auth +``` + +If you use `uv`: + +```bash +uv run pytest -q test/playwright -m smoke +``` + +## Environment variables + +Required/optional: + +- `BASE_URL` (default: `http://127.0.0.1`) + - Example dev UI: `http://localhost:9222`. + - For Docker (`SVR_WEB_HTTP_PORT=80`), set `BASE_URL=http://localhost`. +- `LOGIN_PATH` (default: `/login`) +- `SEEDED_USER_EMAIL` and `SEEDED_USER_PASSWORD` (optional; enables login success test) +- `DEMO_CREDS=1` (optional; uses demo credentials `qa@infiniflow.com` / `123` for login success test) +- `REG_EMAIL_BASE` (default: `qa@infiniflow.org`) +- `REG_EMAIL_UNIQUE=1` (optional; enables unique registration emails like `qa_1700000000000_123456@infiniflow.org`) +- `POST_LOGIN_PATH` (optional; expected path after login success, e.g. `/`) +- `REGISTER_ENABLED_EXPECTED` (optional; reserved for future gating checks) + +Diagnostics and debugging: + +- `PW_STEP_LOG=1` enable step logging +- `PW_NET_LOG=1` log `requestfailed` + console errors during the run +- `PW_TRACE=1` save a Playwright trace on failure +- `PW_BROWSER` (default: `chromium`) +- `PW_HEADLESS` (default: `1`, set `0` to see the browser) +- `PLAYWRIGHT_ACTION_TIMEOUT_MS` (default: `30000`) + - Legacy: `PW_TIMEOUT_MS` +- `PW_SLOWMO_MS` (default: `0`) +- `PLAYWRIGHT_HANG_TIMEOUT_S` (default: `1800`, set `0` to disable) + - Legacy: `HANG_TIMEOUT_S` +- `PLAYWRIGHT_AUTH_READY_TIMEOUT_MS` (default: `15000`) + - Legacy: `AUTH_READY_TIMEOUT_MS` + +## What runs without credentials + +- `auth/test_smoke_auth_page.py` (marker: `smoke`, always runs) +- `auth/test_toggle_login_register.py` (skips if register toggle is gated off) +- `auth/test_validation_presence.py` +- `auth/test_sso_optional.py` (skips if no SSO providers are rendered) +- `auth/test_register_success_optional.py` (skips if register toggle is gated off) +- `auth/test_register_then_login_flow.py` (skips unless `REG_EMAIL_UNIQUE=1`) + +`auth/test_login_success_optional.py` only runs if `DEMO_CREDS=1` or `SEEDED_USER_EMAIL` and `SEEDED_USER_PASSWORD` are set. + +## Login success examples + +Run with demo credentials: + +```bash +DEMO_CREDS=1 BASE_URL=http://localhost:9222 \ + pytest -q test/playwright/auth/test_login_success_optional.py::test_login_success_optional -s -vv +``` + +Run with env credentials: + +```bash +SEEDED_USER_EMAIL=user@yourdomain.com SEEDED_USER_PASSWORD=secret BASE_URL=http://localhost:9222 \ + pytest -q test/playwright/auth/test_login_success_optional.py::test_login_success_optional -s -vv +``` + +## Registration examples + +Registration rejects plus-addressing; the backend only allows local-part characters `[A-Za-z0-9_.-]`. + +Register only: + +```bash +REG_EMAIL_UNIQUE=1 BASE_URL=http://localhost:9222 \ + pytest -q test/playwright/auth/test_register_success_optional.py::test_register_success_optional -s -vv +``` + +Register then login (single test): + +```bash +REG_EMAIL_UNIQUE=1 BASE_URL=http://localhost:9222 \ + pytest -q test/playwright/auth/test_register_then_login_flow.py::test_register_then_login_flow -s -vv +``` + +Run the end-to-end demo script: + +```bash +BASE_URL=http://localhost:9222 \ + scripts/run_auth_demo.sh +``` + +## Artifacts on failure + +Artifacts are written to: + +- `test/playwright/artifacts/` + - per-test screenshots are stored under `test/playwright/artifacts//` + +On failure, the suite writes: + +- a full-page screenshot (`.png`) +- a full HTML dump (`.html`) +- a diagnostics log (`.log`) +- an optional trace (`.zip`) if `PW_TRACE=1` + +## Hang investigation + +- Automatic stack dump after `PLAYWRIGHT_HANG_TIMEOUT_S` seconds. +- Manual dump: `kill -USR1 ` (writes traceback to stderr). diff --git a/test/playwright/__init__.py b/test/playwright/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/playwright/auth/test_login_success_optional.py b/test/playwright/auth/test_login_success_optional.py new file mode 100644 index 0000000000..e7fc29fbf5 --- /dev/null +++ b/test/playwright/auth/test_login_success_optional.py @@ -0,0 +1,248 @@ +import json +import os +from urllib.parse import urlparse + +import pytest +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError +from playwright.sync_api import expect + +from test.playwright.helpers.auth_selectors import ( + AUTH_ACTIVE_FORM, + AUTH_STATUS, + EMAIL_INPUT, + PASSWORD_INPUT, + SUBMIT_BUTTON, +) +from test.playwright.helpers.auth_waits import wait_for_login_complete +from test.playwright.helpers.env_utils import env_bool +from test.playwright.helpers.flow_steps import flow_params, require + +DEMO_EMAIL = "qa@infiniflow.com" +DEMO_PASSWORD = "123" + + +def _resolve_creds(): + if env_bool("DEMO_CREDS"): + return DEMO_EMAIL, DEMO_PASSWORD, "demo" + email = os.getenv("SEEDED_USER_EMAIL") + password = os.getenv("SEEDED_USER_PASSWORD") + if not email or not password: + return None + return email, password, "env" + + +def _debug_login_state(page, label: str) -> None: + if not env_bool("PW_DEBUG_DUMP"): + return + try: + title = page.title() + except Exception as exc: + title = f"" + try: + storage_flags = page.evaluate( + """ + () => Array.from(document.querySelectorAll('[data-testid]')) + .map((el) => el.getAttribute('data-testid')) + .filter((val) => val && /auth/i.test(val)) + .slice(0, 30) + """ + ) + except Exception as exc: + storage_flags = {"error": str(exc)} + print( + f"[auth-debug] label={label} url={page.url} title={title} storage={storage_flags}", + flush=True, + ) + + +def step_01_open_login( + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + _ = seeded_user_credentials + creds = _resolve_creds() + if not creds: + pytest.skip("SEEDED_USER_EMAIL/SEEDED_USER_PASSWORD not set and DEMO_CREDS=1 not enabled") + seeded_email, seeded_password, source = creds + if source == "env": + lowered = seeded_email.lower() + example_domain = "infiniflow.io" + if lowered.endswith(f"@{example_domain}"): + raise AssertionError( + "SEEDED_USER_EMAIL must be a real account (not *@example.com). " + "Set valid credentials or use DEMO_CREDS=1 for demo mode." + ) + print(f"[AUTH] using email: {seeded_email} (source={source})", flush=True) + flow_state["seeded_email"] = seeded_email + flow_state["seeded_password"] = seeded_password + flow_state["login_opened"] = True + + with step("open login page"): + flow_page.goto(login_url, wait_until="domcontentloaded") + snap("open") + + +def step_02_submit_login( + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "login_opened", "seeded_email", "seeded_password") + form, _ = active_auth_context() + email_input = form.locator(EMAIL_INPUT) + password_input = form.locator(PASSWORD_INPUT) + + with step("fill credentials"): + expect(email_input).to_have_count(1) + expect(password_input).to_have_count(1) + email_input.fill(flow_state["seeded_email"]) + password_input.fill(flow_state["seeded_password"]) + expect(password_input).to_have_attribute("type", "password") + password_input.blur() + snap("filled") + + with step("submit login"): + submit_button = form.locator(SUBMIT_BUTTON) + expect(submit_button).to_have_count(1) + auth_click(submit_button, "submit_login") + flow_state["login_submitted"] = True + snap("submitted") + + +def step_03_verify_login( + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "login_submitted") + page = flow_page + post_login_path = os.getenv("POST_LOGIN_PATH") + post_login_path_js = json.dumps(post_login_path) + auth_status_selector = json.dumps(AUTH_STATUS) + wait_js = """ + () => {{ + const postLoginPath = {post_login_path}; + const isVisible = (el) => {{ + if (!el) return false; + const style = window.getComputedStyle(el); + if (style && (style.visibility === 'hidden' || style.display === 'none')) {{ + return false; + }} + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }}; + const path = window.location.pathname || ''; + const successByUrl = postLoginPath + ? path.startsWith(postLoginPath) + : !path.includes('/login'); + const successMarker = document.querySelector( + "a[href*='github.com/infiniflow/ragflow'], a[href*='discord.com/invite']" + ); + const authStatus = document.querySelector({auth_status_selector}); + const statusState = authStatus ? authStatus.getAttribute('data-state') : ''; + if (statusState === 'error') return {{ state: 'error' }}; + if (statusState === 'success') return {{ state: 'success' }}; + if (successByUrl || successMarker) return {{ state: 'success' }}; + return false; + }} + """.format( + post_login_path=post_login_path_js, + auth_status_selector=auth_status_selector, + ) + + with step("wait for success or error"): + try: + result = page.wait_for_function( + wait_js, + timeout=15000, + ) + except PlaywrightTimeoutError as exc: + snap("failure") + _debug_login_state(page, "wait_for_outcome_timeout") + raise AssertionError( + f"Login result did not resolve in time. url={page.url}" + ) from exc + + with step("verify authenticated UI marker"): + outcome = result.json_value() + if outcome.get("state") == "error": + snap("error") + snap("failure") + _debug_login_state(page, "login_error") + raise AssertionError( + "Login error detected. " + f"url={page.url}" + ) + path = urlparse(page.url).path + if post_login_path: + if not path.startswith(post_login_path): + snap("failure") + _debug_login_state(page, "post_login_path_mismatch") + raise AssertionError( + f"Post-login path mismatch. expected_prefix={post_login_path} url={page.url}" + ) + elif "/login" in path: + snap("failure") + _debug_login_state(page, "still_on_login_path") + raise AssertionError(f"URL still on login after submit. url={page.url}") + + with step("verify auth tokens and login form hidden"): + wait_for_login_complete(page, timeout_ms=15000) + try: + expect(page.locator(AUTH_ACTIVE_FORM)).to_have_count(0, timeout=15000) + except AssertionError as exc: + snap("failure") + _debug_login_state(page, "login_form_still_visible") + raise AssertionError( + f"Login form still visible after login. url={page.url}" + ) from exc + snap("success") + + +STEPS = [ + ("01_open_login", step_01_open_login), + ("02_submit_login", step_02_submit_login), + ("03_verify_login", step_03_verify_login), +] + + +@pytest.mark.p1 +@pytest.mark.auth +@pytest.mark.parametrize("step_fn", flow_params(STEPS)) +def test_login_success_optional_flow( + step_fn, + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + step_fn( + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, + ) diff --git a/test/playwright/auth/test_register_success_optional.py b/test/playwright/auth/test_register_success_optional.py new file mode 100644 index 0000000000..57337212d0 --- /dev/null +++ b/test/playwright/auth/test_register_success_optional.py @@ -0,0 +1,256 @@ +import json +import os + +import pytest +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError +from playwright.sync_api import expect + +from test.playwright.helpers.auth_selectors import ( + AUTH_STATUS, + EMAIL_INPUT, + NICKNAME_INPUT, + PASSWORD_INPUT, + REGISTER_TAB, + SUBMIT_BUTTON, +) +from test.playwright.helpers.flow_steps import flow_params, require +from test.playwright.helpers.response_capture import capture_response_json + +RESULT_TIMEOUT_MS = 15000 + + +def _debug_register_response(page, response_info: dict) -> None: + if not os.getenv("PW_DEBUG_DUMP"): + return + message = response_info.get("message") + if isinstance(message, str) and len(message) > 300: + message = message[:300] + print( + "[auth-debug] register_response " + f"url={response_info.get('__url__')} status={response_info.get('__status__')} " + f"code={response_info.get('code')} message={message}", + flush=True, + ) + try: + sonner = page.locator("[data-sonner-toast]") + if sonner.count() > 0: + html = sonner.first.evaluate("el => el.outerHTML.slice(0, 300)") + print(f"[auth-debug] sonner_toast={html}", flush=True) + except Exception as exc: + print(f"[auth-debug] sonner_toast_dump_failed: {exc}", flush=True) + + +def _is_already_registered(toast_text: str) -> bool: + text = (toast_text or "").lower() + return "already" in text and ("register" in text or "registered" in text) + + +def _wait_for_auth_not_loading(page, timeout_ms: int = 5000) -> None: + auth_status_selector = json.dumps(AUTH_STATUS) + page.wait_for_function( + """ + () => { + const status = document.querySelector(%s); + if (!status) return true; + return status.getAttribute('data-state') !== 'loading'; + } + """ % auth_status_selector, + timeout=timeout_ms, + ) + + +def step_01_open_login( + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_debug_dump, + auth_click, + reg_email, + reg_email_generator, + reg_password, + reg_nickname, + reg_email_unique, +): + page = flow_page + with step("open login page"): + page.goto(login_url, wait_until="domcontentloaded") + flow_state["login_opened"] = True + snap("open") + + +def step_02_switch_to_register( + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_debug_dump, + auth_click, + reg_email, + reg_email_generator, + reg_password, + reg_nickname, + reg_email_unique, +): + require(flow_state, "login_opened") + form, card = active_auth_context() + toggle_button = card.locator(REGISTER_TAB) + if toggle_button.count() == 0: + flow_state["register_toggle_available"] = False + pytest.skip("Register toggle not present; registerEnabled may be disabled.") + + with step("switch to register"): + expect(toggle_button).to_have_count(1) + toggle_button.click() + flow_state["register_toggle_available"] = True + snap("toggled_register") + + +def step_03_submit_registration( + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_debug_dump, + auth_click, + reg_email, + reg_email_generator, + reg_password, + reg_nickname, + reg_email_unique, +): + require(flow_state, "login_opened", "register_toggle_available") + page = flow_page + form, _ = active_auth_context() + nickname_input = form.locator(NICKNAME_INPUT) + if nickname_input.count() == 0: + pytest.skip("Register form not active; cannot submit registration.") + + email_input = form.locator(EMAIL_INPUT) + password_input = form.locator(PASSWORD_INPUT) + + current_email = reg_email + with step("fill registration form"): + expect(email_input).to_have_count(1) + expect(password_input).to_have_count(1) + nickname_input.fill(reg_nickname) + email_input.fill(current_email) + password_input.fill(reg_password) + expect(password_input).to_have_attribute("type", "password") + password_input.blur() + snap("filled") + + retried = False + while True: + with step("submit registration and wait for response"): + form, _ = active_auth_context() + submit_button = form.locator(SUBMIT_BUTTON) + expect(submit_button).to_have_count(1) + if not retried: + snap("before_submit_click") + auth_debug_dump("before_submit_click", submit_button) + + try: + response_info = capture_response_json( + page, + lambda: ( + auth_click( + submit_button, + "submit_register_retry" if retried else "submit_register", + ), + snap("retry_submitted" if retried else "submitted"), + ), + lambda resp: resp.request.method == "POST" + and "/v1/user/register" in resp.url, + timeout_ms=RESULT_TIMEOUT_MS, + ) + except PlaywrightTimeoutError as exc: + snap("failure") + raise AssertionError( + f"Register response not received in time. url={page.url} email={current_email}" + ) from exc + + _debug_register_response(page, response_info) + + if response_info.get("code") == 0: + snap("registered_success_response") + form, _ = active_auth_context() + nickname_input = form.locator(NICKNAME_INPUT) + expect(nickname_input).to_have_count(0, timeout=RESULT_TIMEOUT_MS) + break + + snap("registered_error_response") + message_text = response_info.get("message", "") or "" + if _is_already_registered(message_text) and not retried: + retried = True + with step("retry registration with new email"): + _wait_for_auth_not_loading(page) + form, _ = active_auth_context() + email_input = form.locator(EMAIL_INPUT) + expect(email_input).to_have_count(1) + current_email = reg_email_generator(force_unique=True) + email_input.fill(current_email) + snap("retry_filled") + continue + + snap("failure") + raise AssertionError( + "Registration error detected. " + f"url={response_info.get('__url__')} status={response_info.get('__status__')} " + f"code={response_info.get('code')} message={response_info.get('message')} " + f"email={current_email}" + ) + + snap("success") + flow_state["register_complete"] = True + flow_state["registered_email"] = current_email + print(f"REGISTERED_EMAIL={current_email}", flush=True) + + +STEPS = [ + ("01_open_login", step_01_open_login), + ("02_switch_to_register", step_02_switch_to_register), + ("03_submit_registration", step_03_submit_registration), +] + + +@pytest.mark.p1 +@pytest.mark.auth +@pytest.mark.parametrize("step_fn", flow_params(STEPS)) +def test_register_success_optional_flow( + step_fn, + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_debug_dump, + auth_click, + reg_email, + reg_email_generator, + reg_password, + reg_nickname, + reg_email_unique, +): + step_fn( + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_debug_dump, + auth_click, + reg_email, + reg_email_generator, + reg_password, + reg_nickname, + reg_email_unique, + ) diff --git a/test/playwright/auth/test_register_then_login_flow.py b/test/playwright/auth/test_register_then_login_flow.py new file mode 100644 index 0000000000..dc1ae5ee3d --- /dev/null +++ b/test/playwright/auth/test_register_then_login_flow.py @@ -0,0 +1,323 @@ +import json +import os +from urllib.parse import urlparse + +import pytest +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError +from playwright.sync_api import expect + +from test.playwright.helpers.auth_selectors import ( + AUTH_STATUS, + EMAIL_INPUT, + NICKNAME_INPUT, + PASSWORD_INPUT, + REGISTER_TAB, + SUBMIT_BUTTON, +) +from test.playwright.helpers.flow_steps import flow_params, require +from test.playwright.helpers.response_capture import capture_response_json + +RESULT_TIMEOUT_MS = 15000 + + +def _debug_register_response(page, response_info: dict) -> None: + if not os.getenv("PW_DEBUG_DUMP"): + return + message = response_info.get("message") + if isinstance(message, str) and len(message) > 300: + message = message[:300] + print( + "[auth-debug] register_response " + f"url={response_info.get('__url__')} status={response_info.get('__status__')} " + f"code={response_info.get('code')} message={message}", + flush=True, + ) + try: + sonner = page.locator("[data-sonner-toast]") + if sonner.count() > 0: + html = sonner.first.evaluate("el => el.outerHTML.slice(0, 300)") + print(f"[auth-debug] sonner_toast={html}", flush=True) + except Exception as exc: + print(f"[auth-debug] sonner_toast_dump_failed: {exc}", flush=True) + + +def _wait_for_login_outcome( + page, post_login_path: str | None, timeout_ms: int = RESULT_TIMEOUT_MS +): + auth_status_selector = json.dumps(AUTH_STATUS) + return page.wait_for_function( + """ + (postLoginPath) => { + const isVisible = (el) => { + if (!el) return false; + const style = window.getComputedStyle(el); + if (style && (style.visibility === 'hidden' || style.display === 'none')) { + return false; + } + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const authStatus = document.querySelector(%s); + const statusState = authStatus ? authStatus.getAttribute('data-state') : ''; + if (statusState === 'error') return { state: 'error' }; + if (statusState === 'success') return { state: 'success' }; + + const path = window.location.pathname || ''; + const successByUrl = postLoginPath + ? path.startsWith(postLoginPath) + : !path.includes('/login'); + const successMarker = document.querySelector( + "a[href*='github.com/infiniflow/ragflow'], a[href*='discord.com/invite']" + ); + if (successByUrl || successMarker) return { state: 'success' }; + return false; + } + """ % auth_status_selector, + post_login_path, + timeout=timeout_ms, + ) + + +def step_01_open_login( + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_click, + reg_email, + reg_password, + reg_nickname, + reg_email_unique, +): + with step("open login page"): + flow_page.goto(login_url, wait_until="domcontentloaded") + flow_state["login_opened"] = True + snap("open") + + +def step_02_switch_to_register( + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_click, + reg_email, + reg_password, + reg_nickname, + reg_email_unique, +): + require(flow_state, "login_opened") + if not reg_email_unique: + flow_state["reg_email_unique"] = False + pytest.skip("Set REG_EMAIL_UNIQUE=1 for deterministic register→login flow.") + flow_state["reg_email_unique"] = True + form, card = active_auth_context() + toggle_button = card.locator(REGISTER_TAB) + if toggle_button.count() == 0: + flow_state["register_toggle_available"] = False + pytest.skip("Register toggle not present; registerEnabled may be disabled.") + + with step("switch to register"): + expect(toggle_button).to_have_count(1) + toggle_button.click() + flow_state["register_toggle_available"] = True + snap("register_toggled") + + +def step_03_register_user( + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_click, + reg_email, + reg_password, + reg_nickname, + reg_email_unique, +): + require(flow_state, "login_opened", "register_toggle_available", "reg_email_unique") + page = flow_page + form, _ = active_auth_context() + nickname_input = form.locator(NICKNAME_INPUT) + expect(nickname_input).to_have_count(1) + expect(nickname_input).to_be_visible() + + email_input = form.locator(EMAIL_INPUT) + password_input = form.locator(PASSWORD_INPUT) + + with step("fill registration form"): + expect(email_input).to_have_count(1) + expect(password_input).to_have_count(1) + nickname_input.fill(reg_nickname) + email_input.fill(reg_email) + password_input.fill(reg_password) + expect(password_input).to_have_attribute("type", "password") + password_input.blur() + snap("register_filled") + + with step("submit registration and wait for response"): + submit_button = form.locator(SUBMIT_BUTTON) + expect(submit_button).to_have_count(1) + try: + response_info = capture_response_json( + page, + lambda: ( + auth_click(submit_button, "submit_register"), + snap("register_submitted"), + ), + lambda resp: resp.request.method == "POST" + and "/v1/user/register" in resp.url, + timeout_ms=RESULT_TIMEOUT_MS, + ) + except PlaywrightTimeoutError as exc: + snap("register_failure") + raise AssertionError( + f"Register response not received in time. url={page.url}" + ) from exc + + _debug_register_response(page, response_info) + + if response_info.get("code") != 0: + snap("register_error_response") + snap("register_failure") + raise AssertionError( + "Registration error detected. " + f"url={response_info.get('__url__')} status={response_info.get('__status__')} " + f"code={response_info.get('code')} message={response_info.get('message')}" + ) + + snap("register_success_response") + form, _ = active_auth_context() + nickname_input = form.locator(NICKNAME_INPUT) + expect(nickname_input).to_have_count(0, timeout=RESULT_TIMEOUT_MS) + snap("register_success") + flow_state["registered_email"] = reg_email + flow_state["registered_password"] = reg_password + flow_state["register_complete"] = True + print(f"REGISTERED_EMAIL={reg_email}", flush=True) + + +def step_04_login_user( + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_click, + reg_email, + reg_password, + reg_nickname, + reg_email_unique, +): + require(flow_state, "register_complete", "registered_email", "registered_password") + form, _ = active_auth_context() + with step("fill login form"): + email_input = form.locator(EMAIL_INPUT) + password_input = form.locator(PASSWORD_INPUT) + expect(email_input).to_have_count(1) + expect(password_input).to_have_count(1) + email_input.fill(flow_state["registered_email"]) + password_input.fill(flow_state["registered_password"]) + expect(password_input).to_have_attribute("type", "password") + password_input.blur() + snap("login_filled") + + with step("submit login"): + submit_button = form.locator(SUBMIT_BUTTON) + expect(submit_button).to_have_count(1) + auth_click(submit_button, "submit_login") + snap("login_submitted") + + +def step_05_verify_login( + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_click, + reg_email, + reg_password, + reg_nickname, + reg_email_unique, +): + require(flow_state, "register_complete") + page = flow_page + post_login_path = os.getenv("POST_LOGIN_PATH") + + with step("wait for login outcome"): + try: + login_result = _wait_for_login_outcome(page, post_login_path) + except PlaywrightTimeoutError as exc: + snap("login_failure") + raise AssertionError( + f"Login result did not resolve in time. url={page.url}" + ) from exc + + login_outcome = login_result.json_value() + if login_outcome.get("state") == "error": + snap("login_error") + snap("login_failure") + raise AssertionError(f"Login error detected. url={page.url}") + + path = urlparse(page.url).path + if post_login_path: + if not path.startswith(post_login_path): + snap("login_failure") + raise AssertionError( + f"Post-login path mismatch. expected_prefix={post_login_path} url={page.url}" + ) + elif "/login" in path: + snap("login_failure") + raise AssertionError(f"URL still on login after submit. url={page.url}") + + snap("login_success") + + +STEPS = [ + ("01_open_login", step_01_open_login), + ("02_switch_to_register", step_02_switch_to_register), + ("03_register_user", step_03_register_user), + ("04_login_user", step_04_login_user), + ("05_verify_login", step_05_verify_login), +] + + +@pytest.mark.p0 +@pytest.mark.auth +@pytest.mark.parametrize("step_fn", flow_params(STEPS)) +def test_register_then_login_flow( + step_fn, + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_click, + reg_email, + reg_password, + reg_nickname, + reg_email_unique, +): + step_fn( + flow_page, + flow_state, + login_url, + active_auth_context, + step, + snap, + auth_click, + reg_email, + reg_password, + reg_nickname, + reg_email_unique, + ) diff --git a/test/playwright/auth/test_smoke_auth_page.py b/test/playwright/auth/test_smoke_auth_page.py new file mode 100644 index 0000000000..e66e81de63 --- /dev/null +++ b/test/playwright/auth/test_smoke_auth_page.py @@ -0,0 +1,79 @@ +import pytest + +from test.playwright.helpers.flow_context import FlowContext +from test.playwright.helpers.flow_steps import flow_params, require + + +def step_01_open_login(ctx: FlowContext, step, snap): + page = ctx.page + with step("navigate to login page"): + response = page.goto(ctx.smoke_login_url, wait_until="domcontentloaded") + ctx.state["smoke_opened"] = True + ctx.state["smoke_response"] = response + + +def step_02_validate_page(ctx: FlowContext, step, snap): + require(ctx.state, "smoke_opened") + page = ctx.page + response = ctx.state.get("smoke_response") + content = page.content() + content_type = "" + status = None + if response is not None: + status = response.status + content_type = response.headers.get("content-type", "") + + content_head = content.lstrip()[:200] + looks_json = content_head.startswith("{") or content_head.startswith("[") + is_html = "text/html" in content_type.lower() or "= 400: + raise AssertionError(_format_diag(page, response, "HTTP error status")) + + if looks_json or not is_html: + raise AssertionError(_format_diag(page, response, "Non-HTML response")) + + root_count = page.locator("#root").count() + input_count = page.locator("input").count() + logo_count = page.locator("img[alt='logo']").count() + if root_count + input_count + logo_count == 0: + raise AssertionError( + _format_diag(page, response, "No SPA root, inputs, or logo found") + ) + + +STEPS = [ + ("01_open_login", step_01_open_login), + ("02_validate_page", step_02_validate_page), +] + + +@pytest.mark.smoke +@pytest.mark.p0 +@pytest.mark.auth +@pytest.mark.parametrize("step_fn", flow_params(STEPS)) +def test_auth_page_smoke_flow( + step_fn, flow_page, flow_state, base_url, smoke_login_url, step, snap +): + ctx = FlowContext( + page=flow_page, + state=flow_state, + base_url=base_url, + login_url=smoke_login_url, + smoke_login_url=smoke_login_url, + ) + step_fn(ctx, step, snap) + + +def _format_diag(page, response, reason: str) -> str: + status = response.status if response is not None else "" + content_type = "" + if response is not None: + content_type = response.headers.get("content-type", "") + url = page.url + title = page.title() + snippet = page.content().strip().replace("\n", " ")[:500] + return ( + f"{reason}. url={url} title={title} status={status} " + f"content_type={content_type} snippet={snippet}" + ) diff --git a/test/playwright/auth/test_sso_optional.py b/test/playwright/auth/test_sso_optional.py new file mode 100644 index 0000000000..a33ab1feae --- /dev/null +++ b/test/playwright/auth/test_sso_optional.py @@ -0,0 +1,50 @@ +import re + +import pytest + +from test.playwright.helpers.flow_steps import flow_params, require + + +def step_01_open_login(flow_page, flow_state, login_url, active_auth_context, step, snap): + with step("open login page"): + flow_page.goto(login_url, wait_until="domcontentloaded") + flow_state["login_opened"] = True + snap("open") + + +def step_02_initiate_sso(flow_page, flow_state, login_url, active_auth_context, step, snap): + require(flow_state, "login_opened") + page = flow_page + form, _ = active_auth_context() + sso_buttons = form.locator("button:has-text('Sign in with')") + if sso_buttons.count() == 0: + pytest.skip("No SSO providers rendered on the login page") + + with step("initiate SSO navigation"): + clicked = False + for handle in sso_buttons.element_handles(): + if handle.is_visible() and handle.is_enabled(): + handle.click() + clicked = True + break + if not clicked: + pytest.skip("SSO buttons were present but not interactable") + + page.wait_for_url(re.compile(r".*/v1/user/login/"), timeout=5000) + flow_state["sso_clicked"] = True + snap("sso_clicked") + + +STEPS = [ + ("01_open_login", step_01_open_login), + ("02_initiate_sso", step_02_initiate_sso), +] + + +@pytest.mark.p1 +@pytest.mark.auth +@pytest.mark.parametrize("step_fn", flow_params(STEPS)) +def test_sso_optional_flow( + step_fn, flow_page, flow_state, login_url, active_auth_context, step, snap +): + step_fn(flow_page, flow_state, login_url, active_auth_context, step, snap) diff --git a/test/playwright/auth/test_toggle_login_register.py b/test/playwright/auth/test_toggle_login_register.py new file mode 100644 index 0000000000..1651db0a04 --- /dev/null +++ b/test/playwright/auth/test_toggle_login_register.py @@ -0,0 +1,80 @@ +import pytest +from playwright.sync_api import expect + +from test.playwright.helpers.auth_selectors import LOGIN_TAB, NICKNAME_INPUT, REGISTER_TAB +from test.playwright.helpers.flow_steps import flow_params, require + + +def step_01_open_login(flow_page, flow_state, login_url, active_auth_context, step, snap): + page = flow_page + with step("open login page"): + page.goto(login_url, wait_until="domcontentloaded") + flow_state["login_opened"] = True + snap("open") + + +def step_02_switch_to_register( + flow_page, flow_state, login_url, active_auth_context, step, snap +): + require(flow_state, "login_opened") + form, card = active_auth_context() + toggle_button = card.locator(REGISTER_TAB) + if toggle_button.count() == 0: + flow_state["register_toggle_available"] = False + pytest.skip("Register toggle not present; registerEnabled may be disabled.") + flow_state["register_toggle_available"] = True + with step("switch to register"): + expect(toggle_button).to_have_count(1) + toggle_button.click() + snap("toggled_register") + + +def step_03_assert_register_visible( + flow_page, flow_state, login_url, active_auth_context, step, snap +): + require(flow_state, "login_opened", "register_toggle_available") + form, _ = active_auth_context() + nickname_input = form.locator(NICKNAME_INPUT) + expect(nickname_input).to_have_count(1) + expect(nickname_input).to_be_visible() + snap("register_visible") + + +def step_04_switch_back_to_login( + flow_page, flow_state, login_url, active_auth_context, step, snap +): + require(flow_state, "login_opened", "register_toggle_available") + form, card = active_auth_context() + toggle_back = card.locator(LOGIN_TAB) + expect(toggle_back).to_have_count(1) + toggle_back.click() + flow_state["login_toggled_back"] = True + snap("toggled_login") + + +def step_05_assert_login_visible( + flow_page, flow_state, login_url, active_auth_context, step, snap +): + require(flow_state, "login_opened", "login_toggled_back") + form, _ = active_auth_context() + nickname_input = form.locator(NICKNAME_INPUT) + expect(nickname_input).to_have_count(0) + snap("login_visible") + + +STEPS = [ + ("01_open_login", step_01_open_login), + ("02_switch_to_register", step_02_switch_to_register), + ("03_assert_register_visible", step_03_assert_register_visible), + ("04_switch_back_to_login", step_04_switch_back_to_login), + ("05_assert_login_visible", step_05_assert_login_visible), +] + + +@pytest.mark.p1 +@pytest.mark.auth +@pytest.mark.parametrize("step_fn", flow_params(STEPS)) +def test_toggle_login_register_flow( + step_fn, flow_page, flow_state, login_url, active_auth_context, step, snap +): + step_fn(flow_page, flow_state, login_url, active_auth_context, step, snap) diff --git a/test/playwright/auth/test_validation_presence.py b/test/playwright/auth/test_validation_presence.py new file mode 100644 index 0000000000..9671b12d20 --- /dev/null +++ b/test/playwright/auth/test_validation_presence.py @@ -0,0 +1,75 @@ +import pytest +from playwright.sync_api import expect + +from test.playwright.helpers.auth_selectors import EMAIL_INPUT, SUBMIT_BUTTON +from test.playwright.helpers.flow_steps import flow_params, require + + +def step_01_open_login( + flow_page, flow_state, login_url, active_auth_context, step, snap, auth_click +): + page = flow_page + with step("open login page"): + page.goto(login_url, wait_until="domcontentloaded") + flow_state["login_opened"] = True + snap("open") + + +def step_02_submit_empty( + flow_page, flow_state, login_url, active_auth_context, step, snap, auth_click +): + require(flow_state, "login_opened") + form, _ = active_auth_context() + expect(form.locator(EMAIL_INPUT)).to_have_count(1) + + with step("submit empty login form"): + submit_button = form.locator(SUBMIT_BUTTON) + expect(submit_button).to_have_count(1) + auth_click(submit_button, "submit_validation") + flow_state["submitted_empty"] = True + snap("submitted_empty") + + +def step_03_assert_validation( + flow_page, flow_state, login_url, active_auth_context, step, snap, auth_click +): + require(flow_state, "login_opened", "submitted_empty") + form, _ = active_auth_context() + invalid_inputs = form.locator("input[aria-invalid='true']") + error_messages = form.locator("p[id$='-form-item-message']") + + try: + expect(invalid_inputs).not_to_have_count(0, timeout=2000) + snap("validation_visible") + return + except AssertionError: + pass + + try: + expect(error_messages).not_to_have_count(0, timeout=1000) + snap("validation_visible") + return + except AssertionError: + pass + + raise AssertionError( + "No validation feedback detected after submitting an empty login form. " + "Expected aria-invalid inputs or visible error containers. " + "See artifacts for DOM evidence." + ) + + +STEPS = [ + ("01_open_login", step_01_open_login), + ("02_submit_empty", step_02_submit_empty), + ("03_assert_validation", step_03_assert_validation), +] + + +@pytest.mark.p1 +@pytest.mark.auth +@pytest.mark.parametrize("step_fn", flow_params(STEPS)) +def test_validation_presence_flow( + step_fn, flow_page, flow_state, login_url, active_auth_context, step, snap, auth_click +): + step_fn(flow_page, flow_state, login_url, active_auth_context, step, snap, auth_click) diff --git a/test/playwright/conftest.py b/test/playwright/conftest.py new file mode 100644 index 0000000000..fdfc2730cc --- /dev/null +++ b/test/playwright/conftest.py @@ -0,0 +1,1371 @@ +import sys +from pathlib import Path +_PW_DIR = Path(__file__).resolve().parent +if str(_PW_DIR) not in sys.path: + sys.path.insert(0, str(_PW_DIR)) + +import base64 +import faulthandler +import json +import os +import re +import secrets +import signal +import time +from contextlib import contextmanager +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin +from urllib.request import Request, urlopen + +import pytest +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError +from playwright.sync_api import expect, sync_playwright + +ROOT_DIR = Path(__file__).resolve().parents[2] +PLAYWRIGHT_TEST_DIR = Path(__file__).resolve().parent +ARTIFACTS_DIR = Path(__file__).resolve().parent / "artifacts" +BASE_URL_DEFAULT = "http://127.0.0.1" +LOGIN_PATH_DEFAULT = "/login" +DEFAULT_TIMEOUT_MS = 30000 +DEFAULT_HANG_TIMEOUT_S = 1800 +AUTH_READY_TIMEOUT_MS_DEFAULT = 15000 +REG_EMAIL_BASE_DEFAULT = "qa@infiniflow.org" +REG_NICKNAME_DEFAULT = "qa" +REG_PASSWORD_DEFAULT = "123" +REG_EMAIL_LOCAL_RE = re.compile(r"^[A-Za-z0-9_.-]+$") +REG_EMAIL_BACKEND_RE = re.compile(r"^[\w\._-]{1,}@([\w_-]+\.)+[\w-]{2,}$") +AUTH_FORM_SELECTOR = "form[data-testid='auth-form']" +AUTH_ACTIVE_FORM_SELECTOR = "form[data-testid='auth-form'][data-active='true']" +AUTH_EMAIL_INPUT_SELECTOR = ( + "input[data-testid='auth-email'], [data-testid='auth-email'] input" +) +AUTH_PASSWORD_INPUT_SELECTOR = ( + "input[data-testid='auth-password'], [data-testid='auth-password'] input" +) +AUTH_SUBMIT_SELECTOR = ( + "button[data-testid='auth-submit'], [data-testid='auth-submit'] button, [data-testid='auth-submit']" +) + +_PUBLIC_KEY_CACHE = None +_RSA_CIPHER_CACHE = None +_HANG_WATCHDOG_INSTALLED = False + + +class _RegisterDisabled(RuntimeError): + pass + + +def _env_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _env_int(name: str, default: int) -> int: + value = os.getenv(name) + if not value: + return default + try: + return int(value) + except ValueError: + return default + + +def _env_int_with_fallback(primary: str, fallback: str | None, default: int) -> int: + value = os.getenv(primary) + if not value and fallback: + value = os.getenv(fallback) + if not value: + return default + try: + return int(value) + except ValueError: + return default + + +def _sanitize_timeout_ms(value: int | None, fallback: int | None) -> int | None: + if value is None or value <= 0: + return fallback + return value + + +def _playwright_action_timeout_ms() -> int | None: + raw = _env_int_with_fallback( + "PLAYWRIGHT_ACTION_TIMEOUT_MS", "PW_TIMEOUT_MS", DEFAULT_TIMEOUT_MS + ) + return _sanitize_timeout_ms(raw, DEFAULT_TIMEOUT_MS) + + +def _playwright_auth_ready_timeout_ms() -> int | None: + raw = _env_int_with_fallback( + "PLAYWRIGHT_AUTH_READY_TIMEOUT_MS", + "AUTH_READY_TIMEOUT_MS", + AUTH_READY_TIMEOUT_MS_DEFAULT, + ) + return _sanitize_timeout_ms(raw, AUTH_READY_TIMEOUT_MS_DEFAULT) + + +def _playwright_hang_timeout_s() -> int: + raw = _env_int_with_fallback( + "PLAYWRIGHT_HANG_TIMEOUT_S", "HANG_TIMEOUT_S", DEFAULT_HANG_TIMEOUT_S + ) + return raw if raw > 0 else 0 + + + + +def _failure_text(req) -> str: + failure = getattr(req, "failure", None) + if callable(failure): + try: + failure = failure() + except Exception: + return "unknown" + if failure is None: + return "unknown" + if isinstance(failure, str): + return failure or "unknown" + try: + error_text = getattr(failure, "error_text", None) + if error_text: + return str(error_text) + except Exception: + pass + try: + if isinstance(failure, dict): + for key in ("errorText", "error_text"): + value = failure.get(key) + if value: + return str(value) + except Exception: + pass + try: + getter = getattr(failure, "get", None) + if callable(getter): + for key in ("errorText", "error_text"): + value = getter(key) + if value: + return str(value) + except Exception: + pass + try: + return str(failure) + except Exception: + return "unknown" + + +def _build_url(base_url: str, path: str) -> str: + if not base_url: + return path + base = base_url.rstrip("/") + "/" + return urljoin(base, path.lstrip("/")) + + +def _sanitize_filename(value: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("_") + + +def _request_test_file(request) -> Path | None: + node = getattr(request, "node", None) + if node is None: + return None + + node_path = getattr(node, "path", None) + if node_path is not None: + return Path(str(node_path)) + + fspath = getattr(node, "fspath", None) + if fspath is not None: + return Path(str(fspath)) + + nodeid = getattr(node, "nodeid", "") + if nodeid: + return Path(nodeid.split("::", 1)[0]) + + return None + + +def _request_artifacts_dir(request) -> Path: + test_file = _request_test_file(request) + if test_file is None: + base_dir = ARTIFACTS_DIR / "unknown" + base_dir.mkdir(parents=True, exist_ok=True) + return base_dir + + try: + rel_path = test_file.resolve().relative_to(PLAYWRIGHT_TEST_DIR.resolve()) + base_dir = ARTIFACTS_DIR / rel_path.with_suffix("") + except Exception: + file_stem = _sanitize_filename(test_file.stem or str(test_file)) + base_dir = ARTIFACTS_DIR / (file_stem or "unknown") + base_dir.mkdir(parents=True, exist_ok=True) + return base_dir + + +def _request_artifact_prefix(request) -> str: + node = getattr(request, "node", None) + node_name = getattr(node, "name", "") if node is not None else "" + safe_name = _sanitize_filename(node_name) + if safe_name: + return safe_name + nodeid = getattr(node, "nodeid", "") if node is not None else "" + fallback = _sanitize_filename(nodeid) + return fallback or "node" + + +def _split_email_base(value: str) -> tuple[str, str]: + if value.count("@") != 1: + raise ValueError("REG_EMAIL_BASE must be a single email address") + local, domain = value.split("@", 1) + if not local or not domain: + raise ValueError("REG_EMAIL_BASE must include local part and domain") + return local, domain + + +def _unique_email(base: str, suffix: str) -> str: + local, domain = _split_email_base(base) + if "+" in local: + local = local.split("+", 1)[0] + return f"{local}_{suffix}@{domain}" + + +def _assert_reg_email(email: str) -> None: + if "+" in email: + raise AssertionError(f"Registration email contains '+': {email}") + try: + local, _ = _split_email_base(email) + except ValueError as exc: + raise AssertionError(f"Registration email is invalid: {email}") from exc + if not REG_EMAIL_LOCAL_RE.match(local): + raise AssertionError(f"Registration email local part invalid: {email}") + if not REG_EMAIL_BACKEND_RE.match(email): + raise AssertionError(f"Registration email fails backend regex: {email}") + + +def _api_post_json(url: str, payload: dict, timeout_s: int = 10) -> tuple[int, dict | None]: + data = json.dumps(payload).encode("utf-8") + req = Request( + url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urlopen(req, timeout=timeout_s) as resp: + body = resp.read() + if body: + try: + return resp.status, json.loads(body.decode("utf-8")) + except Exception: + return resp.status, None + return resp.status, None + except HTTPError as exc: + body = exc.read() + parsed = None + if body: + try: + parsed = json.loads(body.decode("utf-8")) + except Exception: + parsed = None + raise RuntimeError(f"HTTPError {exc.code}: {parsed or body!r}") from exc + except URLError as exc: + raise RuntimeError(f"URLError: {exc}") from exc + + +def _rsa_encrypt_password(password: str) -> str: + global _PUBLIC_KEY_CACHE + global _RSA_CIPHER_CACHE + try: + from Cryptodome.PublicKey import RSA + from Cryptodome.Cipher import PKCS1_v1_5 as Cipher_pkcs1_v1_5 + except Exception as exc: + raise RuntimeError( + "Cryptodome is required to encrypt passwords for API seeding. " + "Set RAGFLOW_SEEDING_MODE=ui to skip API seeding." + ) from exc + if _PUBLIC_KEY_CACHE is None: + public_key_path = ROOT_DIR / "conf" / "public.pem" + if not public_key_path.exists(): + raise RuntimeError(f"Missing RSA public key at {public_key_path}") + _PUBLIC_KEY_CACHE = public_key_path.read_text(encoding="utf-8") + if _RSA_CIPHER_CACHE is None: + rsa_key = RSA.importKey(_PUBLIC_KEY_CACHE, "Welcome") + _RSA_CIPHER_CACHE = Cipher_pkcs1_v1_5.new(rsa_key) + password_base64 = base64.b64encode(password.encode("utf-8")).decode("utf-8") + encrypted_password = _RSA_CIPHER_CACHE.encrypt(password_base64.encode("utf-8")) + return base64.b64encode(encrypted_password).decode("utf-8") + + +def _is_register_disabled_message(message: str) -> bool: + lowered = (message or "").lower() + return "registration is disabled" in lowered or "register disabled" in lowered + + +def _api_register_user(base_url: str, email: str, password: str, nickname: str) -> None: + url = _build_url(base_url, "/v1/user/register") + encrypted_password = _rsa_encrypt_password(password) + status, payload = _api_post_json( + url, + {"email": email, "password": encrypted_password, "nickname": nickname}, + timeout_s=10, + ) + if status >= 400: + raise RuntimeError(f"register failed status={status}") + if isinstance(payload, dict) and payload.get("code") not in (0, None): + message = str(payload.get("message") or payload) + if _is_register_disabled_message(message): + raise _RegisterDisabled(message) + raise RuntimeError(f"register failed payload={payload}") + + +def _api_login_user(base_url: str, email: str, password: str) -> None: + url = _build_url(base_url, "/v1/user/login") + encrypted_password = _rsa_encrypt_password(password) + status, payload = _api_post_json( + url, + {"email": email, "password": encrypted_password}, + timeout_s=10, + ) + if status >= 400: + raise RuntimeError(f"login failed status={status}") + if isinstance(payload, dict) and payload.get("code") not in (0, None): + raise RuntimeError(f"login failed payload={payload}") + + +def _generate_seeded_email(base_email: str) -> str: + local, domain = _split_email_base(base_email) + if "+" in local: + local = local.split("+", 1)[0] + suffix = f"{int(time.time() * 1000)}_{secrets.token_hex(3)}" + return f"{local}_{suffix}@{domain}" + + +def _auth_form_locator(card, require_nickname: bool = False): + form = card.locator("form[data-testid='auth-form']") + form = form.filter(has=card.locator("[data-testid='auth-email']")) + form = form.filter(has=card.locator("[data-testid='auth-submit']")) + if require_nickname: + form = form.filter(has=card.locator("[data-testid='auth-nickname']")) + return form + + +def _describe_auth_ui(page, card, register_toggle) -> str: + lines = [] + if card is None: + lines.append("auth_card_count=unavailable") + else: + try: + lines.append(f"auth_card_count={card.count()}") + except Exception as exc: + lines.append(f"auth_card_count_error={exc}") + if register_toggle is None: + lines.append("register_toggle_count=unavailable") + else: + try: + toggle_count = register_toggle.count() + toggle_visible = False + if toggle_count: + try: + toggle_visible = register_toggle.first.is_visible() + except Exception: + toggle_visible = False + lines.append(f"register_toggle_count={toggle_count}") + lines.append(f"register_toggle_visible={toggle_visible}") + except Exception as exc: + lines.append(f"register_toggle_error={exc}") + try: + summary = _auth_ready_summary(page) + lines.append(_format_auth_ready_summary(summary).strip()) + except Exception as exc: + lines.append(f"auth_summary_error={exc}") + return "\n".join(line for line in lines if line) + + +def _wait_for_auth_success(page, card, form) -> None: + timeout_ms = _playwright_auth_ready_timeout_ms() + status_marker = page.locator("[data-testid='auth-status']") + if status_marker.count() > 0: + try: + expect(status_marker).to_have_attribute( + "data-state", "success", timeout=timeout_ms + ) + return + except AssertionError: + pass + try: + page.wait_for_function( + "() => Boolean(localStorage.getItem('token') || localStorage.getItem('Authorization'))", + timeout=timeout_ms, + ) + return + except PlaywrightTimeoutError: + pass + try: + expect(card.locator("[data-testid='auth-nickname']")).to_have_count( + 0, timeout=timeout_ms + ) + except AssertionError as exc: + raise RuntimeError( + "Auth success marker not detected after registration." + ) from exc + + +def _ui_register_user( + browser, + login_url: str, + email: str, + password: str, + nickname: str, +) -> None: + context_instance = browser.new_context(ignore_https_errors=True) + page = _configure_page(context_instance.new_page()) + card = None + register_toggle = None + try: + page.goto(login_url, wait_until="domcontentloaded") + timeout_ms = _playwright_auth_ready_timeout_ms() + card = page.locator("[data-testid='auth-card-active']") + expect(card).to_have_count(1, timeout=timeout_ms) + register_toggle = card.locator("[data-testid='auth-toggle-register']") + if register_toggle.count() == 0: + raise _RegisterDisabled("Register toggle not found; registration disabled?") + register_toggle.first.click() + register_form = _auth_form_locator(card, require_nickname=True) + expect(register_form).to_have_count(1, timeout=timeout_ms) + nickname_input = register_form.locator("[data-testid='auth-nickname']") + email_input = register_form.locator("[data-testid='auth-email']") + password_input = register_form.locator("[data-testid='auth-password']") + expect(nickname_input).to_have_count(1, timeout=timeout_ms) + expect(email_input).to_have_count(1, timeout=timeout_ms) + expect(password_input).to_have_count(1, timeout=timeout_ms) + nickname_input.fill(nickname) + email_input.fill(email) + password_input.fill(password) + password_input.blur() + submit_button = register_form.locator(AUTH_SUBMIT_SELECTOR) + expect(submit_button).to_have_count(1, timeout=timeout_ms) + submit_button.click() + _wait_for_auth_success(page, card, register_form) + except _RegisterDisabled: + raise + except Exception as _: + diagnostics = _describe_auth_ui(page, card, register_toggle) + if diagnostics: + print(f"[seeded-ui-register] diagnostics:\n{diagnostics}", flush=True) + raise + finally: + try: + page.close() + finally: + context_instance.close() + + +def _make_reg_email(base: str, unique: bool) -> str: + if not unique: + email = base + else: + suffix = f"{int(time.time() * 1000)}_{os.getpid()}_{secrets.randbelow(1000000)}" + email = _unique_email(base, suffix) + _assert_reg_email(email) + return email + + +@contextmanager +def _step(label: str, enabled: bool) -> None: + start = time.perf_counter() + if enabled: + print(f"[STEP] {label}", flush=True) + try: + yield + finally: + if enabled: + elapsed = time.perf_counter() - start + print(f"[STEP] done in {elapsed:.2f}s: {label}", flush=True) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + report = outcome.get_result() + setattr(item, f"_rep_{report.when}", report) + + +def pytest_sessionstart(session): + ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True) + faulthandler.enable() + global _HANG_WATCHDOG_INSTALLED + hang_timeout = _playwright_hang_timeout_s() + if hang_timeout > 0: + if not _HANG_WATCHDOG_INSTALLED: + faulthandler.dump_traceback_later(hang_timeout, repeat=True) + _HANG_WATCHDOG_INSTALLED = True + print( + "Playwright hang watchdog enabled: dumps after " + f"{hang_timeout}s (set PLAYWRIGHT_HANG_TIMEOUT_S=0 to disable)", + flush=True, + ) + else: + print( + "Playwright hang watchdog disabled (PLAYWRIGHT_HANG_TIMEOUT_S=0)", + flush=True, + ) + try: + faulthandler.register(signal.SIGUSR1, all_threads=True) + except (AttributeError, ValueError): + pass + + +def pytest_sessionfinish(session, exitstatus): + try: + faulthandler.cancel_dump_traceback_later() + except Exception: + pass + + +def pytest_collection_modifyitems(session, config, items): + ordered_paths = [ + "test/playwright/auth/test_smoke_auth_page.py", + "test/playwright/auth/test_toggle_login_register.py", + "test/playwright/auth/test_validation_presence.py", + "test/playwright/auth/test_sso_optional.py", + "test/playwright/auth/test_register_success_optional.py", + "test/playwright/auth/test_login_success_optional.py", + "test/playwright/e2e/test_model_providers_zhipu_ai_defaults.py", + "test/playwright/e2e/test_dataset_upload_parse.py", + "test/playwright/e2e/test_next_apps_chat.py", + "test/playwright/e2e/test_next_apps_search.py", + "test/playwright/e2e/test_next_apps_agent.py", + ] + order_map = {path: idx for idx, path in enumerate(ordered_paths)} + + def _rel_path(item) -> str: + try: + return Path(str(item.fspath)).resolve().relative_to(ROOT_DIR).as_posix() + except Exception: + return str(item.fspath) + + indexed = list(enumerate(items)) + + def _sort_key(entry): + orig_idx, item = entry + rel_path = _rel_path(item) + order_idx = order_map.get(rel_path) + if order_idx is not None: + return (0, order_idx, orig_idx) + return (1, rel_path, item.name, orig_idx) + + items[:] = [item for _, item in sorted(indexed, key=_sort_key)] + + +@pytest.fixture(scope="session") +def base_url() -> str: + value = os.getenv("RAGFLOW_BASE_URL") or os.getenv("BASE_URL") + if not value: + value = BASE_URL_DEFAULT + return value.rstrip("/") + + +@pytest.fixture(scope="session") +def login_path() -> str: + value = os.getenv("LOGIN_PATH") + if not value: + value = LOGIN_PATH_DEFAULT + if not value.startswith("/"): + value = "/" + value + return value + + +@pytest.fixture(scope="session") +def login_url(base_url: str, login_path: str) -> str: + return _build_url(base_url, login_path) + + +@pytest.fixture(scope="session") +def smoke_login_url(login_url: str) -> str: + return login_url + + +@pytest.fixture(scope="session") +def browser(): + browser_name = os.getenv("PW_BROWSER", "chromium") + headless = _env_bool("PW_HEADLESS", True) + slow_mo = _env_int("PW_SLOWMO_MS", 0) + with sync_playwright() as playwright: + if not hasattr(playwright, browser_name): + raise ValueError(f"Unsupported browser: {browser_name}") + browser_type = getattr(playwright, browser_name) + browser_instance = browser_type.launch(headless=headless, slow_mo=slow_mo) + try: + yield browser_instance + finally: + browser_instance.close() + + +@pytest.fixture +def context(browser): + context_instance = browser.new_context(ignore_https_errors=True) + trace_enabled = _env_bool("PW_TRACE", False) + if trace_enabled: + context_instance.tracing.start(screenshots=True, snapshots=True, sources=True) + context_instance._trace_enabled = True + context_instance._trace_saved = False + try: + yield context_instance + finally: + if getattr(context_instance, "_trace_enabled", False) and not getattr( + context_instance, "_trace_saved", False + ): + try: + context_instance.tracing.stop() + except Exception: + pass + context_instance.close() + + +def _configure_page(page_instance): + timeout_ms = _playwright_action_timeout_ms() + if timeout_ms is not None: + page_instance.set_default_timeout(timeout_ms) + page_instance.set_default_navigation_timeout(timeout_ms) + page_instance._diag = { + "console_errors": [], + "page_errors": [], + "request_failed": [], + } + + net_log = _env_bool("PW_NET_LOG", False) + + def on_console(msg): + if msg.type != "error": + return + entry = f"console[{msg.type}]: {msg.text}" + page_instance._diag["console_errors"].append(entry) + if net_log: + print(entry, flush=True) + + def on_page_error(err): + entry = f"pageerror: {err}" + page_instance._diag["page_errors"].append(entry) + if net_log: + print(entry, flush=True) + + def on_request_failed(req): + try: + failure_text = _failure_text(req) + entry = f"requestfailed: {req.method} {req.url} -> {failure_text}" + page_instance._diag["request_failed"].append(entry) + if net_log: + print(entry, flush=True) + except Exception as exc: + if net_log: + print(f"requestfailed: {exc}", flush=True) + return + + page_instance.on("console", on_console) + page_instance.on("pageerror", on_page_error) + page_instance.on("requestfailed", on_request_failed) + return page_instance + + +@pytest.fixture +def page(context, request): + page_instance = _configure_page(context.new_page()) + + try: + yield page_instance + finally: + _write_artifacts_if_failed(page_instance, context, request) + page_instance.close() + + +@pytest.fixture(scope="module") +def flow_context(browser, request): + try: + browser_context_args = request.getfixturevalue("browser_context_args") + except Exception: + browser_context_args = {} + if browser_context_args is None: + browser_context_args = {} + args = dict(browser_context_args) + args.setdefault("ignore_https_errors", True) + ctx = browser.new_context(**args) + yield ctx + ctx.close() + + +@pytest.fixture(scope="module") +def flow_page(flow_context): + page_instance = _configure_page(flow_context.new_page()) + yield page_instance + page_instance.close() + + +@pytest.fixture(scope="module") +def flow_state(): + return {} + + +@pytest.fixture(autouse=True) +def _flow_artifacts(request): + if "flow_page" not in request.fixturenames: + yield + return + yield + try: + page_instance = request.getfixturevalue("flow_page") + context = request.getfixturevalue("flow_context") + except Exception: + return + _write_artifacts_if_failed(page_instance, context, request) + + +@pytest.fixture +def step(): + enabled = _env_bool("PW_STEP_LOG", False) + + def _stepper(label: str): + return _step(label, enabled) + + return _stepper + + +@pytest.fixture +def reg_email_base() -> str: + return os.getenv("REG_EMAIL_BASE", REG_EMAIL_BASE_DEFAULT) + + +@pytest.fixture +def reg_email_unique() -> bool: + return _env_bool("REG_EMAIL_UNIQUE", False) + + +@pytest.fixture +def reg_email_generator(reg_email_base: str, reg_email_unique: bool): + def _generate(force_unique: bool = False) -> str: + unique = reg_email_unique or force_unique + return _make_reg_email(reg_email_base, unique) + + return _generate + + +@pytest.fixture +def reg_email(reg_email_generator) -> str: + return reg_email_generator() + + +@pytest.fixture +def reg_password() -> str: + return REG_PASSWORD_DEFAULT + + +@pytest.fixture(scope="session") +def seeded_user_credentials(base_url: str, login_url: str, browser) -> tuple[str, str]: + env_email = os.getenv("SEEDED_USER_EMAIL") + env_password = os.getenv("SEEDED_USER_PASSWORD") + if env_email and env_password: + return env_email, env_password + + seeding_mode = os.getenv("RAGFLOW_SEEDING_MODE", "auto").strip().lower() + if seeding_mode not in {"auto", "api", "ui"}: + if _env_bool("PW_FIXTURE_DEBUG", False): + print( + f"[seeded] Unknown RAGFLOW_SEEDING_MODE={seeding_mode!r}; using auto.", + flush=True, + ) + seeding_mode = "auto" + + base_email = os.getenv("REG_EMAIL_BASE", REG_EMAIL_BASE_DEFAULT) + password = os.getenv("SEEDED_USER_PASSWORD") or REG_PASSWORD_DEFAULT + nickname = os.getenv("REG_NICKNAME", REG_NICKNAME_DEFAULT) + email = _generate_seeded_email(base_email) + _assert_reg_email(email) + + seed_errors = [] + seeded_via = None + if seeding_mode in {"auto", "api"}: + seeded_via = "api" + try: + _api_register_user(base_url, email, password, nickname) + try: + _api_login_user(base_url, email, password) + except Exception as exc: + if _env_bool("PW_FIXTURE_DEBUG", False): + print(f"[seeded] api login verification failed: {exc}", flush=True) + except _RegisterDisabled as exc: + seed_errors.append(f"api: {exc}") + seeded_via = None + except Exception as exc: + seed_errors.append(f"api: {exc}") + seeded_via = None + if seeding_mode == "api": + details = "; ".join(seed_errors) + raise RuntimeError( + f"Failed to seed user via API registration. {details}" + ) from exc + + if seeded_via is None and seeding_mode in {"auto", "ui"}: + seeded_via = "ui" + try: + _ui_register_user(browser, login_url, email, password, nickname) + except _RegisterDisabled as exc: + seed_errors.append(f"ui: {exc}") + default_email = os.getenv("DEFAULT_SUPERUSER_EMAIL", "admin@ragflow.io") + raise RuntimeError( + "User registration is disabled and no default account is available. " + f"Known superuser defaults ({default_email}) cannot be used with the " + "normal login endpoint. Enable registration or seed a test account." + ) from exc + except Exception as ui_exc: + seed_errors.append(f"ui: {ui_exc}") + details = "; ".join(seed_errors) + raise RuntimeError( + f"Failed to seed user via API or UI registration. {details}" + ) from ui_exc + + os.environ["SEEDED_USER_EMAIL"] = email + os.environ["SEEDED_USER_PASSWORD"] = password + if _env_bool("PW_FIXTURE_DEBUG", False): + print(f"[seeded] created user via {seeded_via}: {email}", flush=True) + return email, password + + +@pytest.fixture +def reg_nickname() -> str: + return REG_NICKNAME_DEFAULT + + +@pytest.fixture +def snap(page, request): + if "flow_page" in request.fixturenames: + page = request.getfixturevalue("flow_page") + base_dir = _request_artifacts_dir(request) + node_prefix = _request_artifact_prefix(request) + counter = {"value": 0} + + def _snap(label: str): + counter["value"] += 1 + safe_label = _sanitize_filename(label) or "step" + filename = f"{node_prefix}__{counter['value']:02d}_{safe_label}.png" + path = base_dir / filename + page.screenshot(path=str(path), full_page=True) + if _env_bool("PW_FIXTURE_DEBUG", False): + print(f"[artifact] snapshot: {path}", flush=True) + return path + + _snap.dir = base_dir + return _snap + + +def _debug_dump_auth_state(page, label: str, submit_locator=None) -> None: + if not _env_bool("PW_DEBUG_DUMP", False): + return + print(f"[auth-debug] label={label}", flush=True) + form_count = page.locator("form").count() + visible_form_count = page.locator("form:visible").count() + print( + f"[auth-debug] forms total={form_count} visible={visible_form_count}", + flush=True, + ) + forms_info = page.evaluate( + """ + () => { + const forms = Array.from(document.querySelectorAll('form')); + const getFace = (el) => { + let node = el; + while (node && node !== document.body) { + const style = window.getComputedStyle(node); + if (style && style.backfaceVisibility === 'hidden') { + return node; + } + node = node.parentElement; + } + return el; + }; + const getFlip = (el) => { + let node = el; + while (node && node !== document.body) { + const style = window.getComputedStyle(node); + if (style && style.transformStyle === 'preserve-3d') { + return node; + } + node = node.parentElement; + } + return null; + }; + const isVisible = (el) => { + const style = window.getComputedStyle(el); + if (style && (style.visibility === 'hidden' || style.display === 'none')) { + return false; + } + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + return forms.filter(isVisible).map((form, idx) => { + const rect = form.getBoundingClientRect(); + const button = form.querySelector('button[type="submit"]'); + const buttonText = button ? (button.textContent || '').trim() : ''; + const face = getFace(form); + const flip = getFlip(face); + return { + index: idx, + authMode: form.getAttribute('data-auth-mode') || '', + isActive: form.getAttribute('data-active') === 'true', + rect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }, + submitText: buttonText.slice(0, 60), + submitHasContinue: buttonText.toLowerCase().includes('continue'), + faceTransform: window.getComputedStyle(face).transform, + faceBackface: window.getComputedStyle(face).backfaceVisibility, + flipTransform: flip ? window.getComputedStyle(flip).transform : null, + flipTransformStyle: flip ? window.getComputedStyle(flip).transformStyle : null, + }; + }); + } + """ + ) + for info in forms_info: + print(f"[auth-debug] visible_form={info}", flush=True) + + if submit_locator is None or submit_locator.count() == 0: + print("[auth-debug] submit button not found", flush=True) + return + try: + bbox = submit_locator.bounding_box() + except Exception as exc: + print(f"[auth-debug] submit bounding box failed: {exc}", flush=True) + return + if not bbox: + print("[auth-debug] submit bounding box empty", flush=True) + return + center_x = bbox["x"] + bbox["width"] / 2 + center_y = bbox["y"] + bbox["height"] / 2 + element_html = page.evaluate( + """ + ({ x, y }) => { + const el = document.elementFromPoint(x, y); + if (!el) return null; + return el.outerHTML ? el.outerHTML.slice(0, 500) : String(el); + } + """, + {"x": center_x, "y": center_y}, + ) + print(f"[auth-debug] elementFromPoint={element_html}", flush=True) + + +@pytest.fixture +def auth_debug_dump(page, request): + if "flow_page" in request.fixturenames: + page = request.getfixturevalue("flow_page") + def _dump(label: str, submit_locator=None) -> None: + _debug_dump_auth_state(page, label, submit_locator) + + return _dump + + +def _write_artifacts_if_failed(page, context, request) -> None: + report = getattr(request.node, "_rep_call", None) + if not report or not report.failed: + return + + timestamp = time.strftime("%Y%m%d-%H%M%S") + base_dir = _request_artifacts_dir(request) + safe_name = _request_artifact_prefix(request) + screenshot_path = base_dir / f"{safe_name}_{timestamp}.png" + html_path = base_dir / f"{safe_name}_{timestamp}.html" + events_path = base_dir / f"{safe_name}_{timestamp}.log" + trace_path = base_dir / f"{safe_name}_{timestamp}.zip" + + try: + page.screenshot(path=str(screenshot_path), full_page=True) + except Exception as exc: + print(f"[artifact] screenshot failed: {exc}", flush=True) + + try: + html_path.write_text(page.content(), encoding="utf-8") + except Exception as exc: + print(f"[artifact] html dump failed: {exc}", flush=True) + + try: + lines = [] + diag = getattr(page, "_diag", {}) + for key in ("console_errors", "page_errors", "request_failed"): + entries = diag.get(key, []) + if entries: + lines.append(f"{key}:") + lines.extend(entries) + if lines: + events_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + except Exception as exc: + print(f"[artifact] events dump failed: {exc}", flush=True) + + if getattr(context, "_trace_enabled", False) and not getattr( + context, "_trace_saved", False + ): + try: + context.tracing.stop(path=str(trace_path)) + context._trace_saved = True + except Exception as exc: + print(f"[artifact] trace dump failed: {exc}", flush=True) + + +def _auth_ready_summary(page) -> dict: + return page.evaluate( + """ + () => { + const summarizeInputs = (form) => { + const inputs = Array.from(form.querySelectorAll('input')); + return inputs.map((input) => ({ + type: input.getAttribute('type') || '', + name: input.getAttribute('name') || '', + autocomplete: input.getAttribute('autocomplete') || '', + placeholder: input.getAttribute('placeholder') || '', + })); + }; + const allForms = Array.from(document.querySelectorAll('form')); + const visibleForms = allForms.filter((el) => { + const style = window.getComputedStyle(el); + if (style && (style.visibility === 'hidden' || style.display === 'none')) { + return false; + } + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + return { + formCount: allForms.length, + visibleFormCount: visibleForms.length, + visibleFormInputs: visibleForms.map(summarizeInputs), + }; + } + """ + ) + + +def _format_auth_ready_summary(summary: dict) -> str: + lines = [ + f"form_count: {summary.get('formCount')}", + f"visible_form_count: {summary.get('visibleFormCount')}", + ] + visible_inputs = summary.get("visibleFormInputs") or [] + for idx, inputs in enumerate(visible_inputs, start=1): + input_parts = [] + for item in inputs: + parts = [] + for key in ("type", "name", "autocomplete", "placeholder"): + value = item.get(key) + if value: + parts.append(f"{key}={value}") + input_parts.append("{" + ", ".join(parts) + "}") + lines.append(f"visible_form_{idx}_inputs: {input_parts}") + return "\n".join(lines) + "\n" + + +def _write_auth_ready_diagnostics(page, request, reason: str) -> None: + timestamp = time.strftime("%Y%m%d-%H%M%S") + base_dir = _request_artifacts_dir(request) + safe_name = _request_artifact_prefix(request) + screenshot_path = base_dir / f"{safe_name}_auth_ready_{timestamp}.png" + html_path = base_dir / f"{safe_name}_auth_ready_{timestamp}.html" + summary_path = base_dir / f"{safe_name}_auth_ready_{timestamp}.log" + + try: + page.screenshot(path=str(screenshot_path), full_page=True) + except Exception as exc: + print(f"[auth_ready] screenshot failed: {exc}", flush=True) + + try: + html_path.write_text(page.content(), encoding="utf-8") + except Exception as exc: + print(f"[auth_ready] html dump failed: {exc}", flush=True) + + try: + summary = _auth_ready_summary(page) + summary_text = ( + f"reason: {reason}\nurl: {page.url}\ntitle: {page.title()}\n" + + _format_auth_ready_summary(summary) + ) + summary_path.write_text(summary_text, encoding="utf-8") + print(summary_text, flush=True) + except Exception as exc: + print(f"[auth_ready] summary failed: {exc}", flush=True) + + +def _wait_for_auth_ui_ready(page, request) -> None: + timeout_ms = _playwright_auth_ready_timeout_ms() + email_selector = AUTH_EMAIL_INPUT_SELECTOR + password_selector = AUTH_PASSWORD_INPUT_SELECTOR + submit_selector = AUTH_SUBMIT_SELECTOR + active_forms = page.locator(AUTH_ACTIVE_FORM_SELECTOR) + try: + expect(active_forms).to_have_count(1, timeout=timeout_ms) + except AssertionError as exc: + _write_auth_ready_diagnostics(page, request, "auth active form not unique") + raise AssertionError( + "Auth UI not ready within " + f"{timeout_ms}ms. Expected a single active auth form." + ) from exc + ready_forms = active_forms.filter( + has=page.locator(password_selector) + ).filter(has=page.locator(email_selector)).filter( + has=page.locator(submit_selector) + ) + try: + expect(ready_forms).not_to_have_count(0, timeout=timeout_ms) + except AssertionError as exc: + _write_auth_ready_diagnostics(page, request, "auth UI readiness timeout") + raise AssertionError( + "Auth UI not ready within " + f"{timeout_ms}ms. Expected a visible form with email-like and password inputs." + ) from exc + + +def _wait_for_active_form_clickable(page, request, form) -> None: + timeout_ms = _playwright_auth_ready_timeout_ms() + active_forms = page.locator(AUTH_ACTIVE_FORM_SELECTOR) + submit_buttons = form.locator(AUTH_SUBMIT_SELECTOR) + try: + expect(active_forms).to_have_count(1, timeout=timeout_ms) + expect(submit_buttons).to_have_count(1, timeout=timeout_ms) + expect(submit_buttons).to_be_visible() + expect(submit_buttons).to_be_enabled() + status = page.locator("[data-testid='auth-status']") + if status.count() > 0: + expect(status).not_to_have_attribute("data-state", "loading") + except AssertionError as exc: + try: + total_forms = page.locator(AUTH_FORM_SELECTOR).count() + active_form_count = active_forms.count() + forms_info = [] + for idx in range(min(total_forms, 5)): + form_node = page.locator(AUTH_FORM_SELECTOR).nth(idx) + try: + info = form_node.evaluate( + """ + (el) => { + const submit = el.querySelector("button[type='submit'], [data-testid='auth-submit']"); + const isVisible = (node) => { + const style = window.getComputedStyle(node); + if (style && (style.visibility === 'hidden' || style.display === 'none')) { + return false; + } + const rect = node.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + return { + authMode: el.getAttribute('data-auth-mode') || '', + active: el.getAttribute('data-active') || '', + submit: submit + ? { + tag: submit.tagName, + type: submit.getAttribute('type'), + text: (submit.innerText || '').trim(), + testid: submit.getAttribute('data-testid'), + visible: isVisible(submit), + enabled: !submit.disabled, + } + : null, + }; + } + """ + ) + except Exception as inner_exc: + info = {"error": str(inner_exc)} + forms_info.append(info) + print( + f"[auth-debug] forms total={total_forms} active_forms={active_form_count} details={forms_info}", + flush=True, + ) + except Exception: + pass + _write_auth_ready_diagnostics( + page, request, "active auth form submit not clickable" + ) + _debug_dump_auth_state(page, "active_form_not_clickable", submit_buttons) + raise AssertionError( + "Active auth form submit button not clickable within " + f"{timeout_ms}ms. The flip animation may still be in progress." + ) from exc + + +def _locator_is_topmost(locator) -> bool: + try: + return bool( + locator.evaluate( + """ + (el) => { + const rect = el.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + const top = document.elementFromPoint(x, y); + return top && (top === el || el.contains(top)); + } + """ + ) + ) + except Exception: + return False + + +@pytest.fixture +def auth_click(): + def _click(locator, label: str = "click") -> None: + timeout_ms = _playwright_auth_ready_timeout_ms() + try: + locator.click(timeout=timeout_ms) + except PlaywrightTimeoutError as exc: + if "intercepts pointer events" in str(exc) and _locator_is_topmost( + locator + ): + if _env_bool("PW_FIXTURE_DEBUG", False): + print(f"[auth-click] forcing {label}", flush=True) + locator.click(force=True, timeout=timeout_ms) + return + raise + + return _click + + +@pytest.fixture +def active_auth_context(page, request): + if "flow_page" in request.fixturenames: + page = request.getfixturevalue("flow_page") + def _mark_active_form() -> None: + timeout_ms = _playwright_auth_ready_timeout_ms() + try: + page.wait_for_function( + """ + () => { + const forms = Array.from(document.querySelectorAll("form[data-testid='auth-form']")) + .filter((el) => el.querySelector("[data-testid='auth-email']")); + const getFace = (el) => { + let node = el; + while (node && node !== document.body) { + const style = window.getComputedStyle(node); + if (style && style.backfaceVisibility === 'hidden') { + return node; + } + node = node.parentElement; + } + return el; + }; + const getFlip = (el) => { + let node = el; + while (node && node !== document.body) { + const style = window.getComputedStyle(node); + if (style && style.transformStyle === 'preserve-3d') { + return node; + } + node = node.parentElement; + } + return null; + }; + const parseSign = (transform) => { + if (!transform || transform === 'none') return 1; + const match3d = transform.match(/^matrix3d\\((.+)\\)$/); + if (match3d) { + const parts = match3d[1].split(',').map((v) => parseFloat(v.trim())); + return Number.isFinite(parts[0]) ? Math.sign(parts[0]) : 0; + } + const match2d = transform.match(/^matrix\\((.+)\\)$/); + if (match2d) { + const parts = match2d[1].split(',').map((v) => parseFloat(v.trim())); + return Number.isFinite(parts[0]) ? Math.sign(parts[0]) : 0; + } + return 0; + }; + const computeFacing = (el) => { + const face = getFace(el); + const faceTransform = window.getComputedStyle(face).transform; + const faceSign = parseSign(faceTransform); + const flip = getFlip(face); + const flipTransform = flip + ? window.getComputedStyle(flip).transform + : 'none'; + const flipSign = parseSign(flipTransform); + return faceSign * flipSign; + }; + if (forms.length > 0) { + const firstFace = getFace(forms[0]); + const flip = getFlip(firstFace); + if (flip) { + const flipTransform = window.getComputedStyle(flip).transform; + const now = performance.now(); + const state = window.__qa_flip_state || { transform: null, time: 0 }; + if (state.transform !== flipTransform) { + window.__qa_flip_state = { transform: flipTransform, time: now }; + return false; + } + if (now - state.time < 150) { + return false; + } + } + } + const candidates = forms + .map((el) => { + const rect = el.getBoundingClientRect(); + if (!rect.width || !rect.height) return null; + return { el, facing: computeFacing(el) }; + }) + .filter(Boolean); + candidates.sort((a, b) => b.facing - a.facing); + let pick = null; + if (candidates.length === 1) { + pick = candidates[0]; + } else if (candidates.length > 1 && candidates[0].facing !== candidates[1].facing) { + pick = candidates[0]; + } + if (!pick) { + const fallback = forms.find((el) => { + const rect = el.getBoundingClientRect(); + if (!rect.width || !rect.height) return false; + const x = rect.left + rect.width / 2; + const y = rect.top + Math.min(rect.height / 2, 10); + const top = document.elementFromPoint(x, y); + return top && el.contains(top); + }); + if (fallback) { + pick = { el: fallback, facing: computeFacing(fallback) }; + } + } + forms.forEach((el) => el.removeAttribute('data-qa-active')); + if (!pick || !pick.el) return false; + pick.el.setAttribute('data-qa-active', 'true'); + const submit = pick.el.querySelector("[data-testid='auth-submit']"); + return Boolean(submit) && pick.facing > 0; + } + """, + timeout=timeout_ms, + ) + except Exception as exc: + _write_auth_ready_diagnostics( + page, request, "active auth form did not become front-facing" + ) + _debug_dump_auth_state(page, "active_form_not_front_facing") + raise AssertionError( + "Active auth form not ready within " + f"{timeout_ms}ms. The flip animation may not have settled." + ) from exc + + def _get(): + _wait_for_auth_ui_ready(page, request) + card = page.locator("[data-testid='auth-card-active']") + form = page.locator(AUTH_ACTIVE_FORM_SELECTOR) + timeout_ms = _playwright_auth_ready_timeout_ms() + try: + expect(form).to_have_count(1, timeout=timeout_ms) + except AssertionError as exc: + _write_auth_ready_diagnostics( + page, request, "active auth form selection failed" + ) + raise AssertionError( + "Active auth form not found. The login card may not be visible or the DOM changed." + ) from exc + _wait_for_active_form_clickable(page, request, form) + return form, card + + return _get diff --git a/test/playwright/e2e/__init__.py b/test/playwright/e2e/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/playwright/e2e/test_dataset_upload_parse.py b/test/playwright/e2e/test_dataset_upload_parse.py new file mode 100644 index 0000000000..5fcc90df08 --- /dev/null +++ b/test/playwright/e2e/test_dataset_upload_parse.py @@ -0,0 +1,295 @@ +import json +import re +import time +from pathlib import Path +from urllib.parse import urljoin + +import pytest +from playwright.sync_api import expect + +from test.playwright.helpers.flow_steps import flow_params, require +from test.playwright.helpers.auth_selectors import EMAIL_INPUT, PASSWORD_INPUT, SUBMIT_BUTTON +from test.playwright.helpers.auth_waits import wait_for_login_complete +from test.playwright.helpers.response_capture import capture_response +from test.playwright.helpers.datasets import ( + delete_uploaded_file, + ensure_parse_on, + ensure_upload_modal_open, + open_create_dataset_modal, + select_chunking_method_general, + upload_file, + wait_for_dataset_detail, + wait_for_dataset_detail_ready, + wait_for_success_dot, +) + +RESULT_TIMEOUT_MS = 15000 + + +def step_01_login( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + email, password = seeded_user_credentials + + repo_root = Path(__file__).resolve().parents[3] + file_paths = [ + repo_root / "test/benchmark/test_docs/Doc1.pdf", + repo_root / "test/benchmark/test_docs/Doc2.pdf", + repo_root / "test/benchmark/test_docs/Doc3.pdf", + ] + for path in file_paths: + if not path.is_file(): + pytest.fail(f"Missing upload fixture: {path}") + flow_state["file_paths"] = [str(path) for path in file_paths] + flow_state["filenames"] = [path.name for path in file_paths] + + with step("open login page"): + flow_page.goto(login_url, wait_until="domcontentloaded") + + form, _ = active_auth_context() + email_input = form.locator(EMAIL_INPUT) + password_input = form.locator(PASSWORD_INPUT) + with step("fill credentials"): + expect(email_input).to_have_count(1) + expect(password_input).to_have_count(1) + email_input.fill(email) + password_input.fill(password) + password_input.blur() + + with step("submit login"): + submit_button = form.locator(SUBMIT_BUTTON) + expect(submit_button).to_have_count(1) + auth_click(submit_button, "submit_login") + + with step("wait for login"): + wait_for_login_complete(flow_page, timeout_ms=RESULT_TIMEOUT_MS) + flow_state["logged_in"] = True + snap("login_complete") + + +def step_02_open_datasets( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "logged_in") + page = flow_page + with step("open datasets"): + page.goto(urljoin(base_url.rstrip("/") + "/", "/"), wait_until="domcontentloaded") + nav_button = page.locator("button", has_text=re.compile(r"^Dataset$", re.I)) + if nav_button.count() > 0: + nav_button.first.click() + else: + page.goto( + urljoin(base_url.rstrip("/") + "/", "/datasets"), + wait_until="domcontentloaded", + ) + snap("datasets_open") + + +def step_03_create_dataset( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "logged_in") + page = flow_page + with step("open create dataset modal"): + modal = open_create_dataset_modal(page, expect, RESULT_TIMEOUT_MS) + snap("dataset_modal_open") + + dataset_name = f"qa-dataset-{int(time.time() * 1000)}" + with step("fill dataset form"): + name_input = modal.locator("input[placeholder='Please input name.']").first + expect(name_input).to_be_visible() + name_input.fill(dataset_name) + + try: + select_chunking_method_general(page, expect, modal, RESULT_TIMEOUT_MS) + except Exception: + snap("failure_dataset_create") + raise + + save_button = None + if hasattr(modal, "get_by_role"): + save_button = modal.get_by_role("button", name=re.compile(r"^save$", re.I)) + if save_button is None or save_button.count() == 0: + save_button = modal.locator("button", has_text=re.compile(r"^save$", re.I)).first + expect(save_button).to_be_visible(timeout=RESULT_TIMEOUT_MS) + save_button.click() + expect(modal).not_to_be_visible(timeout=RESULT_TIMEOUT_MS) + wait_for_dataset_detail(page, timeout_ms=RESULT_TIMEOUT_MS) + wait_for_dataset_detail_ready(page, expect, timeout_ms=RESULT_TIMEOUT_MS) + flow_state["dataset_name"] = dataset_name + snap("dataset_created") + snap("dataset_detail_ready") + + +def step_04_upload_files( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "dataset_name", "file_paths") + page = flow_page + file_paths = [Path(path) for path in flow_state["file_paths"]] + filenames = flow_state.get("filenames") or [path.name for path in file_paths] + flow_state["filenames"] = filenames + + for idx, file_path in enumerate(file_paths): + filename = file_path.name + with step(f"open upload modal for {filename}"): + upload_modal = ensure_upload_modal_open( + page, expect, auth_click, timeout_ms=RESULT_TIMEOUT_MS + ) + if idx == 0: + snap("upload_modal_open") + + with step(f"enable parse on creation for {filename}"): + ensure_parse_on(upload_modal, expect) + if idx == 0: + snap("parse_toggle_on") + + with step(f"upload file {filename}"): + upload_file(page, expect, upload_modal, str(file_path), RESULT_TIMEOUT_MS) + expect(upload_modal.locator(f"text={filename}")).to_be_visible( + timeout=RESULT_TIMEOUT_MS + ) + + with step(f"submit upload {filename}"): + save_button = upload_modal.locator( + "button", has_text=re.compile("save", re.I) + ).first + + def trigger(): + save_button.click() + + capture_response( + page, + trigger, + lambda resp: resp.request.method == "POST" + and "/v1/document/upload" in resp.url, + ) + expect(upload_modal).not_to_be_visible(timeout=RESULT_TIMEOUT_MS) + snap(f"upload_{filename}_submitted") + + row = page.locator( + f"[data-testid='document-row'][data-doc-name={json.dumps(filename)}]" + ) + expect(row).to_be_visible(timeout=RESULT_TIMEOUT_MS) + + flow_state["uploads_done"] = True + + +def step_05_wait_parse_success( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "uploads_done", "filenames") + page = flow_page + for filename in flow_state["filenames"]: + with step(f"wait for parse success {filename}"): + wait_for_success_dot(page, expect, filename, timeout_ms=RESULT_TIMEOUT_MS) + snap(f"parse_{filename}_success") + flow_state["parse_complete"] = True + + +def step_06_delete_one_file( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "parse_complete", "filenames") + page = flow_page + delete_filename = "Doc3.pdf" + with step(f"delete uploaded file {delete_filename}"): + delete_uploaded_file(page, expect, delete_filename, timeout_ms=RESULT_TIMEOUT_MS) + snap("file_deleted_doc3") + expect( + page.locator( + f"[data-testid='document-row'][data-doc-name={json.dumps('Doc1.pdf')}]" + ) + ).to_be_visible(timeout=RESULT_TIMEOUT_MS) + expect( + page.locator( + f"[data-testid='document-row'][data-doc-name={json.dumps('Doc2.pdf')}]" + ) + ).to_be_visible(timeout=RESULT_TIMEOUT_MS) + snap("success") + + +STEPS = [ + ("01_login", step_01_login), + ("02_open_datasets", step_02_open_datasets), + ("03_create_dataset", step_03_create_dataset), + ("04_upload_files", step_04_upload_files), + ("05_wait_parse_success", step_05_wait_parse_success), + ("06_delete_one_file", step_06_delete_one_file), +] + + +@pytest.mark.p1 +@pytest.mark.auth +@pytest.mark.parametrize("step_fn", flow_params(STEPS)) +def test_dataset_upload_parse_and_delete_flow( + step_fn, + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + step_fn( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, + ) diff --git a/test/playwright/e2e/test_model_providers_zhipu_ai_defaults.py b/test/playwright/e2e/test_model_providers_zhipu_ai_defaults.py new file mode 100644 index 0000000000..08f55cf7c9 --- /dev/null +++ b/test/playwright/e2e/test_model_providers_zhipu_ai_defaults.py @@ -0,0 +1,327 @@ +import re +import os +import pytest +from playwright.sync_api import expect + +from test.playwright.helpers.flow_steps import flow_params, require +from test.playwright.helpers.auth_selectors import EMAIL_INPUT, PASSWORD_INPUT, SUBMIT_BUTTON +from test.playwright.helpers.auth_waits import wait_for_login_complete +from test.playwright.helpers.response_capture import capture_response +from test.playwright.helpers.model_providers import ( + open_user_settings, + safe_close_modal, + select_default_model, +) + +RESULT_TIMEOUT_MS = 15000 + + +def step_01_open_login( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + api_key = os.getenv("ZHIPU_AI_API_KEY") + if not api_key: + pytest.skip("ZHIPU_AI_API_KEY not set; skipping model providers test.") + + email, password = seeded_user_credentials + + flow_state["api_key"] = api_key + flow_state["email"] = email + flow_state["password"] = password + + with step("open login page"): + flow_page.goto(login_url, wait_until="domcontentloaded") + flow_state["login_opened"] = True + snap("login_opened") + + +def step_02_login( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "login_opened", "email", "password") + page = flow_page + form, _ = active_auth_context() + email_input = form.locator(EMAIL_INPUT) + password_input = form.locator(PASSWORD_INPUT) + with step("fill credentials"): + expect(email_input).to_have_count(1) + expect(password_input).to_have_count(1) + email_input.fill(flow_state["email"]) + password_input.fill(flow_state["password"]) + password_input.blur() + + with step("submit login"): + submit_button = form.locator(SUBMIT_BUTTON) + expect(submit_button).to_have_count(1) + auth_click(submit_button, "submit_login") + + with step("wait for login"): + wait_for_login_complete(page, timeout_ms=RESULT_TIMEOUT_MS) + + flow_state["logged_in"] = True + snap("home_loaded") + + +def step_03_open_settings( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "logged_in") + page = flow_page + with step("open settings"): + open_user_settings(page, base_url) + flow_state["settings_open"] = True + snap("settings_opened") + + +def step_04_open_model_providers( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "settings_open") + page = flow_page + with step("open model providers"): + model_nav = page.locator("[data-testid='settings-nav-model-providers']") + expect(model_nav).to_have_count(1) + model_nav.first.click() + expect(page.locator("text=Set default models")).to_be_visible() + flow_state["model_providers_open"] = True + snap("model_providers_open") + + +def step_05_filter_zhipu( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "model_providers_open") + page = flow_page + with step("filter providers"): + search_input = page.locator("[data-testid='model-providers-search']") + expect(search_input).to_have_count(1) + search_input.first.fill("zhipu") + available_section = page.locator("[data-testid='available-models-section']") + provider = available_section.locator( + "[data-testid='available-model-card'][data-provider='ZHIPU-AI']" + ).first + if provider.count() == 0: + added_section = page.locator("[data-testid='added-models-section']") + if ( + added_section.locator( + "[data-testid='added-model-card'][data-provider='ZHIPU-AI']" + ).count() + == 0 + ): + raise AssertionError("ZHIPU-AI provider not found in available or added models.") + else: + expect(provider).to_be_visible() + flow_state["provider_filtered"] = True + snap("provider_filtered") + + +def step_06_add_api_key( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "provider_filtered", "api_key") + page = flow_page + available_section = page.locator("[data-testid='available-models-section']") + provider = available_section.locator( + "[data-testid='available-model-card'][data-provider='ZHIPU-AI']" + ).first + + with step("add ZHIPU-AI api key"): + if provider.count() > 0: + provider.click() + else: + added_section = page.locator("[data-testid='added-models-section']") + card = added_section.locator( + "[data-testid='added-model-card'][data-provider='ZHIPU-AI']" + ).first + api_key_button = card.locator("button", has_text=re.compile("API-?Key", re.I)).first + expect(api_key_button).to_be_visible() + api_key_button.click() + modal = page.locator("[data-testid='apikey-modal']") + expect(modal).to_be_visible() + api_input = modal.locator("[data-testid='apikey-input']").first + save_button = modal.locator("[data-testid='apikey-save']").first + try: + def trigger(): + api_input.fill(flow_state["api_key"]) + save_button.click() + + capture_response( + page, + trigger, + lambda resp: resp.request.method == "POST" and "/v1/llm/set_api_key" in resp.url, + ) + expect(modal).not_to_be_visible(timeout=RESULT_TIMEOUT_MS) + except Exception: + safe_close_modal(modal) + raise + + with step("confirm added model"): + added_section = page.locator("[data-testid='added-models-section']") + expect(added_section).to_be_visible() + expect( + added_section.locator( + "[data-testid='added-model-card'][data-provider='ZHIPU-AI']" + ) + ).to_be_visible() + flow_state["provider_added"] = True + snap("provider_saved") + + +def step_07_set_defaults( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "provider_added") + page = flow_page + with step("set default models"): + llm_combo = page.locator("[data-testid='default-llm-combobox']").first + emb_combo = page.locator("[data-testid='default-embedding-combobox']").first + + select_default_model( + page, + expect, + llm_combo, + "glm-4-flash", + "glm-4-flash", + list_testid="default-llm-options", + fallback_to_first=False, + timeout_ms=RESULT_TIMEOUT_MS, + ) + selected_emb_text, _ = select_default_model( + page, + expect, + emb_combo, + "embedding-2", + "embedding-2", + list_testid="default-embedding-options", + fallback_to_first=True, + timeout_ms=RESULT_TIMEOUT_MS, + ) + flow_state["selected_emb_text"] = selected_emb_text + flow_state["defaults_set"] = True + snap("defaults_selected") + + +def step_08_verify_persist( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "defaults_set") + page = flow_page + with step("reload and verify defaults"): + page.reload(wait_until="domcontentloaded") + expect(page.locator("text=Set default models")).to_be_visible() + llm_combo = page.locator("[data-testid='default-llm-combobox']").first + emb_combo = page.locator("[data-testid='default-embedding-combobox']").first + expect(llm_combo).to_contain_text("glm-4-flash") + expect(emb_combo).to_contain_text(flow_state.get("selected_emb_text") or "embedding-2") + added_section = page.locator("[data-testid='added-models-section']") + expect( + added_section.locator( + "[data-testid='added-model-card'][data-provider='ZHIPU-AI']" + ) + ).to_be_visible() + snap("defaults_persisted") + snap("success") + + +STEPS = [ + ("01_open_login", step_01_open_login), + ("02_login", step_02_login), + ("03_open_settings", step_03_open_settings), + ("04_open_model_providers", step_04_open_model_providers), + ("05_filter_zhipu", step_05_filter_zhipu), + ("06_add_api_key", step_06_add_api_key), + ("07_set_defaults", step_07_set_defaults), + ("08_verify_persist", step_08_verify_persist), +] + + +@pytest.mark.p1 +@pytest.mark.auth +@pytest.mark.parametrize("step_fn", flow_params(STEPS)) +def test_add_zhipu_ai_set_defaults_persist_flow( + step_fn, + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + step_fn( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, + ) diff --git a/test/playwright/e2e/test_next_apps_agent.py b/test/playwright/e2e/test_next_apps_agent.py new file mode 100644 index 0000000000..67a0532ba0 --- /dev/null +++ b/test/playwright/e2e/test_next_apps_agent.py @@ -0,0 +1,397 @@ +import re +from pathlib import Path + +import pytest +from playwright.sync_api import expect + +from test.playwright.helpers._auth_helpers import ensure_authed +from test.playwright.helpers.flow_steps import flow_params, require +from test.playwright.helpers._next_apps_helpers import ( + RESULT_TIMEOUT_MS, + _fill_and_save_create_modal, + _goto_home, + _nav_click, + _open_create_from_list, + _unique_name, + _wait_for_url_regex, +) + + +def _visible_testids(page, limit: int = 80): + try: + return page.evaluate( + """ + (limit) => { + const elements = Array.from(document.querySelectorAll('[data-testid]')); + const visible = elements.filter((el) => { + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + const values = Array.from( + new Set( + visible.map((el) => el.getAttribute('data-testid')).filter(Boolean), + ), + ); + values.sort(); + return values.slice(0, limit); + } + """, + limit, + ) + except Exception as exc: + return [f""] + + +def _raise_with_diagnostics(page, message: str, snap=None, snap_name: str = "") -> None: + testids = _visible_testids(page) + if snap is not None and snap_name: + try: + snap(snap_name) + except Exception: + pass + details = f"{message} url={page.url} testids={testids}" + print(details, flush=True) + raise AssertionError(details) + + +def _set_import_file(modal, file_path: str) -> None: + upload_target = modal.locator("[data-testid='agent-import-file']").first + if upload_target.count() == 0: + raise AssertionError("agent-import-file not found in import modal.") + tag_name = upload_target.evaluate("el => el.tagName.toLowerCase()") + if tag_name == "input" and upload_target.get_attribute("type") == "file": + upload_target.set_input_files(file_path) + return + file_input = modal.locator("input[type='file']").first + if file_input.count() == 0: + raise AssertionError("No file input found in agent import modal.") + file_input.set_input_files(file_path) + + +def step_01_ensure_authed( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + repo_root = Path(__file__).resolve().parents[3] + dv_path = repo_root / "test/benchmark/test_docs/dv.json" + if not dv_path.is_file(): + pytest.fail(f"Missing agent import fixture: {dv_path}") + flow_state["dv_path"] = str(dv_path) + + with step("ensure logged in"): + ensure_authed( + flow_page, + login_url, + active_auth_context, + auth_click, + seeded_user_credentials=seeded_user_credentials, + ) + flow_state["logged_in"] = True + snap("authed") + + +def step_02_open_agent_list( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "logged_in") + page = flow_page + with step("open agent list"): + _goto_home(page, base_url) + _nav_click(page, "nav-agent") + expect(page.locator("[data-testid='agents-list']")).to_be_visible( + timeout=RESULT_TIMEOUT_MS + ) + snap("agent_list_open") + + +def step_03_create_first_agent( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "logged_in") + page = flow_page + first_name = _unique_name("qa-agent") + flow_state["first_agent_name"] = first_name + with step("create first agent"): + _open_create_from_list( + page, + "agents-empty-create", + "create-agent", + modal_testid="agent-create-modal", + ) + _fill_and_save_create_modal( + page, + first_name, + modal_testid="agent-create-modal", + name_input_testid="agent-name-input", + save_testid="agent-save", + ) + expect(page.locator("[data-testid='agents-list']")).to_be_visible( + timeout=RESULT_TIMEOUT_MS + ) + expect(page.locator("[data-testid='agent-card']").first).to_be_visible( + timeout=RESULT_TIMEOUT_MS + ) + flow_state["first_agent_created"] = True + snap("agent_first_created") + + +def step_04_import_agent( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "first_agent_created", "dv_path") + page = flow_page + second_name = _unique_name("qa-agent-import") + flow_state["second_agent_name"] = second_name + with step("import agent json"): + create_button = page.locator("[data-testid='create-agent']") + expect(create_button).to_be_visible(timeout=RESULT_TIMEOUT_MS) + create_button.click() + menu = page.locator("[data-testid='agent-create-menu']") + expect(menu).to_be_visible(timeout=RESULT_TIMEOUT_MS) + menu.locator("[data-testid='agent-import-json']").click() + + modal = page.locator("[data-testid='agent-import-modal']") + expect(modal).to_be_visible(timeout=RESULT_TIMEOUT_MS) + snap("agent_import_modal") + + _set_import_file(modal, flow_state["dv_path"]) + name_input = modal.locator("[data-testid='agent-name-input']") + expect(name_input).to_be_visible(timeout=RESULT_TIMEOUT_MS) + name_input.fill(second_name) + save_button = modal.locator("[data-testid='agent-import-save']") + expect(save_button).to_be_visible(timeout=RESULT_TIMEOUT_MS) + save_button.click() + expect(modal).not_to_be_visible(timeout=RESULT_TIMEOUT_MS) + flow_state["second_agent_created"] = True + snap("agent_second_created") + + +def step_05_open_imported_agent( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "second_agent_created", "second_agent_name") + page = flow_page + with step("open imported agent"): + card = page.locator( + "[data-testid='agent-card']", + has=page.locator( + "[data-testid='agent-name']", has_text=re.compile(flow_state["second_agent_name"]) + ), + ).first + expect(card).to_be_visible(timeout=RESULT_TIMEOUT_MS) + auth_click(card, "open_agent") + _wait_for_url_regex(page, r"/agent/") + expect(page.locator("[data-testid='agent-detail']")).to_be_visible( + timeout=RESULT_TIMEOUT_MS + ) + flow_state["agent_detail_open"] = True + snap("agent_detail_open") + + +def step_06_run_agent( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "agent_detail_open") + page = flow_page + with step("run agent"): + import os + + run_ui_timeout_ms = int(os.getenv("PW_AGENT_RUN_UI_TIMEOUT_MS", "60000")) + run_root = page.locator("[data-testid='agent-run']") + run_ui_selector = ( + "[data-testid='agent-run-chat'], " + "[data-testid='chat-textarea'], " + "[data-testid='agent-run-idle']" + ) + run_ui_locator = page.locator(run_ui_selector) + + try: + if run_ui_locator.count() > 0 and run_ui_locator.first.is_visible(): + flow_state["agent_running"] = True + snap("agent_run_already_open") + return + except Exception: + pass + + if run_root.count() == 0: + run_button = page.get_by_role("button", name=re.compile(r"^run$", re.I)) + else: + run_button = run_root + expect(run_button).to_be_visible(timeout=RESULT_TIMEOUT_MS) + try: + auth_click(run_button, "agent_run") + except Exception: + page.wait_for_timeout(500) + auth_click(run_button, "agent_run_retry") + + try: + run_ui_locator.first.wait_for(state="visible", timeout=run_ui_timeout_ms) + except Exception: + _raise_with_diagnostics( + page, + "Agent run UI did not open after clicking Run.", + snap=snap, + snap_name="agent_run_missing", + ) + + flow_state["agent_running"] = True + snap("agent_run_started") + return + + +def step_07_send_chat( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "agent_running") + page = flow_page + with step("send agent chat"): + dataset_combobox = page.locator("[data-testid='chat-datasets-combobox']") + if dataset_combobox.count() > 0: + try: + if dataset_combobox.is_visible(): + dataset_combobox.click() + options = page.locator("[data-testid='datasets-options']") + expect(options).to_be_visible(timeout=RESULT_TIMEOUT_MS) + option = page.locator("[data-testid='datasets-option-0']") + if option.count() == 0: + option = page.locator("[data-testid^='datasets-option-']").first + if option.count() > 0 and option.is_visible(): + try: + flow_state["dataset_label"] = option.inner_text() + except Exception: + flow_state["dataset_label"] = "" + option.click() + flow_state["dataset_selected"] = True + except Exception: + pass + + textarea = page.locator("[data-testid='chat-textarea']") + idle_marker = page.locator("[data-testid='agent-run-idle']") + try: + expect(textarea).to_be_visible(timeout=RESULT_TIMEOUT_MS) + except AssertionError: + _raise_with_diagnostics( + page, + "Chat textarea not visible in agent run UI.", + snap=snap, + snap_name="agent_run_chat_missing", + ) + + textarea.fill("say hello") + textarea.press("Enter") + try: + expect(idle_marker).to_be_visible(timeout=60000) + except AssertionError: + # Older UI builds do not expose agent-run-idle; fallback to assistant reply. + agent_chat = page.locator("[data-testid='agent-run-chat']") + assistant_reply = agent_chat.locator( + "text=/how can i assist|hello/i" + ).first + try: + expect(assistant_reply).to_be_visible(timeout=60000) + except AssertionError: + _raise_with_diagnostics( + page, + "Agent run chat did not return to idle state after sending message.", + snap=snap, + snap_name="agent_run_idle_missing", + ) + snap("agent_run_idle_restored") + + +STEPS = [ + ("01_ensure_authed", step_01_ensure_authed), + ("02_open_agent_list", step_02_open_agent_list), + ("03_create_first_agent", step_03_create_first_agent), + ("04_import_agent", step_04_import_agent), + ("05_open_imported_agent", step_05_open_imported_agent), + ("06_run_agent", step_06_run_agent), + ("07_send_chat", step_07_send_chat), +] + + +@pytest.mark.p1 +@pytest.mark.auth +@pytest.mark.parametrize("step_fn", flow_params(STEPS)) +def test_agent_create_then_import_json_then_run_and_wait_idle_flow( + step_fn, + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + step_fn( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, + ) diff --git a/test/playwright/e2e/test_next_apps_chat.py b/test/playwright/e2e/test_next_apps_chat.py new file mode 100644 index 0000000000..baf3c75a01 --- /dev/null +++ b/test/playwright/e2e/test_next_apps_chat.py @@ -0,0 +1,126 @@ +import pytest +from playwright.sync_api import expect + +from test.playwright.helpers.flow_context import FlowContext +from test.playwright.helpers._auth_helpers import ensure_authed +from test.playwright.helpers.flow_steps import flow_params, require +from test.playwright.helpers._next_apps_helpers import ( + RESULT_TIMEOUT_MS, + _fill_and_save_create_modal, + _goto_home, + _nav_click, + _open_create_from_list, + _select_first_dataset_and_save, + _send_chat_and_wait_done, + _unique_name, + _wait_for_url_or_testid, +) + + +def step_01_ensure_authed(ctx: FlowContext, step, snap): + with step("ensure logged in"): + ensure_authed( + ctx.page, + ctx.login_url, + ctx.active_auth_context, + ctx.auth_click, + seeded_user_credentials=ctx.seeded_user_credentials, + ) + ctx.state["logged_in"] = True + snap("authed") + + +def step_02_open_chat_list(ctx: FlowContext, step, snap): + require(ctx.state, "logged_in") + page = ctx.page + with step("open chat list"): + _goto_home(page, ctx.base_url) + _nav_click(page, "nav-chat") + expect(page.locator("[data-testid='chats-list']")).to_be_visible( + timeout=RESULT_TIMEOUT_MS + ) + snap("chat_list_open") + + +def step_03_open_create_modal(ctx: FlowContext, step, snap): + require(ctx.state, "logged_in") + page = ctx.page + with step("open create chat modal"): + _open_create_from_list(page, "chats-empty-create", "create-chat") + ctx.state["chat_modal_open"] = True + snap("chat_create_modal") + + +def step_04_create_chat(ctx: FlowContext, step, snap): + require(ctx.state, "chat_modal_open") + page = ctx.page + chat_name = _unique_name("qa-chat") + ctx.state["chat_name"] = chat_name + with step("create chat app"): + _fill_and_save_create_modal(page, chat_name) + chat_detail = page.locator("[data-testid='chat-detail']") + try: + _wait_for_url_or_testid(page, r"/next-chat/", "chat-detail", timeout_ms=5000) + except AssertionError: + list_root = page.locator("[data-testid='chats-list']") + expect(list_root).to_be_visible(timeout=RESULT_TIMEOUT_MS) + card = list_root.locator(f"text={chat_name}").first + expect(card).to_be_visible(timeout=RESULT_TIMEOUT_MS) + card.click() + expect(chat_detail).to_be_visible(timeout=RESULT_TIMEOUT_MS) + ctx.state["chat_created"] = True + snap("chat_created") + + +def step_05_select_dataset(ctx: FlowContext, step, snap): + require(ctx.state, "chat_created") + page = ctx.page + with step("select dataset"): + _select_first_dataset_and_save(page, timeout_ms=RESULT_TIMEOUT_MS) + ctx.state["chat_dataset_selected"] = True + snap("chat_dataset_saved") + + +def step_06_ask_question(ctx: FlowContext, step, snap): + require(ctx.state, "chat_dataset_selected") + page = ctx.page + with step("ask question"): + _send_chat_and_wait_done(page, "what is ragflow", timeout_ms=60000) + snap("chat_stream_done") + + +STEPS = [ + ("01_ensure_authed", step_01_ensure_authed), + ("02_open_chat_list", step_02_open_chat_list), + ("03_open_create_modal", step_03_open_create_modal), + ("04_create_chat", step_04_create_chat), + ("05_select_dataset", step_05_select_dataset), + ("06_ask_question", step_06_ask_question), +] + + +@pytest.mark.p1 +@pytest.mark.auth +@pytest.mark.parametrize("step_fn", flow_params(STEPS)) +def test_chat_create_select_dataset_and_receive_answer_flow( + step_fn, + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + ctx = FlowContext( + page=flow_page, + state=flow_state, + base_url=base_url, + login_url=login_url, + active_auth_context=active_auth_context, + auth_click=auth_click, + seeded_user_credentials=seeded_user_credentials, + ) + step_fn(ctx, step, snap) diff --git a/test/playwright/e2e/test_next_apps_search.py b/test/playwright/e2e/test_next_apps_search.py new file mode 100644 index 0000000000..0bcbb134a6 --- /dev/null +++ b/test/playwright/e2e/test_next_apps_search.py @@ -0,0 +1,216 @@ +import pytest +from playwright.sync_api import expect + +from test.playwright.helpers._auth_helpers import ensure_authed +from test.playwright.helpers.flow_steps import flow_params, require +from test.playwright.helpers._next_apps_helpers import ( + RESULT_TIMEOUT_MS, + _fill_and_save_create_modal, + _goto_home, + _nav_click, + _open_create_from_list, + _select_first_dataset_and_save, + _unique_name, + _wait_for_url_or_testid, +) + + +def _wait_for_results_navigation(page, timeout_ms: int = RESULT_TIMEOUT_MS) -> None: + wait_js = """ + () => { + const top = document.querySelector("[data-testid='top-nav']"); + const navs = Array.from(document.querySelectorAll('[role="navigation"]')); + return navs.some((nav) => !top || !top.contains(nav)); + } + """ + page.wait_for_function(wait_js, timeout=timeout_ms) + index = page.evaluate( + """ + () => { + const top = document.querySelector("[data-testid='top-nav']"); + const navs = Array.from(document.querySelectorAll('[role="navigation"]')); + for (let i = 0; i < navs.length; i += 1) { + if (!top || !top.contains(navs[i])) return i; + } + return -1; + } + """ + ) + navs = page.locator("[role='navigation']") + target = navs.first if index < 0 else navs.nth(index) + expect(target).to_be_visible(timeout=timeout_ms) + + +def step_01_ensure_authed( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + with step("ensure logged in"): + ensure_authed( + flow_page, + login_url, + active_auth_context, + auth_click, + seeded_user_credentials=seeded_user_credentials, + ) + flow_state["logged_in"] = True + snap("authed") + + +def step_02_open_search_list( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "logged_in") + page = flow_page + with step("open search list"): + _goto_home(page, base_url) + _nav_click(page, "nav-search") + expect(page.locator("[data-testid='search-list']")).to_be_visible( + timeout=RESULT_TIMEOUT_MS + ) + snap("search_list_open") + + +def step_03_open_create_modal( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "logged_in") + page = flow_page + with step("open create search modal"): + _open_create_from_list(page, "search-empty-create", "create-search") + flow_state["search_modal_open"] = True + snap("search_create_modal") + + +def step_04_create_search( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "search_modal_open") + page = flow_page + search_name = _unique_name("qa-search") + flow_state["search_name"] = search_name + with step("create search app"): + _fill_and_save_create_modal(page, search_name) + _wait_for_url_or_testid(page, r"/next-search/", "search-detail") + expect(page.locator("[data-testid='search-detail']")).to_be_visible( + timeout=RESULT_TIMEOUT_MS + ) + flow_state["search_created"] = True + snap("search_created") + + +def step_05_select_dataset( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "search_created") + page = flow_page + with step("select dataset"): + search_input = page.locator( + "input[placeholder*='How can I help you today']" + ).first + _select_first_dataset_and_save( + page, + timeout_ms=RESULT_TIMEOUT_MS, + post_save_ready_locator=search_input, + ) + flow_state["search_input_ready"] = True + snap("search_dataset_saved") + + +def step_06_run_query( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + require(flow_state, "search_input_ready") + page = flow_page + search_input = page.locator("input[placeholder*='How can I help you today']").first + with step("run search query"): + expect(search_input).to_be_visible(timeout=RESULT_TIMEOUT_MS) + search_input.fill("ragflow") + search_input.press("Enter") + _wait_for_results_navigation(page, timeout_ms=RESULT_TIMEOUT_MS) + snap("search_results_nav") + + +STEPS = [ + ("01_ensure_authed", step_01_ensure_authed), + ("02_open_search_list", step_02_open_search_list), + ("03_open_create_modal", step_03_open_create_modal), + ("04_create_search", step_04_create_search), + ("05_select_dataset", step_05_select_dataset), + ("06_run_query", step_06_run_query), +] + + +@pytest.mark.p1 +@pytest.mark.auth +@pytest.mark.parametrize("step_fn", flow_params(STEPS)) +def test_search_create_select_dataset_and_results_nav_appears_flow( + step_fn, + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, +): + step_fn( + flow_page, + flow_state, + base_url, + login_url, + active_auth_context, + step, + snap, + auth_click, + seeded_user_credentials, + ) diff --git a/test/playwright/helpers/__init__.py b/test/playwright/helpers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/playwright/helpers/_auth_helpers.py b/test/playwright/helpers/_auth_helpers.py new file mode 100644 index 0000000000..8d303ca726 --- /dev/null +++ b/test/playwright/helpers/_auth_helpers.py @@ -0,0 +1,86 @@ +import os + +import pytest +from playwright.sync_api import expect + +RESULT_TIMEOUT_MS = 15000 + + +def _wait_for_login_complete(page, timeout_ms: int = RESULT_TIMEOUT_MS) -> None: + wait_js = """ + () => { + const path = window.location.pathname || ''; + if (path.includes('/login')) return false; + const token = localStorage.getItem('Token'); + const auth = localStorage.getItem('Authorization'); + return Boolean((token && token.length) || (auth && auth.length)); + } + """ + page.wait_for_function(wait_js, timeout=timeout_ms) + + +def ensure_authed( + page, + login_url: str, + active_auth_context, + auth_click, + seeded_user_credentials=None, + timeout_ms: int = RESULT_TIMEOUT_MS, +) -> None: + if seeded_user_credentials: + email, password = seeded_user_credentials + else: + email = os.getenv("SEEDED_USER_EMAIL") + password = os.getenv("SEEDED_USER_PASSWORD") + if not email or not password: + pytest.skip("SEEDED_USER_EMAIL/SEEDED_USER_PASSWORD not set.") + + token_wait_js = """ + () => { + const token = localStorage.getItem('Token'); + const auth = localStorage.getItem('Authorization'); + return Boolean((token && token.length) || (auth && auth.length)); + } + """ + + try: + if "/login" not in page.url: + if ( + page.locator( + "input[data-testid='auth-email'], [data-testid='auth-email'] input" + ).count() + == 0 + ): + try: + page.wait_for_function(token_wait_js, timeout=2000) + return + except Exception: + pass + except Exception: + pass + + page.goto(login_url, wait_until="domcontentloaded") + + form, _ = active_auth_context() + email_input = form.locator( + "input[data-testid='auth-email'], [data-testid='auth-email'] input" + ) + password_input = form.locator( + "input[data-testid='auth-password'], [data-testid='auth-password'] input" + ) + expect(email_input).to_have_count(1) + expect(password_input).to_have_count(1) + email_input.fill(email) + password_input.fill(password) + password_input.blur() + + submit_button = form.locator( + "button[data-testid='auth-submit'], [data-testid='auth-submit'] button, [data-testid='auth-submit']" + ) + expect(submit_button).to_have_count(1) + auth_click(submit_button, "submit_login") + + _wait_for_login_complete(page, timeout_ms=timeout_ms) + expect(page.locator("form[data-testid='auth-form'][data-active='true']")).to_have_count( + 0, timeout=timeout_ms + ) diff --git a/test/playwright/helpers/_next_apps_helpers.py b/test/playwright/helpers/_next_apps_helpers.py new file mode 100644 index 0000000000..25830fcc99 --- /dev/null +++ b/test/playwright/helpers/_next_apps_helpers.py @@ -0,0 +1,437 @@ +import re +import time +from urllib.parse import urljoin + +from playwright.sync_api import expect + +from test.playwright.helpers.response_capture import capture_response + +RESULT_TIMEOUT_MS = 15000 + + +def _unique_name(prefix: str) -> str: + return f"{prefix}-{int(time.time() * 1000)}" + + +def _assert_not_on_login(page) -> None: + if "/login" in page.url or page.locator("input[autocomplete='email']").count() > 0: + raise AssertionError( + "Expected authenticated session; landed on /login. " + "Ensure ensure_authed(...) was called and credentials are set." + ) + + +def _goto_home(page, base_url: str) -> None: + page.goto(urljoin(base_url.rstrip("/") + "/", "/"), wait_until="domcontentloaded") + _assert_not_on_login(page) + + +def _nav_click(page, testid: str) -> None: + locator = page.locator(f"[data-testid='{testid}']") + if locator.count() > 0: + expect(locator.first).to_be_visible(timeout=RESULT_TIMEOUT_MS) + locator.first.click() + return + + nav_text_map = { + "nav-chat": "chat", + "nav-search": "search", + "nav-agent": "agent", + } + label = nav_text_map.get(testid) + if label: + pattern = re.compile(rf"^{re.escape(label)}$", re.I) + fallback = page.get_by_role("button", name=pattern) + if fallback.count() == 0: + top_nav = page.locator("[data-testid='top-nav']") + if top_nav.count() > 0: + fallback = top_nav.first.get_by_text(pattern) + else: + fallback = page.get_by_text(pattern) + if fallback.count() == 0: + fallback = page.locator("button, [role='button'], a, span, div").filter( + has_text=pattern + ) + expect(fallback.first).to_be_visible(timeout=RESULT_TIMEOUT_MS) + fallback.first.click() + return + + expect(locator).to_be_visible(timeout=RESULT_TIMEOUT_MS) + locator.click() + + +def _open_create_from_list( + page, + empty_testid: str, + create_btn_testid: str, + modal_testid: str = "rename-modal", +): + empty = page.locator(f"[data-testid='{empty_testid}']") + if empty.count() > 0 and empty.first.is_visible(): + empty.first.click() + else: + create_btn = page.locator(f"[data-testid='{create_btn_testid}']") + if create_btn.count() > 0: + expect(create_btn.first).to_be_visible(timeout=RESULT_TIMEOUT_MS) + create_btn.first.click() + else: + create_text_map = { + "create-chat": r"create\s+chat", + "create-search": r"create\s+search", + "create-agent": r"create\s+agent", + } + pattern = create_text_map.get(create_btn_testid) + clicked = False + if pattern: + fallback_btn = page.get_by_role( + "button", name=re.compile(pattern, re.I) + ) + if fallback_btn.count() > 0 and fallback_btn.first.is_visible(): + fallback_btn.first.click() + clicked = True + + if not clicked: + empty_text_map = { + "chats-empty-create": r"no chat app created yet", + "search-empty-create": r"no search app created yet", + "agents-empty-create": r"no agent", + } + empty_pattern = empty_text_map.get(empty_testid) + if empty_pattern: + empty_state = page.locator("div, section, article").filter( + has_text=re.compile(empty_pattern, re.I) + ) + if empty_state.count() > 0 and empty_state.first.is_visible(): + empty_state.first.click() + clicked = True + + if not clicked: + fallback_card = page.locator( + ".border-dashed, [class*='border-dashed']" + ).first + expect(fallback_card).to_be_visible(timeout=RESULT_TIMEOUT_MS) + fallback_card.click() + modal = page.locator(f"[data-testid='{modal_testid}']") + expect(modal).to_be_visible(timeout=RESULT_TIMEOUT_MS) + return modal + + +def _fill_and_save_create_modal( + page, + name: str, + modal_testid: str = "rename-modal", + name_input_testid: str = "rename-name-input", + save_testid: str = "rename-save", +) -> None: + modal = page.locator(f"[data-testid='{modal_testid}']") + expect(modal).to_be_visible(timeout=RESULT_TIMEOUT_MS) + name_input = modal.locator(f"[data-testid='{name_input_testid}']") + expect(name_input).to_be_visible(timeout=RESULT_TIMEOUT_MS) + name_input.fill(name) + save_button = modal.locator(f"[data-testid='{save_testid}']") + expect(save_button).to_be_visible(timeout=RESULT_TIMEOUT_MS) + save_button.click() + expect(modal).not_to_be_visible(timeout=RESULT_TIMEOUT_MS) + + +def _select_first_dataset_and_save( + page, + timeout_ms: int = RESULT_TIMEOUT_MS, + response_timeout_ms: int = 30000, + post_save_ready_locator=None, +) -> None: + chat_root = page.locator("[data-testid='chat-detail']") + search_root = page.locator("[data-testid='search-detail']") + scope_root = None + combobox_testid = None + save_testid = None + try: + if chat_root.count() > 0 and chat_root.is_visible(): + scope_root = chat_root + combobox_testid = "chat-datasets-combobox" + save_testid = "chat-settings-save" + except Exception: + pass + if scope_root is None: + try: + if search_root.count() > 0 and search_root.is_visible(): + scope_root = search_root + combobox_testid = "search-datasets-combobox" + save_testid = "search-settings-save" + except Exception: + pass + if scope_root is None: + scope_root = page + combobox_testid = "search-datasets-combobox" + save_testid = "search-settings-save" + + def _find_dataset_combobox(search_scope): + combo = search_scope.locator(f"[data-testid='{combobox_testid}']") + if combo.count() > 0: + return combo + combo = search_scope.locator("[role='combobox']").filter( + has_text=re.compile(r"select|dataset|please", re.I) + ) + if combo.count() > 0: + return combo + return search_scope.locator("[role='combobox']") + + combobox = _find_dataset_combobox(scope_root) + if combobox.count() == 0: + settings_candidates = [ + scope_root.locator("button:has(svg.lucide-settings)"), + scope_root.locator("button:has(svg[class*='settings'])"), + scope_root.locator("[data-testid='chat-settings']"), + scope_root.locator("[data-testid='search-settings']"), + scope_root.locator("button", has_text=re.compile(r"search settings", re.I)), + scope_root.locator("button", has=scope_root.locator("svg.lucide-settings")), + page.locator("button:has(svg.lucide-settings)"), + page.locator("button", has_text=re.compile(r"search settings", re.I)), + ] + for settings_button in settings_candidates: + if settings_button.count() == 0: + continue + if not settings_button.first.is_visible(): + continue + settings_button.first.click() + break + + settings_dialog = page.locator("[role='dialog']").filter( + has_text=re.compile(r"settings", re.I) + ) + if settings_dialog.count() > 0 and settings_dialog.first.is_visible(): + scope_root = settings_dialog.first + combobox = _find_dataset_combobox(scope_root) + + combobox = combobox.first + expect(combobox).to_be_visible(timeout=timeout_ms) + combo_text = "" + try: + combo_text = combobox.inner_text() + except Exception: + combo_text = "" + if combo_text and not re.search(r"please\s+select|select", combo_text, re.I): + return + + combobox.click() + + options = page.locator("[data-testid='datasets-options']") + if options.count() == 0: + options = page.locator("[role='listbox']:visible") + if options.count() == 0: + options = page.locator("[cmdk-list]:visible") + options = options.first + expect(options).to_be_visible(timeout=timeout_ms) + + option = None + prioritized = [ + options.locator("[data-testid='datasets-option-0']"), + options.locator("[data-testid^='datasets-option-']"), + options.locator("[data-testid='datasets-option']"), + options.locator("[role='option']"), + options.locator("[cmdk-item], [data-value]"), + ] + for candidates in prioritized: + if candidates.count() == 0: + continue + limit = min(candidates.count(), 20) + for idx in range(limit): + candidate = candidates.nth(idx) + try: + if not candidate.is_visible(): + continue + text = (candidate.inner_text() or "").strip().lower() + except Exception: + continue + if not text or "no results found" in text: + continue + option = candidate + break + if option is not None: + break + + if option is None: + list_text = "" + try: + list_text = options.inner_text() + except Exception: + list_text = "" + raise AssertionError( + "No selectable dataset option is available in dataset combobox. " + f"list_text={list_text[:200]!r}" + ) + expect(option).to_be_visible(timeout=timeout_ms) + option.click() + try: + selected_text = combobox.inner_text().strip() + except Exception: + selected_text = "" + if re.search(r"please\s*select|select", selected_text, re.I): + raise AssertionError( + "Dataset selection did not stick after clicking dataset option. " + f"combobox_text={selected_text!r}" + ) + + save_button = scope_root.locator(f"[data-testid='{save_testid}']") + if save_button.count() == 0: + save_button = scope_root.get_by_role( + "button", name=re.compile(r"^save$", re.I) + ) + if save_button.count() == 0: + save_button = scope_root.locator( + "button[type='submit']", has_text=re.compile(r"^save$", re.I) + ).first + save_button = save_button.first + expect(save_button).to_be_visible(timeout=timeout_ms) + + def trigger(): + save_button.click() + + try: + capture_response( + page, + trigger, + lambda resp: "/v1/dialog/set" in resp.url + and resp.request.method in ("POST", "PUT", "PATCH"), + timeout_ms=response_timeout_ms, + ) + except Exception: + pass + try: + expect(options).not_to_be_visible(timeout=timeout_ms) + except AssertionError: + pass + if post_save_ready_locator is not None: + expect(post_save_ready_locator).to_be_visible(timeout=timeout_ms) + else: + page.wait_for_timeout(250) + + +def _send_chat_and_wait_done( + page, text: str, timeout_ms: int = 60000 +) -> None: + textarea = page.locator("[data-testid='chat-textarea']") + expect(textarea).to_be_visible(timeout=RESULT_TIMEOUT_MS) + tag_name = "" + contenteditable = None + try: + tag_name = textarea.evaluate("el => el.tagName") + except Exception: + tag_name = "" + try: + contenteditable = textarea.get_attribute("contenteditable") + except Exception: + contenteditable = None + + is_input = tag_name in ("INPUT", "TEXTAREA") + is_editable = is_input or contenteditable == "true" + if not is_editable: + raise AssertionError( + "chat-textarea is not an editable element. " + f"url={page.url} tag={tag_name!r} contenteditable={contenteditable!r}" + ) + + textarea.fill(text) + typed_value = "" + try: + if is_input: + typed_value = textarea.input_value() + else: + typed_value = textarea.inner_text() + except Exception: + typed_value = "" + + if text not in (typed_value or ""): + textarea.click() + page.keyboard.press("Control+A") + page.keyboard.type(text) + try: + if is_input: + typed_value = textarea.input_value() + else: + typed_value = textarea.inner_text() + except Exception: + typed_value = "" + if text not in (typed_value or ""): + raise AssertionError( + "Failed to type prompt into chat-textarea. " + f"url={page.url} tag={tag_name!r} contenteditable={contenteditable!r} " + f"typed_value={typed_value!r}" + ) + + composer = textarea.locator("xpath=ancestor::form[1]") + if composer.count() == 0: + composer = textarea.locator("xpath=ancestor::div[1]") + send_button = None + if composer.count() > 0: + if hasattr(composer, "get_by_role"): + send_button = composer.get_by_role( + "button", name=re.compile(r"send message", re.I) + ) + if send_button is None or send_button.count() == 0: + send_button = composer.locator( + "button", has_text=re.compile(r"send message", re.I) + ) + if send_button is not None and send_button.count() > 0: + send_button.first.click() + send_used = True + else: + textarea.press("Enter") + send_used = False + + status_marker = page.locator("[data-testid='chat-stream-status']").first + try: + expect(status_marker).to_have_attribute( + "data-status", "idle", timeout=timeout_ms + ) + except Exception as exc: + try: + # Some UI builds remove the stream-status marker when generation finishes. + expect(page.locator("[data-testid='chat-stream-status']")).to_have_count( + 0, timeout=timeout_ms + ) + return + except Exception: + pass + try: + marker_count = page.locator("[data-testid='chat-stream-status']").count() + except Exception: + marker_count = -1 + try: + status_value = status_marker.get_attribute("data-status") + except Exception: + status_value = None + raise AssertionError( + "Chat stream status marker not idle within timeout. " + f"url={page.url} marker_count={marker_count} status={status_value!r} " + f"tag={tag_name!r} contenteditable={contenteditable!r} " + f"typed_value={typed_value!r} send_button_used={send_used}" + ) from exc + + +def _wait_for_url_regex(page, pattern: str, timeout_ms: int = RESULT_TIMEOUT_MS) -> None: + regex = re.compile(pattern) + page.wait_for_url(regex, wait_until="commit", timeout=timeout_ms) + + +def _wait_for_url_or_testid( + page, url_regex: str, testid: str, timeout_ms: int = RESULT_TIMEOUT_MS +) -> str: + end_time = time.time() + (timeout_ms / 1000) + regex = re.compile(url_regex) + locator = page.locator(f"[data-testid='{testid}']") + while time.time() < end_time: + try: + if regex.search(page.url): + return "url" + except Exception: + pass + try: + if locator.count() > 0 and locator.is_visible(): + return "testid" + except Exception: + pass + page.wait_for_timeout(100) + raise AssertionError( + f"Timed out waiting for url {url_regex!r} or testid {testid!r}. url={page.url}" + ) diff --git a/test/playwright/helpers/auth_selectors.py b/test/playwright/helpers/auth_selectors.py new file mode 100644 index 0000000000..51336a500b --- /dev/null +++ b/test/playwright/helpers/auth_selectors.py @@ -0,0 +1,17 @@ +"""Auth UI selectors for Playwright suite. Keep stable testids.""" + +AUTH_FORM = "form[data-testid='auth-form']" +AUTH_ACTIVE_FORM = "form[data-testid='auth-form'][data-active='true']" + +EMAIL_INPUT = "input[data-testid='auth-email'], [data-testid='auth-email'] input" +PASSWORD_INPUT = "input[data-testid='auth-password'], [data-testid='auth-password'] input" +NICKNAME_INPUT = "input[data-testid='auth-nickname'], [data-testid='auth-nickname'] input" + +SUBMIT_BUTTON = ( + "button[data-testid='auth-submit'], [data-testid='auth-submit'] button, " + "[data-testid='auth-submit']" +) + +REGISTER_TAB = "[data-testid='auth-toggle-register']" +LOGIN_TAB = "[data-testid='auth-toggle-login']" +AUTH_STATUS = "[data-testid='auth-status']" diff --git a/test/playwright/helpers/auth_waits.py b/test/playwright/helpers/auth_waits.py new file mode 100644 index 0000000000..31fae9b542 --- /dev/null +++ b/test/playwright/helpers/auth_waits.py @@ -0,0 +1,42 @@ + +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + +try: + from test.playwright.helpers._next_apps_helpers import ( + RESULT_TIMEOUT_MS as DEFAULT_TIMEOUT_MS, + ) +except Exception: + DEFAULT_TIMEOUT_MS = 15000 + + +def wait_for_login_complete(page, timeout_ms: int | None = None) -> None: + if timeout_ms is None: + timeout_ms = DEFAULT_TIMEOUT_MS + wait_js = """ + () => { + const path = window.location.pathname || ''; + if (path.includes('/login')) return false; + const token = localStorage.getItem('Token'); + const auth = localStorage.getItem('Authorization'); + return Boolean((token && token.length) || (auth && auth.length)); + } + """ + try: + page.wait_for_function(wait_js, timeout=timeout_ms) + except PlaywrightTimeoutError as exc: + url = page.url + testids = [] + try: + testids = page.evaluate( + """ + () => Array.from(document.querySelectorAll('[data-testid]')) + .map((el) => el.getAttribute('data-testid')) + .filter((val) => val && /auth/i.test(val)) + .slice(0, 30) + """ + ) + except Exception: + testids = [] + raise AssertionError( + f"Login did not complete within {timeout_ms}ms. url={url} auth_testids={testids}" + ) from exc diff --git a/test/playwright/helpers/datasets.py b/test/playwright/helpers/datasets.py new file mode 100644 index 0000000000..7b61ea110f --- /dev/null +++ b/test/playwright/helpers/datasets.py @@ -0,0 +1,503 @@ +import json +import re + +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + +from test.playwright.helpers.debug_utils import debug +from test.playwright.helpers.env_utils import env_bool + + +def wait_for_dataset_detail(page, timeout_ms: int) -> None: + """Wait for dataset detail path to appear in the URL.""" + wait_js = """ + () => { + const path = window.location.pathname || ''; + return /^\\/datasets\\/.+/.test(path) || /^\\/dataset\\/dataset\\/.+/.test(path); + } + """ + page.wait_for_function(wait_js, timeout=timeout_ms) + + +def wait_for_dataset_detail_ready(page, expect, timeout_ms: int) -> None: + """Wait for dataset detail UI to become ready/visible.""" + wait_for_dataset_detail(page, timeout_ms=timeout_ms) + try: + page.wait_for_load_state("networkidle", timeout=timeout_ms) + except Exception: + try: + page.wait_for_load_state("domcontentloaded", timeout=timeout_ms) + except Exception: + pass + + heading = page.locator("[role='heading']").first + main = page.locator("[role='main']").first + if main.count() > 0: + anchor = main.locator("text=/\\b(add|upload|file|document)\\b/i").first + else: + anchor = page.locator("text=/\\b(add|upload|file|document)\\b/i").first + try: + if heading.count() > 0: + expect(heading).to_be_visible(timeout=timeout_ms) + return + if main.count() > 0: + expect(main).to_be_visible(timeout=timeout_ms) + return + expect(anchor).to_be_visible(timeout=timeout_ms) + except AssertionError: + if env_bool("PW_DEBUG_DUMP"): + url = page.url + button_count = page.locator("button, [role='button']").count() + body_text = page.evaluate( + "(() => (document.body && document.body.innerText) || '')()" + ) + debug( + f"[dataset] detail_ready_failed url={url} button_count={button_count}" + ) + debug(f"[dataset] body_text_snippet={body_text[:200]!r}") + raise + + +def upload_file(page, expect, dialog, file_path: str, timeout_ms: int) -> None: + """Upload a file from the dataset upload modal.""" + dropzone = dialog.locator("[data-testid='dataset-upload-dropzone']").first + expect(dropzone).to_be_visible(timeout=timeout_ms) + if hasattr(page, "expect_file_chooser"): + with page.expect_file_chooser() as chooser_info: + dropzone.click() + chooser_info.value.set_files(file_path) + return + input_locator = dialog.locator("input[type='file']") + if input_locator.count() == 0: + raise AssertionError("File chooser not available and no input[type='file'] found.") + input_locator.first.set_input_files(file_path) + + +def wait_for_success_dot(page, expect, file_name: str, timeout_ms: int) -> None: + """Wait for the parse success dot to show for a file row.""" + name_selector = f"[data-doc-name={json.dumps(file_name)}]" + row = page.locator(f"[data-testid='document-row']{name_selector}") + expect(row).to_be_visible(timeout=timeout_ms) + status = row.locator("[data-testid='document-parse-status']") + expect(status).to_have_attribute("data-state", "success", timeout=timeout_ms) + + +def dump_clickable_candidates(page) -> None: + """Dump a short list of visible clickable UI candidates for debugging.""" + candidates = page.locator("button, [role='button'], a") + total = candidates.count() + lines = [] + limit = min(total, 10) + for idx in range(limit): + item = candidates.nth(idx) + try: + if not item.is_visible(): + continue + text = item.inner_text().strip().replace("\n", " ") + except Exception: + continue + if text: + lines.append(text[:80]) + debug(f"[dataset] clickable_candidates={total} visible_sample={lines}") + + +def get_upload_modal(page): + """Return the dataset upload modal locator.""" + return page.locator("[data-testid='dataset-upload-modal']") + + +def ensure_upload_modal_open(page, expect, auth_click, timeout_ms: int): + """Ensure the dataset upload modal is visible, opening it if needed.""" + modal = get_upload_modal(page) + if modal.count() > 0: + try: + expect(modal).to_be_visible(timeout=timeout_ms) + return modal + except AssertionError: + pass + return open_upload_modal_from_dataset_detail( + page, expect, auth_click, timeout_ms=timeout_ms + ) + + +def ensure_parse_on(upload_modal, expect) -> None: + """Enable parse-on-creation toggle in the upload modal.""" + parse_switch = upload_modal.locator("[data-testid='parse-on-creation-toggle']").first + expect(parse_switch).to_be_visible() + state = parse_switch.get_attribute("data-state") + if state == "checked": + return + parse_switch.click() + expect(parse_switch).to_have_attribute("data-state", "checked") + + +def open_upload_modal_from_dataset_detail(page, expect, auth_click, timeout_ms: int): + """Open the upload modal from dataset detail view.""" + wait_for_dataset_detail_ready(page, expect, timeout_ms=timeout_ms) + page.wait_for_selector("button", timeout=timeout_ms) + + if hasattr(page, "get_by_role"): + tab_locator = page.get_by_role( + "tab", name=re.compile(r"^(files|documents|file)$", re.I) + ) + if tab_locator.count() > 0: + tab = tab_locator.first + try: + if tab.is_visible(): + tab.click() + page.wait_for_timeout(250) + except Exception: + pass + + candidate_names = re.compile( + r"(upload file|upload|add file|add document|add|new)", re.I + ) + trigger_locator = None + if hasattr(page, "get_by_role"): + trigger_locator = page.get_by_role("button", name=candidate_names) + if trigger_locator is None or trigger_locator.count() == 0: + trigger_locator = page.locator("[role='button'], button, a").filter( + has_text=candidate_names + ) + + trigger = None + if trigger_locator.count() > 0: + limit = min(trigger_locator.count(), 5) + for idx in range(limit): + candidate = trigger_locator.nth(idx) + try: + if candidate.is_visible(): + trigger = candidate + break + except Exception: + continue + + if trigger is None: + aria_candidates = page.locator( + "button[aria-label], button[title], [role='button'][aria-label], [role='button'][title]" + ) + limit = min(aria_candidates.count(), 10) + for idx in range(limit): + candidate = aria_candidates.nth(idx) + try: + if not candidate.is_visible(): + continue + aria_label = candidate.get_attribute("aria-label") or "" + title = candidate.get_attribute("title") or "" + if candidate_names.search(aria_label) or candidate_names.search(title): + trigger = candidate + break + except Exception: + continue + + if trigger is None: + if env_bool("PW_DEBUG_DUMP"): + debug("[dataset] upload_trigger_not_found initial scan") + button_dump = [] + buttons = page.locator("button") + total = buttons.count() + limit = min(total, 20) + for idx in range(limit): + item = buttons.nth(idx) + try: + if not item.is_visible(): + continue + except Exception: + continue + try: + text = item.inner_text().strip() + except Exception as exc: + text = f"" + try: + aria_label = item.get_attribute("aria-label") + except Exception as exc: + aria_label = f"" + try: + title = item.get_attribute("title") + except Exception as exc: + title = f"" + button_dump.append( + {"text": text, "aria_label": aria_label, "title": title} + ) + raise AssertionError( + "Upload entrypoint not found on dataset detail page. " + f"visible_buttons={button_dump}" + ) + + try: + if trigger.evaluate("el => el.tagName.toLowerCase() === 'button'"): + auth_click(trigger, "open_upload") + else: + trigger.click() + except Exception: + trigger.click() + + def _click_upload_file_popover_item() -> bool: + locators = [ + page.locator("[role='menuitem']").filter( + has_text=re.compile(r"^upload file$", re.I) + ), + page.locator("[role='option']").filter( + has_text=re.compile(r"^upload file$", re.I) + ), + page.locator("div, span, li").filter( + has_text=re.compile(r"^upload file$", re.I) + ), + ] + for locator in locators: + if locator.count() == 0: + continue + limit = min(locator.count(), 5) + for idx in range(limit): + candidate = locator.nth(idx) + try: + if candidate.is_visible(): + candidate.click() + return True + except Exception: + continue + return False + + clicked_item = _click_upload_file_popover_item() + if not clicked_item: + if env_bool("PW_DEBUG_DUMP"): + try: + button_texts = page.evaluate( + """ + () => Array.from(document.querySelectorAll('button,[role="button"],a')) + .filter((el) => { + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }) + .map((el) => (el.innerText || '').trim()) + .filter(Boolean) + .slice(0, 20) + """ + ) + except Exception: + button_texts = [] + has_upload_text = page.locator("text=/upload file/i").count() > 0 + debug(f"[dataset] upload_item_missing has_upload_text={has_upload_text}") + debug(f"[dataset] visible_button_texts={button_texts}") + raise AssertionError( + "Upload file popover item not found after clicking Add trigger." + ) + + try: + page.wait_for_load_state("domcontentloaded", timeout=timeout_ms) + except Exception: + pass + + upload_modal = page.locator("[data-testid='dataset-upload-modal']") + expect(upload_modal).to_be_visible(timeout=timeout_ms) + return upload_modal + + +def select_chunking_method_general(page, expect, modal, timeout_ms: int) -> None: + """Select the General chunking method inside the dataset modal.""" + trigger_locator = modal.locator( + "button", + has=modal.locator( + "span", has_text=re.compile(r"please select a chunking method\\.", re.I) + ), + ).first + if trigger_locator.count() == 0: + label = modal.locator("text=/please select a chunking method\\./i").first + if label.count() > 0: + trigger_locator = label.locator("xpath=ancestor::button[1]").first + if trigger_locator.count() == 0: + trigger_locator = modal.locator( + "button", + has_text=re.compile(r"please select a chunking method\\.", re.I), + ).first + + if trigger_locator.count() == 0: + if env_bool("PW_DEBUG_DUMP"): + modal_text = modal.inner_text() + button_count = modal.locator("button").count() + label_count = modal.locator( + "text=/please select a chunking method\\./i" + ).count() + debug( + "[dataset] chunking_trigger_missing " + f"button_count={button_count} label_count={label_count} " + f"trigger_locator_count={trigger_locator.count()} " + "trigger_handle_found=False" + ) + debug(f"[dataset] modal_text_snippet={modal_text[:300]!r}") + raise AssertionError("Chunking method dropdown trigger not found.") + + trigger_for_assert = trigger_locator + expect(trigger_locator).to_be_visible(timeout=timeout_ms) + try: + trigger_locator.click() + except Exception: + trigger_locator.click(force=True) + listbox = page.locator("[role='listbox']:visible").last + if listbox.count() == 0: + listbox = page.locator("[cmdk-list]:visible").last + if listbox.count() == 0: + listbox = page.locator("[data-state='open']:visible").last + if listbox.count() == 0: + listbox = page.locator("body").locator("div:visible").last + + option = listbox.locator("span", has_text=re.compile(r"^General$", re.I)).first + if option.count() == 0: + option = listbox.locator( + "div", has=page.locator("span", has_text=re.compile(r"^General$", re.I)) + ).first + if option.count() == 0 and env_bool("PW_DEBUG_DUMP"): + try: + listbox_text = listbox.inner_text() + except Exception: + listbox_text = "" + span_count = listbox.locator( + "span", has_text=re.compile(r"^General$", re.I) + ).count() + debug( + "[dataset] general_option_missing " + f"listbox_count={listbox.count()} span_count={span_count}" + ) + debug(f"[dataset] listbox_text_snippet={listbox_text[:300]!r}") + expect(option).to_be_visible(timeout=timeout_ms) + option.click() + if trigger_for_assert is not None: + try: + expect(trigger_for_assert).to_contain_text( + re.compile(r"General", re.I), timeout=timeout_ms + ) + except AssertionError: + # Trigger can rerender after selection; verify selected label in modal instead. + expect(modal).to_contain_text(re.compile(r"General", re.I), timeout=timeout_ms) + + +def open_create_dataset_modal(page, expect, timeout_ms: int): + """Open the create dataset modal from the datasets page.""" + wait_js = """ + () => { + const txt = (document.body && document.body.innerText || '').toLowerCase(); + if (txt.includes('no dataset created yet')) return true; + return Array.from(document.querySelectorAll('button')).some((b) => + (b.innerText || '').toLowerCase().includes('create dataset') + ); + } + """ + try: + page.wait_for_function(wait_js, timeout=timeout_ms) + except PlaywrightTimeoutError: + if env_bool("PW_DEBUG_DUMP"): + url = page.url + body_text = page.evaluate( + "(() => (document.body && document.body.innerText) || '')()" + ) + lines = body_text.splitlines() + snippet = "\n".join(lines[:20])[:500] + debug(f"[dataset] entrypoint_wait_timeout url={url} snippet={snippet!r}") + raise + + empty_text = page.locator("text=/no dataset created yet/i").first + if empty_text.count() > 0: + debug("[dataset] using empty-state entrypoint") + expect(empty_text).to_be_visible(timeout=5000) + element_handle = empty_text.element_handle() + if element_handle is None: + debug("[dataset] empty-state text element handle not available") + dump_clickable_candidates(page) + raise AssertionError("Empty-state text element not available for click.") + handle = page.evaluate_handle( + """ + (el) => { + const closest = el.closest('button, a, [role="button"]'); + if (closest) return closest; + let node = el; + for (let i = 0; i < 6 && node; i += 1) { + if (node.nodeType !== Node.ELEMENT_NODE) { + node = node.parentElement; + continue; + } + const element = node; + const hasOnClick = typeof element.onclick === 'function' || element.hasAttribute('onclick'); + const tabIndex = element.getAttribute('tabindex'); + const hasTab = tabIndex === '0'; + const cursor = window.getComputedStyle(element).cursor; + if (hasOnClick || hasTab || cursor === 'pointer') { + return element; + } + node = element.parentElement; + } + return null; + } + """, + element_handle, + ) + element = handle.as_element() + if element is None: + debug("[dataset] empty-state clickable ancestor not found") + dump_clickable_candidates(page) + raise AssertionError("No clickable ancestor found for empty dataset state.") + element.click() + else: + debug("[dataset] using create button entrypoint") + create_btn = None + if hasattr(page, "get_by_role"): + create_btn = page.get_by_role( + "button", name=re.compile(r"create dataset", re.I) + ) + if create_btn is None or create_btn.count() == 0: + create_btn = page.locator( + "button", has_text=re.compile(r"create dataset", re.I) + ).first + if create_btn.count() == 0: + if env_bool("PW_DEBUG_DUMP"): + url = page.url + body_text = page.evaluate( + "(() => (document.body && document.body.innerText) || '')()" + ) + lines = body_text.splitlines() + snippet = "\n".join(lines[:20])[:500] + debug(f"[dataset] entrypoint_not_found url={url} snippet={snippet!r}") + dump_clickable_candidates(page) + raise AssertionError("No dataset entrypoint found after readiness wait.") + debug(f"[dataset] create_button_count={create_btn.count()}") + try: + expect(create_btn).to_be_visible(timeout=5000) + except AssertionError: + if env_bool("PW_DEBUG_DUMP"): + url = page.url + body_text = page.evaluate( + "(() => (document.body && document.body.innerText) || '')()" + ) + lines = body_text.splitlines() + snippet = "\n".join(lines[:20])[:500] + debug(f"[dataset] entrypoint_not_found url={url} snippet={snippet!r}") + raise + create_btn.click() + + modal = page.locator("[role='dialog']").filter(has_text=re.compile("create dataset", re.I)) + expect(modal).to_be_visible(timeout=timeout_ms) + return modal + + +def delete_uploaded_file(page, expect, filename: str, timeout_ms: int) -> None: + """Delete a document row by filename and confirm the modal.""" + row = page.locator( + f"[data-testid='document-row'][data-doc-name={json.dumps(filename)}]" + ) + expect(row).to_be_visible(timeout=timeout_ms) + delete_button = row.locator("[data-testid='document-delete']") + expect(delete_button).to_be_visible(timeout=timeout_ms) + delete_button.click() + confirm = page.locator("[role='alertdialog']") + expect(confirm).to_be_visible() + confirm_delete = confirm.locator( + "button", has_text=re.compile("^delete$", re.I) + ).first + expect(confirm_delete).to_be_visible(timeout=timeout_ms) + try: + confirm_delete.click(timeout=timeout_ms) + except Exception: + # The confirm button can rerender during open/animation; reacquire and force. + confirm_delete = confirm.locator( + "button", has_text=re.compile("^delete$", re.I) + ).first + confirm_delete.click(timeout=timeout_ms, force=True) + expect(row).not_to_be_visible(timeout=timeout_ms) diff --git a/test/playwright/helpers/debug_utils.py b/test/playwright/helpers/debug_utils.py new file mode 100644 index 0000000000..3c79b170b8 --- /dev/null +++ b/test/playwright/helpers/debug_utils.py @@ -0,0 +1,6 @@ +from test.playwright.helpers.env_utils import env_bool + + +def debug(msg: str) -> None: + if env_bool("PW_DEBUG_DUMP"): + print(msg, flush=True) diff --git a/test/playwright/helpers/env_utils.py b/test/playwright/helpers/env_utils.py new file mode 100644 index 0000000000..88175ed5ec --- /dev/null +++ b/test/playwright/helpers/env_utils.py @@ -0,0 +1,18 @@ +import os + + +def env_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if not value: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def env_int(name: str, default: int) -> int: + value = os.getenv(name) + if not value: + return default + try: + return int(value) + except ValueError: + return default diff --git a/test/playwright/helpers/flow_context.py b/test/playwright/helpers/flow_context.py new file mode 100644 index 0000000000..719d141cf2 --- /dev/null +++ b/test/playwright/helpers/flow_context.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass +class FlowContext: + page: Any + state: dict + base_url: str + login_url: str + smoke_login_url: str | None = None + active_auth_context: Any | None = None + auth_click: Any | None = None + seeded_user_credentials: Any | None = None diff --git a/test/playwright/helpers/flow_steps.py b/test/playwright/helpers/flow_steps.py new file mode 100644 index 0000000000..da69374240 --- /dev/null +++ b/test/playwright/helpers/flow_steps.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Callable, Sequence + +import pytest + +StepFn = Callable[..., None] +Steps = Sequence[tuple[str, StepFn]] + + +def flow_params(steps: Steps): + return [pytest.param(step_fn, id=step_id) for step_id, step_fn in steps] + + +def require(flow_state: dict, *keys: str) -> None: + missing = [key for key in keys if not flow_state.get(key)] + if missing: + pytest.skip(f"Missing prerequisite: {', '.join(missing)}") diff --git a/test/playwright/helpers/model_providers.py b/test/playwright/helpers/model_providers.py new file mode 100644 index 0000000000..fd3bb9ee28 --- /dev/null +++ b/test/playwright/helpers/model_providers.py @@ -0,0 +1,268 @@ +import json +import re +from urllib.parse import urljoin + +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + +from test.playwright.helpers.debug_utils import debug +from test.playwright.helpers.response_capture import capture_response + + +def wait_for_path_prefix(page, prefix: str, timeout_ms: int) -> None: + """Wait until the URL path starts with the provided prefix.""" + prefix_json = json.dumps(prefix) + wait_js = f""" + () => {{ + const prefix = {prefix_json}; + const path = window.location.pathname || ''; + return path.startsWith(prefix); + }} + """ + page.wait_for_function(wait_js, timeout=timeout_ms) + + +def safe_close_modal(modal) -> None: + """Best-effort close for API key modal.""" + try: + api_input = modal.locator("input").first + if api_input.count() > 0: + api_input.fill("") + except Exception as exc: + debug(f"[model-providers] failed to clear api input: {exc}") + try: + cancel_button = modal.locator("button", has_text=re.compile("cancel", re.I)) + if cancel_button.count() > 0: + cancel_button.first.click() + return + except Exception as exc: + debug(f"[model-providers] cancel modal click failed: {exc}") + try: + close_button = modal.locator("button", has=modal.locator("svg")).first + if close_button.count() > 0: + close_button.click() + except Exception as exc: + debug(f"[model-providers] close modal click failed: {exc}") + + +def open_user_settings(page, base_url: str) -> None: + """Navigate to the user settings page with fallback paths.""" + entrypoint = page.locator("[data-testid='settings-entrypoint']") + if entrypoint.count() > 0: + entrypoint.first.click() + wait_for_path_prefix(page, "/user-setting", timeout_ms=5000) + return + + header = page.locator("section").filter(has=page.locator("img[alt='logo']")).first + candidates = [ + page.locator("a[href='/user-setting']"), + page.locator("text=User settings"), + header.locator("img:not([alt='logo'])"), + ] + + for candidate in candidates: + debug(f"[model-providers] settings candidate count={candidate.count()}") + if candidate.count() == 0: + continue + try: + candidate.first.click() + wait_for_path_prefix(page, "/user-setting", timeout_ms=5000) + return + except PlaywrightTimeoutError: + continue + except Exception as exc: + debug(f"[model-providers] settings click failed: {exc}") + + fallback_url = urljoin(base_url.rstrip("/") + "/", "/user-setting") + page.goto(fallback_url, wait_until="domcontentloaded") + wait_for_path_prefix(page, "/user-setting", timeout_ms=5000) + + +def needs_selection(combobox, option_text: str) -> bool: + """Return True when the combobox does not already show the option text.""" + current_text = combobox.inner_text().strip() + return option_text not in current_text + + +def click_with_retry(page, expect, locator_factory, attempts: int, timeout_ms: int) -> None: + """Click a locator with retries and visibility checks.""" + last_exc = None + for _ in range(attempts): + option = locator_factory() + try: + expect(option).to_be_attached(timeout=timeout_ms) + expect(option).to_be_visible(timeout=timeout_ms) + option.scroll_into_view_if_needed() + option.click() + return + except Exception as exc: + last_exc = exc + page.wait_for_timeout(100) + raise AssertionError(f"Click failed after {attempts} attempts: {last_exc}") + + +def select_cmdk_option_by_value_prefix( + page, + expect, + combobox, + value_prefix: str, + option_text: str, + list_testid: str, + fallback_to_first: bool, + timeout_ms: int, +) -> tuple[str, str | None]: + """Select a cmdk option by value prefix or option text.""" + combobox.click() + + controls_id = combobox.get_attribute("aria-controls") + options_container = None + option_selector = ( + "[data-testid='combobox-option'], [role='option'], [cmdk-item], [data-value]" + ) + + if controls_id: + controls_selector = f"[id={json.dumps(controls_id)}]:visible" + scoped = page.locator(controls_selector) + if scoped.count() > 0: + options_container = scoped.first + + if options_container is None and list_testid: + legacy_container = page.locator(f"[data-testid='{list_testid}']:visible") + if legacy_container.count() > 0: + options_container = legacy_container.first + + escaped_prefix = value_prefix.replace("'", "\\'") + value_selector = f"[data-value^='{escaped_prefix}']" + option_pattern = re.compile(rf"\b{re.escape(option_text)}\b", re.I) + + def options_locator(): + if options_container is not None: + return options_container.locator(option_selector) + return page.locator(option_selector) + + def option_locator(): + by_value = ( + options_container.locator(value_selector) + if options_container is not None + else page.locator(f"{value_selector}:visible") + ) + if by_value.count() > 0: + return by_value.first + return options_locator().filter(has_text=option_pattern).first + + expect(options_locator().first).to_be_visible(timeout=timeout_ms) + + option = option_locator() + if option.count() == 0: + options = options_locator() + if fallback_to_first and options.count() > 0: + first_option = options.first + selected_text = "" + selected_value = None + try: + selected_text = first_option.inner_text().strip() + except Exception: + selected_text = "" + try: + selected_value = first_option.get_attribute("data-value") + except Exception: + selected_value = None + click_with_retry(page, expect, lambda: first_option, attempts=3, timeout_ms=timeout_ms) + if selected_text: + expect(combobox).to_contain_text( + selected_text, timeout=timeout_ms + ) + try: + expect(combobox).to_have_attribute( + "aria-expanded", "false", timeout=timeout_ms + ) + except AssertionError: + page.keyboard.press("Escape") + expect(combobox).to_have_attribute( + "aria-expanded", "false", timeout=timeout_ms + ) + return selected_text or option_text, selected_value + dump = [] + count = min(options.count(), 30) + for i in range(count): + item = options.nth(i) + try: + text = item.inner_text().strip() + except Exception as exc: + text = f"" + try: + data_value = item.get_attribute("data-value") + except Exception as exc: + data_value = f"" + dump.append(f"{i + 1:02d}. text={text!r} data-value={data_value!r}") + dump_text = "\n".join(dump) + raise AssertionError( + "No matching cmdk option found. " + f"value_prefix={value_prefix!r} option_text={option_text!r} " + f"list_testid={list_testid!r} aria_controls={controls_id!r} " + f"options_count={options.count()}\n" + f"options:\n{dump_text}" + ) + + selected_text = option_text + try: + selected_text = option.inner_text().strip() or option_text + except Exception: + selected_text = option_text + selected_value = option.get_attribute("data-value") + click_with_retry(page, expect, option_locator, attempts=3, timeout_ms=timeout_ms) + expect(combobox).to_contain_text(selected_text, timeout=timeout_ms) + try: + expect(combobox).to_have_attribute("aria-expanded", "false", timeout=timeout_ms) + except AssertionError: + page.keyboard.press("Escape") + expect(combobox).to_have_attribute("aria-expanded", "false", timeout=timeout_ms) + return selected_text, selected_value + + +def select_default_model( + page, + expect, + combobox, + value_prefix: str, + option_text: str, + list_testid: str, + fallback_to_first: bool, + timeout_ms: int, +) -> tuple[str, str | None]: + """Select and persist a default model.""" + if not needs_selection(combobox, option_text): + try: + current_text = combobox.inner_text().strip() + except Exception: + current_text = option_text + return current_text, None + + selected = ("", None) + + def trigger(): + nonlocal selected + selected = select_cmdk_option_by_value_prefix( + page, + expect, + combobox, + value_prefix, + option_text, + list_testid, + fallback_to_first=fallback_to_first, + timeout_ms=timeout_ms, + ) + + try: + capture_response( + page, + trigger, + lambda resp: resp.request.method == "POST" + and "/v1/user/set_tenant_info" in resp.url, + ) + except PlaywrightTimeoutError: + if not selected[0]: + raise + + expected_text = selected[0] or option_text + expect(combobox).to_contain_text(expected_text, timeout=timeout_ms) + return selected diff --git a/test/playwright/helpers/response_capture.py b/test/playwright/helpers/response_capture.py new file mode 100644 index 0000000000..f7ad33c6f6 --- /dev/null +++ b/test/playwright/helpers/response_capture.py @@ -0,0 +1,39 @@ + +try: + from test.playwright.helpers._auth_helpers import RESULT_TIMEOUT_MS as DEFAULT_TIMEOUT_MS +except Exception: + # Fallback for standalone usage when helper constants are unavailable. + DEFAULT_TIMEOUT_MS = 30_000 + + +def capture_response(page, trigger, predicate, timeout_ms: int = DEFAULT_TIMEOUT_MS): + if hasattr(page, "expect_response"): + with page.expect_response(predicate, timeout=timeout_ms) as response_info: + trigger() + return response_info.value + if hasattr(page, "expect_event"): + with page.expect_event( + "response", predicate=predicate, timeout=timeout_ms + ) as response_info: + trigger() + return response_info.value + if hasattr(page, "wait_for_event"): + trigger() + return page.wait_for_event("response", predicate=predicate, timeout=timeout_ms) + raise RuntimeError("Playwright Page lacks expect_response/expect_event/wait_for_event.") + + +def capture_response_json( + page, trigger, predicate, timeout_ms: int = DEFAULT_TIMEOUT_MS +) -> dict: + response = capture_response(page, trigger, predicate, timeout_ms) + info: dict = {"__url__": response.url, "__status__": response.status} + try: + data = response.json() + if isinstance(data, dict): + info.update(data) + else: + info["__parse_error__"] = "non-dict response body" + except Exception as exc: + info["__parse_error__"] = str(exc) + return info