various fixes
This commit is contained in:
@@ -213,14 +213,17 @@ def test_admin_login_unlocks_sources_and_tuning(
|
||||
expect(page.locator("#docs-table")).to_be_visible()
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(13)
|
||||
|
||||
# Chat: the tuning UI is back — header toggle with count badge,
|
||||
# Sign out instead of Sign in, Tune under the answer.
|
||||
# Chat: the tuning UI is back — Sign out instead of Sign in, Tune
|
||||
# under the answer. The header toggle is NOT back: removed from the
|
||||
# navbar at owner request (2026-08-28), the panel still ships
|
||||
# hidden (note management lives on /tuning.html).
|
||||
page.goto(app_url)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||
toggle = page.locator("#steering-toggle")
|
||||
expect(toggle).to_be_visible()
|
||||
expect(page.locator("#steering-count")).to_have_text("0")
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
"the steering toggle was removed from the navbar (2026-08-28)"
|
||||
)
|
||||
expect(page.locator("#steering-panel")).to_be_hidden()
|
||||
|
||||
_ask(page, QUESTION)
|
||||
tune = page.locator(".msg.brain .tune-btn").last
|
||||
@@ -249,7 +252,10 @@ def test_logout_returns_to_anonymous(
|
||||
login(page, app_url, next="/") # straight into the chat
|
||||
expect(page).to_have_url(app_url + "/")
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||
expect(page.locator("#steering-toggle")).to_be_visible()
|
||||
# The steering toggle was removed from the navbar (2026-08-28) —
|
||||
# absent for the admin too; the panel still ships hidden.
|
||||
assert page.locator("#steering-toggle").count() == 0
|
||||
expect(page.locator("#steering-panel")).to_be_hidden()
|
||||
|
||||
# One grounded turn as admin (persisted to localStorage by phase 14).
|
||||
_ask(page, QUESTION)
|
||||
|
||||
@@ -20,15 +20,18 @@ 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;
|
||||
owner permission 2026-08-26) + #sync-btn + #new-chat-btn +
|
||||
#sign-out-btn (with #sign-in-link hidden) — on all five pages, same
|
||||
id+class inventory, same DOM order. The #steering-toggle was removed
|
||||
from the navbar at owner request (2026-08-28); note management lives
|
||||
on /tuning.html;
|
||||
* 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).
|
||||
five pages — and the steering toggle (removed at owner request,
|
||||
2026-08-28) + panel are ABSENT from the DOM (the panel via the 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,
|
||||
@@ -40,26 +43,28 @@ page's bar (64px / 58px) and row 2 (``.doc-titlebar``) is present with
|
||||
(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).
|
||||
Steering off-chat: on /tuning.html (admin, zero notes) the navbar
|
||||
carries no steering toggle (removed at owner request, 2026-08-28) — the
|
||||
header #steering-panel section still ships hidden and the Tuning
|
||||
page's own note list shows the empty state — 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
|
||||
Sign out visible; the anonymous removal of the steering panel 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.
|
||||
title to render. The seed truncates steering_notes, so the Tuning page
|
||||
shows the empty state 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``
|
||||
5. ``test_steering_surface_off_chat_on_tuning_page``
|
||||
6. ``test_sync_button_present_on_tuning_page_without_triggering``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -152,7 +157,6 @@ _INVENTORY_JS = """() => {
|
||||
const sel = [
|
||||
".brand",
|
||||
".app-nav > a.nav-link",
|
||||
"#steering-toggle",
|
||||
"#sync-btn",
|
||||
"#new-chat-btn",
|
||||
"#sign-in-link",
|
||||
@@ -180,8 +184,8 @@ def _header_inventory(page: Page) -> list[str]:
|
||||
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."""
|
||||
anonymous visitors the steering panel removal happens in the SAME
|
||||
pass, so it is 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()
|
||||
@@ -220,7 +224,11 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
# 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()
|
||||
# The steering toggle was removed from the navbar at owner
|
||||
# request (2026-08-28) — absent on every page, admin included.
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
f"{name}: the steering toggle was removed from the navbar"
|
||||
)
|
||||
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()
|
||||
@@ -233,9 +241,11 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
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).
|
||||
# …and the steering surface is ABSENT (the toggle was removed
|
||||
# from the navbar at owner request, 2026-08-28; the panel via
|
||||
# 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"
|
||||
)
|
||||
@@ -289,7 +299,9 @@ def _admin_login_page_inventory(page: Page, app_url: str) -> list[str]:
|
||||
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()
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
"login: the steering toggle was removed from the navbar"
|
||||
)
|
||||
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()
|
||||
@@ -434,13 +446,14 @@ def test_viewer_back_link_honors_back_param(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# 5. Steering off-chat: on /tuning.html (admin, zero notes) the navbar
|
||||
# carries no steering toggle (removed at owner request, 2026-08-28)
|
||||
# — the header panel section still ships hidden and the Tuning
|
||||
# page's own note list shows the empty state. No chat turn needed.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_steering_panel_works_off_chat_on_tuning_page(
|
||||
def test_steering_surface_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})
|
||||
@@ -450,26 +463,16 @@ def test_steering_panel_works_off_chat_on_tuning_page(
|
||||
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()
|
||||
# Settled admin state: the navbar toggle is GONE (removed at owner
|
||||
# request, 2026-08-28), the header panel section still ships
|
||||
# hidden, and the Tuning page's own note list shows the empty
|
||||
# state (zero seeded notes).
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
"the steering toggle was removed from the navbar"
|
||||
)
|
||||
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")
|
||||
expect(page.locator("#tune-list .tuning-note")).to_have_count(0)
|
||||
expect(page.locator("#tune-empty")).to_be_visible()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+52
-62
@@ -13,8 +13,9 @@ stored in Postgres (``steering_notes``) → injected into the system prompt
|
||||
of every subsequent turn as the ``<tuning>`` section. The mock LLM
|
||||
echoes the first tuning note into its answer
|
||||
(`` (tuning: <first note line>)``), so prompt injection is observable in
|
||||
the UI deterministically. Notes are listed newest-first in the header
|
||||
"Tuning" panel, where each can be deleted.
|
||||
the UI deterministically. Notes are listed newest-first on the Tuning
|
||||
page (``/tuning.html``), where each can be deleted — the header "Tuning"
|
||||
toggle was removed from the navbar at owner request (2026-08-28).
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_tune_under_answer_persists_and_steers``
|
||||
@@ -45,8 +46,9 @@ QUESTION = "How is my Kubernetes cluster set up?"
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
NOTE = "STEEER-MARKER be concise"
|
||||
XSS_NOTE = "<script>window.__xss = true; alert('xss')</script>"
|
||||
#: index.html ships exactly three classic/module script tags: the
|
||||
#: phase-39 brand.js classic layer + markdown.js + the app.js module.
|
||||
#: Both index.html and tuning.html ship exactly three classic/module
|
||||
#: script tags: the phase-39 brand.js classic layer + markdown.js + the
|
||||
#: page module (app.js / tuning.js).
|
||||
BASE_SCRIPT_COUNT = 3
|
||||
|
||||
|
||||
@@ -113,11 +115,6 @@ def _tune_and_save(page: Page, note: str) -> None:
|
||||
expect(saved).to_contain_text("Saved — future answers will follow this.", timeout=15_000)
|
||||
|
||||
|
||||
def _open_panel(page: Page) -> None:
|
||||
page.click("#steering-toggle")
|
||||
expect(page.locator("#steering-panel")).to_be_visible()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Tune under an answer → persisted → next answer carries the note
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -147,14 +144,15 @@ def test_tune_under_answer_persists_and_steers(
|
||||
rows = db.scalars(select(SteeringNote)).all()
|
||||
assert [r.note for r in rows] == [NOTE]
|
||||
|
||||
# The header panel shows the note with an updated count badge.
|
||||
_open_panel(page)
|
||||
expect(page.locator("#steering-count")).to_have_text("1")
|
||||
expect(page.locator("#steering-list .steering-note")).to_have_count(1)
|
||||
expect(page.locator("#steering-list .steering-note-text")).to_have_text(NOTE)
|
||||
page.click("#steering-toggle") # close again
|
||||
# The Tuning page (the steering-notes manager — the navbar toggle
|
||||
# was removed at owner request, 2026-08-28) lists the note.
|
||||
page.goto(app_url + "/tuning.html")
|
||||
expect(page.locator("#tune-list .tuning-note")).to_have_count(1)
|
||||
expect(page.locator("#tune-list .tuning-note-text")).to_have_text(NOTE)
|
||||
|
||||
# The NEXT answer carries the note — it reached the system prompt.
|
||||
page.goto(app_url + "/")
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
_ask(page, QUESTION)
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).to_contain_text(f"(tuning: {NOTE})")
|
||||
@@ -179,19 +177,21 @@ def test_delete_note_stops_steering(
|
||||
_ask(page, QUESTION)
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(f"(tuning: {NOTE})")
|
||||
|
||||
# Delete the note from the panel.
|
||||
_open_panel(page)
|
||||
expect(page.locator("#steering-count")).to_have_text("1")
|
||||
page.locator("#steering-list .steering-delete").click()
|
||||
expect(page.locator("#steering-list .steering-note")).to_have_count(0)
|
||||
expect(page.locator("#steering-count")).to_have_text("0")
|
||||
expect(page.locator("#steering-empty")).to_be_visible()
|
||||
expect(page.locator("#steering-announcer")).to_contain_text("deleted")
|
||||
# Delete the note from the Tuning page (the header panel is no
|
||||
# longer reachable — the navbar toggle is gone).
|
||||
page.goto(app_url + "/tuning.html")
|
||||
expect(page.locator("#tune-list .tuning-note")).to_have_count(1)
|
||||
page.locator(".tuning-delete").click()
|
||||
expect(page.locator("#tune-list .tuning-note")).to_have_count(0, timeout=15_000)
|
||||
expect(page.locator("#tune-empty")).to_be_visible()
|
||||
expect(page.locator("#tune-announcer")).to_contain_text("deleted")
|
||||
|
||||
with SessionLocal() as db:
|
||||
assert db.scalars(select(SteeringNote)).all() == []
|
||||
|
||||
# The next answer no longer carries the marker.
|
||||
page.goto(app_url + "/")
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
_ask(page, QUESTION)
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER)
|
||||
@@ -222,12 +222,13 @@ def test_note_rendered_as_text_xss_safe(
|
||||
_ask(page, QUESTION)
|
||||
_tune_and_save(page, XSS_NOTE)
|
||||
|
||||
# Panel: the payload is visible as LITERAL text…
|
||||
_open_panel(page)
|
||||
expect(page.locator("#steering-list .steering-note-text")).to_have_text(XSS_NOTE)
|
||||
# Tuning page: the payload is visible as LITERAL text…
|
||||
page.goto(app_url + "/tuning.html")
|
||||
expect(page.locator("#tune-list .tuning-note-text")).to_have_text(XSS_NOTE)
|
||||
|
||||
# …never as an executed element: no script tag anywhere, no dialog.
|
||||
assert page.locator("#steering-panel script").count() == 0
|
||||
# …never as an executed element: no script tag in the list, the page
|
||||
# still carries exactly its own three scripts, no dialog.
|
||||
assert page.locator("#tune-list script").count() == 0
|
||||
expect(page.locator("script")).to_have_count(BASE_SCRIPT_COUNT)
|
||||
assert dialogs == [], f"the note must never execute as script: {dialogs}"
|
||||
assert page.evaluate("() => window.__xss === undefined") is True
|
||||
@@ -239,34 +240,27 @@ def test_note_rendered_as_text_xss_safe(
|
||||
|
||||
|
||||
def test_tuning_panel_a11y(page: Page, app_url: str, db_ready: None) -> None:
|
||||
_reset_db(mock_port=0, seed=False) # no KB seeding needed for the panel a11y
|
||||
"""The steering surface's a11y now lives on the Tuning page (the
|
||||
header toggle was removed from the navbar at owner request,
|
||||
2026-08-28): the note list, the polite live region, and the labeled
|
||||
per-note delete (≥44px)."""
|
||||
_reset_db(mock_port=0, seed=False) # no KB seeding needed for the page a11y
|
||||
page.set_default_timeout(30_000)
|
||||
page.goto(app_url)
|
||||
login(page, app_url, next="/") # phase 16: the panel is admin-only
|
||||
login(page, app_url, next="/tuning.html") # phase 16: the notes are admin-only
|
||||
|
||||
toggle = page.locator("#steering-toggle")
|
||||
panel = page.locator("#steering-panel")
|
||||
announcer = page.locator("#steering-announcer")
|
||||
# The navbar no longer carries a steering toggle — absent for the
|
||||
# admin too — and the header panel section still ships hidden.
|
||||
assert page.locator("#steering-toggle").count() == 0
|
||||
expect(page.locator("#steering-panel")).to_be_hidden()
|
||||
|
||||
# Initial: closed, correctly wired, polite live region present.
|
||||
expect(toggle).to_have_attribute("aria-expanded", "false")
|
||||
expect(toggle).to_have_attribute("aria-controls", "steering-panel")
|
||||
expect(panel).to_have_attribute("role", "region")
|
||||
assert "Tuning notes" in (panel.get_attribute("aria-label") or "")
|
||||
expect(panel).to_be_hidden()
|
||||
# The page's own polite live region.
|
||||
announcer = page.locator("#tune-announcer")
|
||||
assert announcer.get_attribute("role") == "status"
|
||||
assert announcer.get_attribute("aria-live") == "polite"
|
||||
# Accessible name comes from its visible text (icon is aria-hidden).
|
||||
assert "Tuning" in toggle.inner_text()
|
||||
|
||||
# Open: expanded + the designed empty state.
|
||||
toggle.click()
|
||||
expect(toggle).to_have_attribute("aria-expanded", "true")
|
||||
expect(panel).to_be_visible()
|
||||
expect(page.locator("#steering-empty")).to_be_visible()
|
||||
expect(page.locator("#steering-count")).to_have_text("0")
|
||||
|
||||
# Add a note (API), then re-open the panel to refresh it.
|
||||
# Add a note (API), reload to refresh the list (tuning.js fetches
|
||||
# on boot).
|
||||
page.evaluate(
|
||||
"""async () => {
|
||||
const r = await fetch('/api/steering', {
|
||||
@@ -277,26 +271,22 @@ def test_tuning_panel_a11y(page: Page, app_url: str, db_ready: None) -> None:
|
||||
if (!r.ok) throw new Error('steering POST failed: ' + r.status);
|
||||
}"""
|
||||
)
|
||||
toggle.click() # close
|
||||
toggle.click() # re-open (refreshes the list)
|
||||
note_item = page.locator("#steering-list .steering-note")
|
||||
page.reload()
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
note_item = page.locator("#tune-list .tuning-note")
|
||||
expect(note_item).to_have_count(1)
|
||||
expect(note_item.locator(".steering-note-text")).to_have_text("a11y note one")
|
||||
expect(note_item.locator(".tuning-note-text")).to_have_text("a11y note one")
|
||||
|
||||
# The per-note delete is a real, labeled button (≥44px target).
|
||||
delete = page.locator("#steering-list .steering-delete")
|
||||
delete = page.locator(".tuning-delete")
|
||||
expect(delete).to_have_attribute("type", "button")
|
||||
assert (delete.get_attribute("aria-label") or "").startswith("Delete tuning note:")
|
||||
box = delete.bounding_box()
|
||||
assert box is not None and box["height"] >= 44
|
||||
|
||||
# Delete: list empties, count updates, the live region announces it.
|
||||
# Delete: list empties, the empty state shows, the live region
|
||||
# announces it.
|
||||
delete.click()
|
||||
expect(page.locator("#steering-list .steering-note")).to_have_count(0)
|
||||
expect(page.locator("#steering-count")).to_have_text("0")
|
||||
expect(page.locator("#tune-list .tuning-note")).to_have_count(0, timeout=15_000)
|
||||
expect(page.locator("#tune-empty")).to_be_visible()
|
||||
expect(announcer).to_contain_text("deleted")
|
||||
|
||||
# And the toggle closes cleanly again.
|
||||
toggle.click()
|
||||
expect(toggle).to_have_attribute("aria-expanded", "false")
|
||||
expect(panel).to_be_hidden()
|
||||
|
||||
@@ -6,27 +6,25 @@ toggle) to ANONYMOUS visitors — it shipped VISIBLE in all six pages'
|
||||
markup and ``assets/header.js`` removed it only after ``/api/whoami``
|
||||
resolved, so the button flashed for the whole whoami round-trip.
|
||||
|
||||
The fix mirrors the admin-only nav links (phase 19/29/35 contract):
|
||||
the toggle now SHIPS ``hidden`` in every page and ``initSharedHeader``
|
||||
unhides it only when whoami says admin; the anonymous end-state is
|
||||
unchanged (toggle + panel REMOVED from the DOM — phase 16 "absent,
|
||||
not hidden"). This suite proves the browser-level contract:
|
||||
Phase 40 fixed it with the ship-hidden / reveal-for-admin contract
|
||||
(the admin-only nav links). The owner then asked for the button to go
|
||||
away entirely (2026-08-28): the navbar ``#steering-toggle`` is now
|
||||
ABSENT from every page — for the admin AND anonymous — so the
|
||||
never-visible contract holds by construction, and note management
|
||||
lives on ``/tuning.html``. The anonymous end-state is unchanged: the
|
||||
``#steering-panel`` is REMOVED from the DOM (phase 16 "absent, not
|
||||
hidden") and ``/api/steering`` is never fetched. This suite proves the
|
||||
browser-level contract:
|
||||
|
||||
* a MutationObserver (installed via ``add_init_script`` before any
|
||||
page code runs) records every frame in which ``#steering-toggle``
|
||||
is both in the DOM and visible (``offsetParent !== null`` or
|
||||
``!hidden``) — an anonymous load records ZERO such frames, on
|
||||
every page, from first paint to the settled state;
|
||||
* after the whoami round-trip the toggle is ABSENT from the DOM for
|
||||
anonymous visitors (removed, not hidden);
|
||||
* the admin UX is untouched (phase 15/34 behavior): the toggle is
|
||||
revealed, clicking opens ``#steering-panel``
|
||||
(``aria-expanded="true"``) and the count badge matches the note
|
||||
list — including a self-check that the observer records the admin
|
||||
reveal, so the zero-frame anonymous assertions are not vacuous;
|
||||
* the ship-hidden nav-link contract this phase relies on
|
||||
(``#nav-sources`` / ``#nav-git-sources`` / ``#nav-tuning``) is
|
||||
intact: hidden for anonymous, revealed for admin.
|
||||
* ``#steering-toggle`` is absent from the DOM on every page, for
|
||||
anonymous AND admin — nothing can flash, because nothing ships;
|
||||
* the anonymous end-state survives the removal: ``#steering-panel``
|
||||
is removed from the DOM, not just hidden;
|
||||
* the admin UX around the removal is intact: the header panel section
|
||||
still ships hidden (never opened from the header anymore), and the
|
||||
ship-hidden nav-link contract this phase relies on
|
||||
(``#nav-sources`` / ``#nav-git-sources`` / ``#nav-tuning``) holds:
|
||||
hidden for anonymous, revealed for admin.
|
||||
|
||||
Story: ``.agent/user_stories/tuning-toggle-flash.md``
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
@@ -42,82 +40,27 @@ Test → story mapping (Playwright Mapping Rule):
|
||||
from __future__ import annotations
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import SessionLocal
|
||||
from e2e.auth_helpers import login
|
||||
|
||||
#: The pages the contract must hold on besides the chat page (mapping
|
||||
#: rule 2). document.html / git-sources.html are covered by the
|
||||
#: source-level unit pins (tests/unit/test_steering_toggle_visibility.py)
|
||||
#: — the four pages here are the ones an anonymous visitor actually
|
||||
#: lands on.
|
||||
#: source-level unit pins (tests/unit/test_steering_toggle_removal.py)
|
||||
#: — the three pages here are the other pages an anonymous visitor
|
||||
#: actually lands on.
|
||||
OTHER_PAGES = ("/sources.html", "/tuning.html", "/login.html")
|
||||
|
||||
ADMIN_NOTE = "PHASE40-E2E note — badge check"
|
||||
|
||||
#: Admin-only nav links — the ship-hidden / reveal-for-admin family the
|
||||
#: steering toggle now belongs to (phase 19/29/35 contract).
|
||||
#: steering toggle belonged to (phase 19/29/35 contract).
|
||||
NAV_IDS = ("#nav-sources", "#nav-git-sources", "#nav-tuning")
|
||||
|
||||
#: Runs in every new document BEFORE any page script (addInitScript):
|
||||
#: arms a MutationObserver over the whole DOM and records every frame
|
||||
#: in which #steering-toggle is attached AND visible — visible meaning
|
||||
#: rendered (offsetParent !== null) OR carrying no [hidden] attribute
|
||||
#: (!el.hidden). A shipped-VISIBLE toggle (the old bug) is recorded the
|
||||
#: moment the parser inserts it; a shipped-hidden toggle that is later
|
||||
#: revealed is recorded at the reveal mutation. The array is fresh per
|
||||
#: document, so each navigation asserts its own frames.
|
||||
VISIBILITY_OBSERVER_JS = """
|
||||
window.__tuningVisibleFrames = [];
|
||||
(() => {
|
||||
const visible = (el) =>
|
||||
!!el && el.isConnected && (el.offsetParent !== null || !el.hidden);
|
||||
const check = () => {
|
||||
if (visible(document.getElementById("steering-toggle"))) {
|
||||
window.__tuningVisibleFrames.push({
|
||||
at: Math.round(performance.now()),
|
||||
href: location.pathname,
|
||||
});
|
||||
}
|
||||
};
|
||||
const start = () => {
|
||||
check();
|
||||
new MutationObserver(check).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["hidden"],
|
||||
});
|
||||
};
|
||||
if (document.documentElement) start();
|
||||
else document.addEventListener("DOMContentLoaded", start);
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def install_visibility_observer(page: Page) -> None:
|
||||
"""Arm the never-visible frame counter on every document this page
|
||||
creates — the initial load, the post-login redirect, re-gotos."""
|
||||
page.add_init_script(VISIBILITY_OBSERVER_JS)
|
||||
|
||||
|
||||
def visible_frames(page: Page) -> list[dict[str, object]]:
|
||||
"""The frames the observer recorded in the CURRENT document."""
|
||||
return page.evaluate("() => window.__tuningVisibleFrames || []")
|
||||
|
||||
|
||||
def _assert_no_flash(page: Page, path: str) -> None:
|
||||
"""Zero visible frames + the phase-16 absent end-state, for one
|
||||
anonymously loaded page."""
|
||||
frames = visible_frames(page)
|
||||
assert frames == [], f"{path}: the toggle was visible {len(frames)}x: {frames!r}"
|
||||
def _assert_no_toggle(page: Page, path: str) -> None:
|
||||
"""The navbar toggle is absent from the DOM for one page — nothing
|
||||
to flash, for either role."""
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
f"{path}: #steering-toggle must be REMOVED from the DOM for "
|
||||
"anonymous (phase 16 'absent, not hidden')"
|
||||
)
|
||||
assert page.locator("#steering-panel").count() == 0, (
|
||||
f"{path}: #steering-panel must be removed together with the toggle"
|
||||
f"{path}: #steering-toggle must be ABSENT from the DOM "
|
||||
"(removed from the navbar at owner request, 2026-08-28)"
|
||||
)
|
||||
|
||||
|
||||
@@ -125,14 +68,21 @@ def _wait_header_settled_anonymous(page: Page) -> None:
|
||||
"""Whoami resolved on the current document: the anonymous state
|
||||
reveals the Sign in link and keeps Sign out hidden (the pair is
|
||||
decided by the SAME initSharedHeader pass that removes the
|
||||
toggle)."""
|
||||
steering panel)."""
|
||||
page.wait_for_load_state("networkidle")
|
||||
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||
|
||||
|
||||
def _wait_header_settled_admin(page: Page) -> None:
|
||||
"""Whoami resolved for a signed-in admin on the current document."""
|
||||
page.wait_for_load_state("networkidle")
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Anonymous chat load: zero visible frames, toggle absent afterwards
|
||||
# 1. Anonymous chat load: zero toggle, panel removed (never visible)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -140,14 +90,19 @@ def test_anonymous_never_sees_toggle(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
install_visibility_observer(page)
|
||||
page.goto(app_url + "/")
|
||||
_wait_header_settled_anonymous(page)
|
||||
_assert_no_flash(page, "/")
|
||||
_assert_no_toggle(page, "/")
|
||||
# The phase-16 end-state survives the removal: the panel is REMOVED
|
||||
# from the DOM, not just hidden.
|
||||
assert page.locator("#steering-panel").count() == 0, (
|
||||
"/: #steering-panel must be removed from the DOM for anonymous "
|
||||
"(phase 16 'absent, not hidden')"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Anonymous loads of the other pages: same zero-flash contract
|
||||
# 2. Anonymous loads of the other pages: same no-toggle contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -155,15 +110,19 @@ def test_anonymous_other_pages_never_flash(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
install_visibility_observer(page)
|
||||
for path in OTHER_PAGES:
|
||||
page.goto(app_url + path)
|
||||
_wait_header_settled_anonymous(page)
|
||||
_assert_no_flash(page, path)
|
||||
_assert_no_toggle(page, path)
|
||||
assert page.locator("#steering-panel").count() == 0, (
|
||||
f"{path}: #steering-panel must be removed from the DOM for "
|
||||
"anonymous (phase 16 'absent, not hidden')"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Admin: revealed, clickable, count badge matches the list
|
||||
# 3. Admin: the toggle is gone too — the panel still ships hidden and
|
||||
# the nav links are revealed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -171,60 +130,26 @@ def test_admin_toggle_revealed_and_working(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE steering_notes"))
|
||||
db.commit()
|
||||
|
||||
install_visibility_observer(page)
|
||||
login(page, app_url, next="/")
|
||||
page.wait_for_load_state("networkidle")
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
_wait_header_settled_admin(page)
|
||||
|
||||
toggle = page.locator("#steering-toggle")
|
||||
panel = page.locator("#steering-panel")
|
||||
|
||||
# Revealed — the [hidden] attribute is gone and the button renders.
|
||||
expect(toggle).to_be_visible()
|
||||
assert toggle.get_attribute("hidden") is None
|
||||
|
||||
# Observer self-check: the reveal IS a recorded visible frame, so
|
||||
# the zero-frame anonymous assertions above cannot be vacuous.
|
||||
assert visible_frames(page) != [], "the admin reveal was not observed"
|
||||
|
||||
# Click: the panel opens, aria-expanded tracks it (phase 15/34).
|
||||
toggle.click()
|
||||
expect(panel).to_be_visible()
|
||||
expect(toggle).to_have_attribute("aria-expanded", "true")
|
||||
|
||||
# Count badge matches the list — first the empty state…
|
||||
expect(page.locator("#steering-count")).to_have_text("0")
|
||||
expect(page.locator("#steering-list .steering-note")).to_have_count(0)
|
||||
expect(page.locator("#steering-empty")).to_be_visible()
|
||||
|
||||
# …then with one note created through the real admin API.
|
||||
page.evaluate(
|
||||
"""async (note) => {
|
||||
const r = await fetch("/api/steering", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({note}),
|
||||
});
|
||||
if (!r.ok) throw new Error("steering POST failed: " + r.status);
|
||||
}""",
|
||||
ADMIN_NOTE,
|
||||
)
|
||||
toggle.click() # close
|
||||
toggle.click() # re-open (refreshes the list)
|
||||
expect(panel).to_be_visible()
|
||||
expect(toggle).to_have_attribute("aria-expanded", "true")
|
||||
expect(page.locator("#steering-count")).to_have_text("1")
|
||||
expect(page.locator("#steering-list .steering-note")).to_have_count(1)
|
||||
expect(page.locator("#steering-list .steering-note-text")).to_have_text(ADMIN_NOTE)
|
||||
# The navbar toggle was removed at owner request (2026-08-28) —
|
||||
# absent for the admin as well; nothing in the header can flash
|
||||
# it back, because nothing ships it anymore.
|
||||
_assert_no_toggle(page, "/")
|
||||
# The header panel section survives (kept fresh by the chat page's
|
||||
# per-bubble Tune form) but ships hidden — no header control opens
|
||||
# it anymore; note management lives on /tuning.html.
|
||||
assert page.locator("#steering-panel").count() == 1
|
||||
expect(page.locator("#steering-panel")).to_be_hidden()
|
||||
# The surviving path to the notes: the admin-only Tuning nav link
|
||||
# is revealed.
|
||||
expect(page.locator("#nav-tuning")).to_be_visible()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Nav-contract regression (phase 19/34): the ship-hidden family the
|
||||
# toggle now belongs to is intact
|
||||
# toggle used to belong to is intact
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -245,9 +170,8 @@ def test_nav_contract_regression(
|
||||
)
|
||||
|
||||
# Admin: the same links are revealed — the exact contract the
|
||||
# steering toggle now mirrors.
|
||||
# steering toggle mirrored before it was removed.
|
||||
login(page, app_url, next="/")
|
||||
page.wait_for_load_state("networkidle")
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
_wait_header_settled_admin(page)
|
||||
for nav in NAV_IDS:
|
||||
expect(page.locator(nav)).to_be_visible()
|
||||
|
||||
Reference in New Issue
Block a user