501 lines
22 KiB
Python
501 lines
22 KiB
Python
"""Phase 34 story E2E (Playwright): ONE navbar on every page.
|
|
|
|
Story: ``.agent/user_stories/nav-consistency.md``
|
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
|
|
|
uv run pytest tests/e2e/test_nav_consistency.py -v --no-cov
|
|
|
|
TODO.md L3 (owner 2026-08-26): "I want the navbar to be consistent
|
|
between every page. I don't want buttons to pop in and out of existance.
|
|
Just keep all those buttons active across all tabs."
|
|
|
|
Contract under test — the header is IDENTICAL on all five pages (chat,
|
|
sources, document viewer, global tuning, login): one shared markup block
|
|
(phase 34 task 03), one owner of all control behavior (header.js, tasks
|
|
01/02), the viewer's back + title preserved in a second titlebar row
|
|
(task 04), and the phase-12/19 height contract (64px desktop / 58px at
|
|
≤640px) on the standard row everywhere.
|
|
|
|
Per role, the VISIBLE inventory:
|
|
|
|
* admin: brand + nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning]
|
|
(four links, that order — the Git sources link joined in phase 35,
|
|
owner permission 2026-08-26) + #steering-toggle + #sync-btn +
|
|
#new-chat-btn + #sign-out-btn (with #sign-in-link hidden) — on all
|
|
five pages, same id+class inventory, same DOM order;
|
|
* anonymous: brand + nav [Chat] (#nav-sources / #nav-git-sources /
|
|
#nav-tuning hidden — locked A10 UI revision) + #new-chat-btn +
|
|
#sign-in-link (with #sync-btn hidden, #sign-out-btn hidden) on all
|
|
five pages — and the steering toggle + panel are ABSENT from the DOM
|
|
(phase 16 "absent, not hidden" treatment, carried into phase 34 task
|
|
01; test_admin_auth pins it).
|
|
|
|
Normalization for the inventory comparison: the current-page ``is-active``
|
|
nav marker and the sign-in ``?next=`` value legitimately differ per page,
|
|
so both are stripped (the href is compared by pathname only).
|
|
|
|
Viewer specifics: row 1 (the standard bar) is exactly as tall as the chat
|
|
page's bar (64px / 58px) and row 2 (``.doc-titlebar``) is present with
|
|
#doc-back + #doc-title + #doc-meta badges; #doc-back target resolution
|
|
(phase 13) is honored — ``back=`` accepted for same-origin relative
|
|
URLs, rejected (→ /sources.html) otherwise.
|
|
|
|
Steering works off-chat: on /tuning.html (admin, zero notes) the toggle
|
|
opens/closes #steering-panel with the empty state and a 0 count badge —
|
|
no chat needed. Sync is present, not triggered: #sync-btn is visible on
|
|
/tuning.html but is never clicked here (a real sync clones real repos —
|
|
the full state machine is test_sync_button.py's job).
|
|
|
|
Determinism note: every assertion is settled-state — each page visit
|
|
first waits for the whoami toggle to land (exactly one of Sign in /
|
|
Sign out visible; the anonymous removal of the steering toggle happens
|
|
in the SAME initSharedHeader pass) and, on the viewer, for the document
|
|
title to render. The seed truncates steering_notes, so the count badge is
|
|
0 on every admin page. No chat turn is ever submitted; #sync-btn is
|
|
never clicked.
|
|
|
|
Test → story mapping (Playwright Mapping Rule):
|
|
1. ``test_admin_inventory_identical_on_all_five_pages``
|
|
2. ``test_anonymous_inventory_identical_on_all_five_pages``
|
|
3. ``test_viewer_row1_height_matches_chat_and_titlebar_present``
|
|
4. ``test_viewer_back_link_honors_back_param``
|
|
5. ``test_steering_panel_works_off_chat_on_tuning_page``
|
|
6. ``test_sync_button_present_on_tuning_page_without_triggering``
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
from pathlib import Path
|
|
from threading import Thread
|
|
from typing import Any
|
|
|
|
from playwright.sync_api import Page, expect
|
|
from sqlalchemy import text
|
|
|
|
from app.config import Settings
|
|
from app.db import SessionLocal
|
|
from app.rag.importer import ImportSummary, import_sources
|
|
from app.rag.llm import LLMClient
|
|
from e2e.auth_helpers import login
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
|
|
|
#: The five pages of the app (acceptance criterion 2 of the story).
|
|
CHAT_URL = "/"
|
|
SOURCES_URL = "/sources.html"
|
|
TUNING_URL = "/tuning.html"
|
|
LOGIN_URL = "/login.html"
|
|
#: A seeded fixture doc (source=docs), URL-encoded — the same document
|
|
#: every viewer suite uses (title "Kubernetes Homelab Cluster").
|
|
VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md"
|
|
DOC_TITLE = "Kubernetes Homelab Cluster"
|
|
|
|
#: The phase-12/19 pinned bar heights (frontend/assets/styles.css
|
|
#: --header-h, desktop and ≤640px).
|
|
DESKTOP_HEADER_H = 64
|
|
MOBILE_HEADER_H = 58
|
|
|
|
|
|
async def _import_fixtures(mock_port: int) -> ImportSummary:
|
|
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
|
|
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
|
return await import_sources([FIXTURES], LLMClient(settings))
|
|
|
|
|
|
def _run_in_thread(coro: Any) -> Any:
|
|
"""Run a coroutine on a worker thread (Playwright owns the test loop)."""
|
|
box: dict[str, Any] = {}
|
|
|
|
def runner() -> None:
|
|
try:
|
|
box["value"] = asyncio.run(coro)
|
|
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
|
|
box["error"] = e
|
|
|
|
t = Thread(target=runner)
|
|
t.start()
|
|
t.join()
|
|
if "error" in box:
|
|
raise box["error"]
|
|
return box["value"]
|
|
|
|
|
|
def _seed_db(mock_port: int) -> None:
|
|
"""Fresh KB + ZERO steering notes (deterministic count badge on
|
|
every admin page) + the fixture docs for the viewer URL."""
|
|
with SessionLocal() as db:
|
|
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
|
db.commit()
|
|
_run_in_thread(_import_fixtures(mock_port))
|
|
|
|
|
|
def _box_height(page: Page, selector: str) -> float:
|
|
box = page.locator(selector).bounding_box()
|
|
assert box is not None, f"{selector} not rendered"
|
|
return box["height"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The heart of the suite: the normalized header-control inventory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
#: The header controls, in their shipped DOM order. The inventory is
|
|
#: normalized per the task: the current-page ``is-active`` nav marker is
|
|
#: stripped from the class list, and anchor hrefs are compared by
|
|
#: pathname only (the sign-in ``?next=`` value legitimately differs per
|
|
#: page — it is rewritten to the current page by header.js).
|
|
_INVENTORY_JS = """() => {
|
|
const inner = document.querySelector("header .header-inner");
|
|
if (!inner) return null;
|
|
const sel = [
|
|
".brand",
|
|
".app-nav > a.nav-link",
|
|
"#steering-toggle",
|
|
"#sync-btn",
|
|
"#new-chat-btn",
|
|
"#sign-in-link",
|
|
"#sign-out-btn",
|
|
].join(",");
|
|
return [...inner.querySelectorAll(sel)].map((el) => {
|
|
const classes = [...el.classList].filter((c) => c !== "is-active");
|
|
const id = el.id ? "#" + el.id : "";
|
|
const href = el.tagName === "A" ? (el.getAttribute("href") || "").split("?")[0] : "";
|
|
const text = (el.textContent || "").replace(/\\s+/g, " ").trim();
|
|
return el.tagName.toLowerCase() + id + "." + classes.join(".") + "::" + text + "::" + href;
|
|
});
|
|
}"""
|
|
|
|
|
|
def _header_inventory(page: Page) -> list[str]:
|
|
"""The ordered id+class inventory of the header controls on the page
|
|
``page`` is showing (normalized — see _INVENTORY_JS)."""
|
|
inv = page.evaluate(_INVENTORY_JS)
|
|
assert inv is not None, "no `header .header-inner` on this page"
|
|
assert len(inv) >= 8, f"header control inventory unexpectedly short: {inv}"
|
|
return inv
|
|
|
|
|
|
def _wait_settled(page: Page, admin: bool) -> None:
|
|
"""Wait for initSharedHeader's whoami toggle to land: exactly one of
|
|
Sign in / Sign out is visible (both ship hidden in the HTML). For
|
|
anonymous visitors the steering toggle + panel removal happens in
|
|
the SAME pass, so they are already gone when this returns."""
|
|
if admin:
|
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
|
else:
|
|
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
|
|
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
|
|
|
|
|
def _assert_landmarks(page: Page, label: str) -> None:
|
|
"""UI Structure Check (AGENTS.md rule 5): the page's landmarks — a
|
|
<header>, the labeled <nav>, and <main> — survive on every page."""
|
|
assert page.locator("header").count() >= 1, f"{label}: no <header> landmark"
|
|
assert page.locator('nav[aria-label="Primary"]').count() == 1, (
|
|
f"{label}: no labeled <nav aria-label> landmark"
|
|
)
|
|
assert page.locator("main").count() >= 1, f"{label}: no <main> landmark"
|
|
|
|
|
|
def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[str]:
|
|
"""Goto a page, wait for the settled header state (+ the document on
|
|
the viewer), check the per-role visible inventory, and return the
|
|
normalized control inventory."""
|
|
page.goto(app_url + url)
|
|
_wait_settled(page, admin=admin)
|
|
if name == "viewer":
|
|
# The document itself has settled (rendered, not Loading…/
|
|
# not-found) so the bar is measured on the real page.
|
|
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
|
|
|
# The per-role VISIBLE inventory (the story: no button pops in or
|
|
# out because of which page you are on).
|
|
expect(page.locator(".app-nav a[href='/']")).to_be_visible() # Chat
|
|
if admin:
|
|
expect(page.locator("#nav-sources")).to_be_visible()
|
|
# Phase 35: the fourth admin-only nav link (Git sources) is
|
|
# revealed on every page, between Sources and Tuning.
|
|
expect(page.locator("#nav-git-sources")).to_be_visible()
|
|
expect(page.locator("#nav-tuning")).to_be_visible()
|
|
expect(page.locator("#steering-toggle")).to_be_visible()
|
|
expect(page.locator("#sync-btn")).to_be_visible()
|
|
expect(page.locator("#sign-out-btn")).to_be_visible()
|
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
|
else:
|
|
# Locked A10 UI revision: admin-only links ship hidden, never
|
|
# revealed for anonymous…
|
|
expect(page.locator("#nav-sources")).to_be_hidden()
|
|
expect(page.locator("#nav-git-sources")).to_be_hidden()
|
|
expect(page.locator("#nav-tuning")).to_be_hidden()
|
|
expect(page.locator("#sync-btn")).to_be_hidden()
|
|
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
|
expect(page.locator("#sign-in-link")).to_be_visible()
|
|
# …and the steering surface is ABSENT (phase 16 "absent, not
|
|
# hidden", carried into the shared module by phase 34 task 01
|
|
# — test_admin_auth pins the same contract).
|
|
assert page.locator("#steering-toggle").count() == 0, (
|
|
f"{name}: the steering toggle must be absent for anonymous"
|
|
)
|
|
assert page.locator("#steering-panel").count() == 0, (
|
|
f"{name}: the steering panel must be absent for anonymous"
|
|
)
|
|
expect(page.locator("#new-chat-btn")).to_be_visible()
|
|
|
|
_assert_landmarks(page, name)
|
|
return _header_inventory(page)
|
|
|
|
|
|
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
|
|
|
|
|
|
def _admin_login_page_inventory(page: Page, app_url: str) -> list[str]:
|
|
"""The login page's header IN THE ADMIN STATE. A signed-in admin is
|
|
redirected off the login form by login.js (``location.replace`` →
|
|
the default next, /sources.html — phase 16, pinned by
|
|
test_admin_auth), so this ONE visit serves the page script with the
|
|
redirect lines suppressed (a test-local route on the login.js
|
|
script; the page's header — settled by the same initSharedHeader
|
|
pass — is what gets measured, and the page stays put).
|
|
|
|
The browser cache is cleared first: phase 33 caches ``/assets/*``
|
|
``immutable`` for a year, and the earlier form login already fetched
|
|
the (unmodified) login.js — a cache hit would bypass the route.
|
|
"""
|
|
login_js = (REPO / "frontend" / "assets" / "login.js").read_text(encoding="utf-8")
|
|
assert "window.location.replace(safeNext())" in login_js
|
|
suppressed = login_js.replace(
|
|
"window.location.replace(safeNext())",
|
|
"window.__e2e_redirectSuppressed = true; // test: observe the header",
|
|
)
|
|
page.route(
|
|
LOGIN_JS_ROUTE,
|
|
lambda route: route.fulfill(
|
|
status=200, content_type="text/javascript", body=suppressed
|
|
),
|
|
)
|
|
try:
|
|
cdp = page.context.new_cdp_session(page)
|
|
try:
|
|
cdp.send("Network.clearBrowserCache")
|
|
finally:
|
|
cdp.detach()
|
|
page.goto(app_url + LOGIN_URL)
|
|
expect(page).to_have_url(app_url + LOGIN_URL, timeout=15_000)
|
|
_wait_settled(page, admin=True)
|
|
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
|
|
expect(page.locator("#nav-sources")).to_be_visible()
|
|
expect(page.locator("#nav-git-sources")).to_be_visible()
|
|
expect(page.locator("#nav-tuning")).to_be_visible()
|
|
expect(page.locator("#steering-toggle")).to_be_visible()
|
|
expect(page.locator("#sync-btn")).to_be_visible()
|
|
expect(page.locator("#sign-out-btn")).to_be_visible()
|
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
|
expect(page.locator("#new-chat-btn")).to_be_visible()
|
|
_assert_landmarks(page, "login")
|
|
return _header_inventory(page)
|
|
finally:
|
|
page.unroute(LOGIN_JS_ROUTE)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Admin: the same visible controls, same inventory, same DOM order,
|
|
# on all five pages
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_admin_inventory_identical_on_all_five_pages(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
page.set_viewport_size({"width": 1280, "height": 800})
|
|
_seed_db(mock_llm)
|
|
|
|
login(page, app_url, next=CHAT_URL)
|
|
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
|
|
|
|
inventories: dict[str, list[str]] = {}
|
|
for name, url in (
|
|
("chat", CHAT_URL),
|
|
("sources", SOURCES_URL),
|
|
("viewer", VIEWER_URL),
|
|
("tuning", TUNING_URL),
|
|
):
|
|
inventories[name] = _visit(page, app_url, name, url, admin=True)
|
|
# The login page redirects a signed-in admin away — measure it with
|
|
# the redirect aborted (see the helper).
|
|
inventories["login"] = _admin_login_page_inventory(page, app_url)
|
|
|
|
reference = inventories["chat"]
|
|
for name, inv in inventories.items():
|
|
assert inv == reference, (
|
|
f"admin header control inventory differs on {name}:\n"
|
|
f" chat: {reference}\n {name}: {inv}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Anonymous: the reduced bar — identically — on all five pages
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_anonymous_inventory_identical_on_all_five_pages(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
page.set_viewport_size({"width": 1280, "height": 800})
|
|
_seed_db(mock_llm)
|
|
|
|
# No login: a fresh context is anonymous by construction.
|
|
inventories: dict[str, list[str]] = {}
|
|
for name, url in (
|
|
("chat", CHAT_URL),
|
|
("sources", SOURCES_URL),
|
|
("viewer", VIEWER_URL),
|
|
("tuning", TUNING_URL),
|
|
("login", LOGIN_URL),
|
|
):
|
|
inventories[name] = _visit(page, app_url, name, url, admin=False)
|
|
|
|
reference = inventories["chat"]
|
|
for name, inv in inventories.items():
|
|
assert inv == reference, (
|
|
f"anonymous header control inventory differs on {name}:\n"
|
|
f" chat: {reference}\n {name}: {inv}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Viewer: row 1 is exactly the chat bar's height (64px / 58px) and
|
|
# the titlebar row (back + title + meta) is present
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_viewer_row1_height_matches_chat_and_titlebar_present(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
_seed_db(mock_llm)
|
|
|
|
for width, expected_h in ((1280, DESKTOP_HEADER_H), (375, MOBILE_HEADER_H)):
|
|
page.set_viewport_size({"width": width, "height": 800})
|
|
|
|
page.goto(app_url + CHAT_URL)
|
|
chat_h = _box_height(page, ".app-header")
|
|
|
|
page.goto(app_url + VIEWER_URL)
|
|
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
|
# Row 1 IS the standard bar — the same --header-h as chat…
|
|
row1 = _box_height(page, ".doc-header .app-header")
|
|
assert row1 == expected_h, f"viewer row 1 is {row1}px at {width}px"
|
|
assert chat_h == expected_h, f"chat bar is {chat_h}px at {width}px"
|
|
assert row1 == chat_h, "viewer row 1 must match the chat bar exactly"
|
|
# …and the titlebar row exists below it (content-sized, > 0)…
|
|
titlebar = _box_height(page, ".doc-titlebar")
|
|
assert titlebar > 0, "the .doc-titlebar row is not rendered"
|
|
# …with the back link + the rendered title + the meta badges.
|
|
expect(page.locator("#doc-back")).to_be_visible()
|
|
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
|
|
expect(page.locator("#doc-meta .doc-source-badge", has_text="docs")).to_be_visible()
|
|
expect(page.locator("#doc-meta .format-badge", has_text="md")).to_be_visible()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Viewer: #doc-back target resolution (phase 13) — one positive, one
|
|
# rejection case, both by clicking the link
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_viewer_back_link_honors_back_param(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
page.set_viewport_size({"width": 1280, "height": 800})
|
|
_seed_db(mock_llm)
|
|
|
|
# Positive: back=/ (same-origin relative) is honored — href "/",
|
|
# label "Chat", and the click returns to the chat page.
|
|
page.goto(app_url + VIEWER_URL + "&back=%2F")
|
|
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
|
back = page.locator("#doc-back")
|
|
expect(back).to_have_attribute("href", "/")
|
|
expect(back.locator("span")).to_have_text("Chat")
|
|
back.click()
|
|
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
|
|
|
|
# Rejection: an absolute URL is NOT same-origin-relative — the
|
|
# target falls back to the Sources page (label "Sources") and the
|
|
# click goes there.
|
|
page.goto(app_url + VIEWER_URL + "&back=https%3A%2F%2Fevil.example")
|
|
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
|
back = page.locator("#doc-back")
|
|
expect(back).to_have_attribute("href", "/sources.html")
|
|
expect(back.locator("span")).to_have_text("Sources")
|
|
back.click()
|
|
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Steering works off-chat: on /tuning.html (admin, zero notes) the
|
|
# header toggle drives the panel — open/close cycle, empty state,
|
|
# count badge 0. No chat turn is needed.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_steering_panel_works_off_chat_on_tuning_page(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
page.set_viewport_size({"width": 1280, "height": 800})
|
|
_seed_db(mock_llm) # truncates steering_notes → zero notes
|
|
|
|
login(page, app_url, next=TUNING_URL)
|
|
expect(page).to_have_url(app_url + TUNING_URL, timeout=30_000)
|
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
|
|
|
# Settled admin state: the toggle is on the bar, the panel ships
|
|
# hidden, and the count badge reads 0 (zero seeded notes).
|
|
expect(page.locator("#steering-toggle")).to_be_visible()
|
|
expect(page.locator("#steering-panel")).to_be_hidden()
|
|
expect(page.locator("#steering-count")).to_have_text("0")
|
|
|
|
# Open: the panel shows, the toggle's aria-expanded follows, and
|
|
# the empty state is visible (the re-open refresh fetched 0 notes).
|
|
page.click("#steering-toggle")
|
|
expect(page.locator("#steering-panel")).to_be_visible()
|
|
expect(page.locator("#steering-toggle")).to_have_attribute("aria-expanded", "true")
|
|
expect(page.locator("#steering-empty")).to_be_visible()
|
|
expect(page.locator("#steering-list .steering-note")).to_have_count(0)
|
|
expect(page.locator("#steering-count")).to_have_text("0")
|
|
|
|
# Close: the cycle completes, the count badge still reads 0.
|
|
page.click("#steering-toggle")
|
|
expect(page.locator("#steering-panel")).to_be_hidden()
|
|
expect(page.locator("#steering-toggle")).to_have_attribute("aria-expanded", "false")
|
|
expect(page.locator("#steering-count")).to_have_text("0")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Sync is present (admin) on a non-Sources page — and is NOT
|
|
# triggered: a real sync clones real repos; the full state machine
|
|
# is test_sync_button.py's job.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sync_button_present_on_tuning_page_without_triggering(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
page.set_viewport_size({"width": 1280, "height": 800})
|
|
_seed_db(mock_llm)
|
|
|
|
login(page, app_url, next=TUNING_URL)
|
|
expect(page).to_have_url(app_url + TUNING_URL, timeout=30_000)
|
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
|
|
|
btn = page.locator("#sync-btn")
|
|
expect(btn).to_be_visible(timeout=15_000)
|
|
# Idle, retry-ready state — the boot re-attach (GET /api/sync/status,
|
|
# idle on the fresh app) must not have left it busy or labeled as a
|
|
# finished run.
|
|
expect(btn).to_be_enabled()
|
|
assert btn.get_attribute("aria-busy") is None, "a fresh idle sync must not be busy"
|
|
expect(page.locator("#sync-label")).to_have_text("Sync sources")
|
|
# Deliberately NOT clicked.
|