Files
ragflow/test/playwright/helpers/_next_apps_helpers.py
Idriss Sbaaoui 2f4ca38adf Fix : make playwright tests idempotent (#13332)
### What problem does this PR solve?

Playwright tests previously depended on cross-file execution order
(`auth -> provider -> dataset -> chat`).
This change makes setup explicit and idempotent via fixtures so tests
can run independently.

- Added/standardized prerequisite fixtures in
`test/playwright/conftest.py`:
- `ensure_auth_context`, `ensure_model_provider_configured`,
`ensure_dataset_ready`, `ensure_chat_ready`
- Made provisioning reusable/idempotent with `RUN_ID`-based resource
naming.
- Synced auth envs (`E2E_ADMIN_EMAIL`, `E2E_ADMIN_PASSWORD`) into seeded
creds.
- Fixed provider cache freshness (`auth_header`/`page` refresh on cache
hit).

Also included minimal stability fixes:
- dataset create stale-element click handling,
- search wait logic for results/empty-state,
- agent create-menu handling,
- agent run-step retry when run UI doesn’t open first click.

### Type of change

- [x] Test fix
- [x] Refactoring

---------

Co-authored-by: Liu An <asiro@qq.com>
2026-03-04 10:07:14 +08:00

460 lines
16 KiB
Python

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()
if modal_testid == "agent-create-modal":
menu = page.locator("[data-testid='agent-create-menu']")
if menu.count() > 0 and menu.first.is_visible():
create_blank = menu.locator("text=/create from blank/i")
if create_blank.count() > 0 and create_blank.first.is_visible():
create_blank.first.click()
else:
first_item = menu.locator("[role='menuitem']").first
expect(first_item).to_be_visible(timeout=RESULT_TIMEOUT_MS)
first_item.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 _search_query_input(page):
candidates = [
page.locator("[data-testid='search-query-input']"),
page.locator("input[placeholder*='How can I help you today']"),
page.locator("input[placeholder*='help you today']"),
]
for candidate in candidates:
if candidate.count() > 0:
return candidate.first
return page.locator("input[type='text']").first
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}"
)