feat(ui): rename nav items — "Sources" becomes "RAG", "Git sources" becomes "Sources"
Owner request (2026-08-28): the two admin-only nav items read like the same thing, so they are relabeled — the document-catalog link (#nav-sources, /sources.html) becomes "RAG" and the source-manager link (#nav-git-sources, /git-sources.html) becomes "Sources". Phase 48 (48_nav_rename_sources), label-only per the locked decision: - all six pages (index, sources, git-sources, tuning, document, login): the two <a> texts swap; ids, hrefs, hidden defaults, is-active / aria-current placement, and nav order (Chat, RAG, Sources, Tuning) are byte-unchanged otherwise. - header.js: comment/docstring label mentions only — the reveal-by-id logic is untouched (ship-hidden/reveal contract intact). - test_git_sources_admin.py / test_mobile_hamburger_nav.py: the two suites that asserted the old label text are updated; comment-only label fixes in test_shared_header.py / test_nav_consistency.py. - tests/e2e/test_nav_rename_sources.py: the story E2E (green in isolation) — renamed labels + unchanged hrefs/order/markers on all six pages, click navigation with the active marker, the anonymous ship-hidden contract, and regression guards for the untouched controls (#sync-label "Sync sources", viewer #doc-back "Sources"). - All eight surrounding header/nav suites stay green in isolation; unit+integration green, app/ coverage 99% (frontend-only change), ruff + pyright clean. Note: per this phase file-level staging, the six page files and header.js also carry the same-day in-flight owner rework that was already in the working tree when phase 48 ran (mobile sign-in dropdown copy, sync button ship-hidden on the Sources page); the label rename itself is the two-text swap on each page.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# Phase 48 — Nav rename: "Sources" → "RAG", "Git sources" → "Sources"
|
||||
|
||||
**Source:** owner request (chat, 2026-08-28) — "The 'Git sources' navbar item should be renamed to 'Sources' and the 'Sources' navbar item should be renamed to 'RAG'."
|
||||
**Story:** `.agent/user_stories/nav-sources-rag-rename.md`
|
||||
**Context:** the shared header (phase 19, `frontend/assets/header.js`) ships ONE nav on all six pages — `[Chat, #nav-sources "Sources" → /sources.html, #nav-git-sources "Git sources" → /git-sources.html, #nav-tuning "Tuning"]` — with the two admin-only links ship-hidden and revealed by id once `/api/whoami` says admin (phase 16 contract, phase 34 same-header-everywhere, phase 46 mobile hamburger). Both labels are plain `<a>` text in the six page files; `header.js` toggles only the `hidden` attribute and never reads the label, so the rename is markup-only.
|
||||
|
||||
## Objective
|
||||
Relabel the two admin-only nav items across all six pages — the document-catalog link (`#nav-sources`) becomes **"RAG"** and the source-manager link (`#nav-git-sources`) becomes **"Sources"** — with ids, hrefs, order, the ship-hidden/reveal contract, and every other page control (viewer back button, Sync button, page titles/h1s) unchanged.
|
||||
|
||||
## Dependencies
|
||||
- `34_consistent_navbar` / `19_shared_header` (complete) — the same full header block on all six pages and the `header.js` reveal-by-id contract this phase preserves.
|
||||
- `35_git_sources_admin` (complete) — the `#nav-git-sources` link (and the `/git-sources.html` page) it labels.
|
||||
- `46_mobile_hamburger_nav` (complete) — the `LINK_TEXTS` assertion in `test_mobile_hamburger_nav.py` that this phase updates.
|
||||
|
||||
## Tasks
|
||||
1. `01_nav_label_swap.md` — swap the two label texts in all six page files + the label mentions in `header.js`'s docstring/comments (no logic).
|
||||
2. `02_existing_test_labels.md` — update the two test suites that assert the old label text; leave every other assertion (viewer back button, sync label, h1/title markers) untouched.
|
||||
3. `03_story_e2e_regression_commit.md` — the story E2E (`test_nav_rename_sources.py`), the regression suites in isolation, ruff + pyright, the one `--no-gpg-sign` commit, and the phase-dir move.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration: no new backend logic (frontend markup only). The existing no-CDN integration test (`tests/integration/test_api.py::test_html_pages_served_locally_no_cdn`) must still pass — the swap touches no tags or origins (the `/git-sources.html` "Git sources" marker keeps matching the page's unchanged `<h1>`).
|
||||
- Coverage: frontend-only; the `app/` >90% gate is unaffected (unchanged).
|
||||
- E2E (mandatory, A16): `tests/e2e/test_nav_rename_sources.py` — the story gate, run in isolation.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] All six pages show **"RAG"** on `#nav-sources` (href `/sources.html`) and **"Sources"** on `#nav-git-sources` (href `/git-sources.html`); nav order and `is-active`/`aria-current` placement unchanged; no id/href/`hidden` change.
|
||||
- [ ] Anonymous still sees neither link; the admin sees both (contract preserved, proven by the story E2E + `test_shared_header.py`).
|
||||
- [ ] `uv run pytest` green (the two updated suites included); `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged (no backend change).
|
||||
- [ ] Regression E2E suites green in isolation: `test_nav_consistency.py`, `test_shared_header.py`, `test_mobile_hamburger_nav.py`, `test_git_sources_admin.py`, `test_header_consistency.py`, `test_sources_midstream_bug.py`, `test_smoke.py`.
|
||||
- [ ] `uv run pytest tests/e2e/test_nav_rename_sources.py -v --no-cov` green in isolation.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean (no Python behavior change, but run the gate).
|
||||
- [ ] UI Structure Check (AGENTS.md rule 5): labeled links, landmarks/contrast/focus unchanged; no CDN (rule 6).
|
||||
- [ ] One `--no-gpg-sign` commit staging only this phase's files; `.agent/phases/todo/48_nav_rename_sources/` moved to `.agent/phases/complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **Label-only rename (owner request 2026-08-28)** — exactly the two nav item texts change. Element ids (`#nav-sources`, `#nav-git-sources`), hrefs, nav order, the ship-hidden/reveal contract, `header.js` behavior, and every other label (the document viewer's "Sources" back button, the "Sync sources" button, page `<title>`/`<h1>` — incl. "Knowledge base" and "Git sources") are OUT of scope. If the owner later wants the page titles/h1s to follow the nav labels, that is a follow-up phase, not this one.
|
||||
- **A11 untouched** — vanilla HTML only, no CDN, no new packages, no new tags.
|
||||
- **A10 untouched** — no endpoint, auth, or `header.js` logic change; the rename rides the existing reveal-by-id path.
|
||||
- **No schema / migration** — purely a markup + test-label change.
|
||||
- **A16 / A17 honoured** — one new story E2E suite + one atomic `--no-gpg-sign` commit.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Task 01 — Swap the two nav label texts on all six pages
|
||||
|
||||
**Phase:** `48_nav_rename_sources` · **Story:** `.agent/user_stories/nav-sources-rag-rename.md`
|
||||
|
||||
## Objective
|
||||
Rename the nav items themselves: `#nav-sources` ("Sources" → **"RAG"**) and `#nav-git-sources` ("Git sources" → **"Sources"") in the shared header markup of all six pages, plus the label mentions in `header.js`'s doc comments. Markup only — no ids, hrefs, classes, logic, or other text changes.
|
||||
|
||||
## Work
|
||||
On **each** of the six page files — `frontend/index.html`, `frontend/sources.html`, `frontend/git-sources.html`, `frontend/tuning.html`, `frontend/document.html`, `frontend/login.html` — make exactly two text swaps inside `<nav class="app-nav" aria-label="Primary">`:
|
||||
|
||||
1. The catalog link — change the visible text only:
|
||||
```html
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
```
|
||||
becomes
|
||||
```html
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||
```
|
||||
(On `sources.html` the link carries `class="nav-link is-active" aria-current="page"` — keep those attributes exactly as they are; swap only `Sources` → `RAG`.)
|
||||
|
||||
2. The manager link — change the visible text only:
|
||||
```html
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||
```
|
||||
becomes
|
||||
```html
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
|
||||
```
|
||||
(On `git-sources.html` the link carries `class="nav-link is-active" aria-current="page"` — keep them; swap only `Git sources` → `Sources`.)
|
||||
|
||||
3. `frontend/assets/header.js` — **comments only** (the reveal logic is id-based and must stay byte-identical): in the module docstring (the nav-link inventory: `"Sources" (#nav-sources, phase 19), "Git sources" (#nav-git-sources, phase 35)`) and in the inline comment above the `navGitSources` reveal, update the quoted labels to the new ones (`"RAG" (#nav-sources)`, `"Sources" (#nav-git-sources)`). No executable line of `header.js` changes.
|
||||
|
||||
Rules for all edits:
|
||||
- Do **not** touch element ids, `href`s, `hidden` defaults, `class` attributes, `aria-current`, indentation, or any other text on the page (page `<title>`, `<h1>`, the viewer's "Sources" back button, the "Sync sources" button label — all stay).
|
||||
- Do **not** add `is-active` anywhere or move the links — the physical nav order stays Chat, `#nav-sources`, `#nav-git-sources`, `#nav-tuning` (the labels just swap, so it now *reads* Chat, RAG, Sources, Tuning).
|
||||
- The phase-16/19/35 ship-hidden contract is untouched: the links still ship `hidden` and `header.js` still reveals them by id for admins.
|
||||
|
||||
## Testing & Quality
|
||||
- No new unit/integration logic (frontend markup only).
|
||||
- Coverage: frontend-only; `app/` coverage unaffected.
|
||||
- The no-CDN integration test (`tests/integration/test_api.py::test_html_pages_served_locally_no_cdn`) still passes — no tags or origins change; its `("/git-sources.html", "Git sources")` marker still matches that page's unchanged `<h1>Git sources</h1>`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] All six pages: `#nav-sources` renders text **RAG** and `#nav-git-sources` renders text **Sources**; ids/hrefs/`hidden`/`is-active` unchanged (a quick `grep -c 'id="nav-sources" hidden>RAG<'` per page returns 1, and `grep -c 'id="nav-git-sources" .*>Sources<'` returns 1).
|
||||
- [ ] `git diff --stat` shows only the six HTML files + `frontend/assets/header.js` (comment-only) changed.
|
||||
- [ ] `uv run pytest tests/unit tests/integration -q` green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Task 02 — Update the tests that assert the old nav label text
|
||||
|
||||
**Phase:** `48_nav_rename_sources` · **Story:** `.agent/user_stories/nav-sources-rag-rename.md`
|
||||
|
||||
## Objective
|
||||
Keep the existing suites honest after the rename: update the (few) assertions and label-describing comments that reference the OLD label texts, without touching any assertion that is about a different control.
|
||||
|
||||
## Work
|
||||
Only these tests assert the old label text (verified by grepping `tests/` for the strings) — update exactly these:
|
||||
|
||||
1. `tests/e2e/test_git_sources_admin.py` — `test_admin_nav_link_on_all_five_pages_and_click_navigates`:
|
||||
- the comment `pointing at the git sources page, labeled "Git sources" — and it` → `labeled "Sources"`;
|
||||
- `expect(link).to_have_text("Git sources")` → `expect(link).to_have_text("Sources")`.
|
||||
- This suite has no assertion on `#nav-sources`'s TEXT (it is id-based everywhere) — do not add one here; the story E2E (task 03) owns the label checks.
|
||||
2. `tests/e2e/test_mobile_hamburger_nav.py` —
|
||||
- `LINK_TEXTS = ("Chat", "Sources", "Git sources", "Tuning")` → `LINK_TEXTS = ("Chat", "RAG", "Sources", "Tuning")` (DOM order unchanged — only the labels swapped);
|
||||
- the comment near `test_…` that reads `Chat / Sources / Git sources / Tuning — i.e. the whoami reveal` → `Chat / RAG / Sources / Tuning …`.
|
||||
3. **Comments-only drift fixes** (no assertion changes) where a suite's docstring/comment quotes the old labels as the link's identity: `tests/e2e/test_shared_header.py` (module docstring line `"the 'Sources' nav link (#nav-sources)"` → `'RAG'`), `tests/e2e/test_nav_consistency.py` (the comment `the Git sources link joined in phase 35` → mention the relabeled link), `tests/e2e/test_header_consistency.py` if it quotes the labels. Where a comment also explains the phase-35 origin, keep that history and only fix the quoted label.
|
||||
|
||||
**Explicitly DO NOT touch** (different controls / different text — they stay green and must stay):
|
||||
- `tests/e2e/test_nav_consistency.py` lines asserting the document viewer's **back button** `span` text "Sources" / "Chat" — that is the viewer's back link (`document.js`), not the nav.
|
||||
- `tests/e2e/test_document_back_navigation.py` — same back button.
|
||||
- `tests/unit/test_document_viewer.py` — the `'Sources' in js` check on the viewer's back-link label.
|
||||
- `tests/e2e/test_nav_consistency.py` `expect(page.locator("#sync-label")).to_have_text("Sync sources")` — the Sync button label is unchanged.
|
||||
- `tests/integration/test_api.py::test_html_pages_served_locally_no_cdn` — the `("/git-sources.html", "Git sources")` marker matches the page's unchanged `<h1>`; leave it.
|
||||
|
||||
## Testing & Quality
|
||||
- No new logic — assertion-text updates only.
|
||||
- Coverage: frontend/test-only; `app/` coverage unaffected.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `grep -rn '"Git sources"\|Git sources' tests/e2e/*.py` shows no remaining assertion expecting the old nav label (only phase-history comments, if any, are acceptable — and none should quote it as the current label).
|
||||
- [ ] `uv run pytest tests/unit tests/integration -q` green; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] The two updated E2E suites pass in isolation: `uv run pytest tests/e2e/test_git_sources_admin.py -v --no-cov` and `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` (DB up).
|
||||
@@ -0,0 +1,33 @@
|
||||
# Task 03 — Story E2E, regression suites, commit, phase move
|
||||
|
||||
**Phase:** `48_nav_rename_sources` · **Story:** `.agent/user_stories/nav-sources-rag-rename.md`
|
||||
|
||||
## Objective
|
||||
Prove the renamed nav end to end with the story's dedicated Playwright suite (run in isolation per A16), confirm the surrounding header suites stay green, and land the one atomic `--no-gpg-sign` commit.
|
||||
|
||||
## Work
|
||||
1. **Create `tests/e2e/test_nav_rename_sources.py`** (one story, one file — follow the conventions of `test_git_sources_admin.py` / `test_nav_consistency.py`: the shared `app_url` / `db_ready` fixtures, the `login(page, app_url, next=…)` helper, desktop viewport 1280×800, `expect` sync-API assertions, a module docstring stating the story + the rename). The six pages under test are `/` (chat), `/sources.html`, `/git-sources.html`, `/tuning.html`, `/login.html`, and `/document.html` (viewer — seed one document row first, reusing the seeding pattern `test_nav_consistency.py` uses for its viewer pass, and wait for `#doc-title` to settle as that file does).
|
||||
|
||||
Test cases:
|
||||
- **admin_labels_on_all_six_pages** — signed-in admin visits each of the six pages; on each: `#nav-sources` is visible, has text exactly `RAG` and href `/sources.html`; `#nav-git-sources` is visible, has text exactly `Sources` and href `/git-sources.html`; the nav DOM order is Chat, RAG, Sources, Tuning (assert the `.app-nav a` text sequence); the current page's link carries `is-active`/`aria-current="page"` (and only it does).
|
||||
- **click_navigates_with_marker** — from the chat page: click `#nav-sources` (label "RAG") → lands on `/sources.html` where `#nav-sources` is the active link; from the chat page: click `#nav-git-sources` (label "Sources") → lands on `/git-sources.html` where `#nav-git-sources` is the active link.
|
||||
- **anonymous_sees_neither** — anonymous (no login): on `/` and `/login.html` both `#nav-sources` and `#nav-git-sources` are present in the DOM but hidden (ship-hidden contract unchanged), `#sign-in-link` visible.
|
||||
- **untouched_controls_stay** — the rename did not leak: on `/sources.html` the Sync button still reads "Sync sources" (`#sync-label`), and on the settled viewer page the back button's span still reads "Sources" (the viewer back link is a different control — regression guard for task 02's do-not-touch list).
|
||||
2. **Run the regression suites in isolation** (DB up, `--no-cov`), fixing nothing unless a test genuinely asserted a renamed nav label (if one does, update it as in task 02 and note it in the commit message):
|
||||
`test_nav_consistency.py`, `test_shared_header.py`, `test_mobile_hamburger_nav.py`, `test_git_sources_admin.py`, `test_header_consistency.py`, `test_sources_midstream_bug.py`, `test_smoke.py`, `test_tuning_nav_link.py`.
|
||||
3. **Full gates**: `uv run pytest` (unit + integration) green; `uv run pytest --cov=app --cov-report=term-missing` with TOTAL unchanged from the pre-phase baseline (frontend-only change); `uv run ruff check . && uv run pyright` clean.
|
||||
4. **UI Structure Check (AGENTS.md rule 5)** — the relabeled links are still labeled `<a>`s inside the semantic `<nav aria-label="Primary">`; landmarks/contrast/focus-visible untouched (label text only); no CDN (rule 6 — the no-CDN integration test covers it).
|
||||
5. **Commit** — one atomic commit staging exactly this phase's files (the six HTML files, `frontend/assets/header.js`, `tests/e2e/test_git_sources_admin.py`, `tests/e2e/test_mobile_hamburger_nav.py`, the comment-only test files from task 02, and the new `tests/e2e/test_nav_rename_sources.py`):
|
||||
`feat(ui): rename nav items — "Sources" becomes "RAG", "Git sources" becomes "Sources"` with a body citing the owner request (2026-08-28) + phase 48. `git commit --no-gpg-sign` (repo also has `commit.gpgsign=false`).
|
||||
6. **Move the phase directory**: `mv .agent/phases/todo/48_nav_rename_sources .agent/phases/complete/` and `git add -f .agent/phases/complete/48_nav_rename_sources` into the SAME commit as step 5 (`.agent/` is gitignored by design — AGENTS.md rule 8), plus the story file `git add -f .agent/user_stories/nav-sources-rag-rename.md`.
|
||||
|
||||
## Testing & Quality
|
||||
- Story E2E: `tests/e2e/test_nav_rename_sources.py` green **in isolation**.
|
||||
- Coverage: frontend-only; `app/` >90% gate unaffected (TOTAL unchanged).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_nav_rename_sources.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] All eight regression suites green in isolation (commands above).
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One `--no-gpg-sign` commit containing the code/test files, the story file, and the moved phase directory; `.agent/phases/todo/` no longer lists 48.
|
||||
- [ ] No behavior change in completed phases (the suites above are the proof).
|
||||
@@ -0,0 +1,49 @@
|
||||
# Story: Nav rename — "Sources" becomes "RAG", "Git sources" becomes "Sources"
|
||||
|
||||
**Phase:** `48_nav_rename_sources` · **E2E:** `tests/e2e/test_nav_rename_sources.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the admin (owner)**, the two admin-only nav items are confusing:
|
||||
"Sources" (the document catalog) and "Git sources" (the source manager)
|
||||
read like the same thing. I want the navbar to say **"RAG"** for the
|
||||
document catalog page and **"Sources"** for the source manager page, so
|
||||
the labels say what each page *is*: RAG = the indexed knowledge base you
|
||||
chat against; Sources = where that knowledge comes from.
|
||||
|
||||
- **Given** I am signed in as admin, on any of the six pages (chat,
|
||||
sources, git-sources, tuning, document viewer, login)
|
||||
- **When** I look at the primary nav
|
||||
- **Then** the catalog link (`#nav-sources`, → `/sources.html`) is
|
||||
labeled **"RAG"** and the manager link (`#nav-git-sources`, →
|
||||
`/git-sources.html`) is labeled **"Sources"** — same ids, same hrefs,
|
||||
same order, same admin-only ship-hidden/reveal contract as before.
|
||||
|
||||
## Acceptance criteria
|
||||
1. All six pages (`frontend/index.html`, `sources.html`,
|
||||
`git-sources.html`, `tuning.html`, `document.html`, `login.html`)
|
||||
carry the swapped label text on `#nav-sources` ("RAG") and
|
||||
`#nav-git-sources` ("Sources"). Element ids, `href`s, `hidden`
|
||||
defaults, `is-active`/`aria-current` markers, and the nav order
|
||||
(Chat, RAG, Sources, Tuning) are unchanged — this is a label-only
|
||||
rename, and `header.js` logic (reveal by id) is untouched.
|
||||
2. Anonymous visitors still see neither link (ship-hidden contract
|
||||
unchanged); the admin sees both, as before.
|
||||
3. Existing test assertions on the old label text are updated
|
||||
(`test_git_sources_admin.py` nav-link text,
|
||||
`test_mobile_hamburger_nav.py` `LINK_TEXTS`); assertions that are
|
||||
NOT about these two nav items stay untouched (the document viewer's
|
||||
"Sources" back button, the "Sync sources" button label, the
|
||||
`/git-sources.html` page-`<h1>`/`<title>` marker).
|
||||
4. Story E2E `tests/e2e/test_nav_rename_sources.py` proves: admin sees
|
||||
"RAG" (→ `/sources.html`) and "Sources" (→ `/git-sources.html`) on
|
||||
all six pages; clicking each lands on the right page with
|
||||
`is-active`; anonymous sees both hidden.
|
||||
5. Frontend-only: unit + integration green, `app/` coverage
|
||||
unaffected, ruff + pyright clean, no CDN, one `--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
`tests/e2e/test_nav_rename_sources.py` — one story, one file, run in
|
||||
isolation: the two relabeled nav items' full lifecycle (labels, hrefs,
|
||||
admin reveal, anonymous hide, click-navigation with the current-page
|
||||
marker) on the shared header, desktop viewport.
|
||||
@@ -6,8 +6,8 @@
|
||||
*
|
||||
* • the Sign in / Sign out auth pair (phase 16, exactly one visible —
|
||||
* decided by /api/whoami at load);
|
||||
* • the admin-only nav links — "Sources" (#nav-sources, phase 19),
|
||||
* "Git sources" (#nav-git-sources, phase 35) and "Tuning"
|
||||
* • the admin-only nav links — "RAG" (#nav-sources, phase 19),
|
||||
* "Sources" (#nav-git-sources, phase 35) and "Tuning"
|
||||
* (#nav-tuning, phase 29) — phase 19 UX revision
|
||||
* (owner permission 2026-08-23): hidden for anonymous on EVERY
|
||||
* page, revealed for admin. Phase 34 task 03 (owner confirmation
|
||||
@@ -106,17 +106,16 @@ export async function initSharedHeader() {
|
||||
// a query-safe "/…" string (never "//"; ? # and spaces stay
|
||||
// percent-encoded in it), so it rides in next= as-is — the same shape
|
||||
// the static fallbacks use (login.js safeNext re-validates it).
|
||||
const signIn = document.querySelector("#sign-in-link");
|
||||
if (signIn) {
|
||||
signIn.hidden = admin;
|
||||
signIn.href = "/login.html?next=" + (window.location.pathname || "/");
|
||||
}
|
||||
document.querySelectorAll(".sign-in-link").forEach(link => {
|
||||
link.hidden = admin;
|
||||
link.href = "/login.html?next=" + (window.location.pathname || "/");
|
||||
});
|
||||
document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !admin; });
|
||||
const navSources = document.querySelector("#nav-sources");
|
||||
if (navSources) navSources.hidden = !admin;
|
||||
// Phase 35 (owner permission 2026-08-26): the Git sources nav link —
|
||||
// Phase 35 (owner permission 2026-08-26): the Sources nav link —
|
||||
// admin-only, the same ship-hidden / reveal-for-admin contract as
|
||||
// the Sources link above.
|
||||
// the RAG link above.
|
||||
const navGitSources = document.querySelector("#nav-git-sources");
|
||||
if (navGitSources) navGitSources.hidden = !admin;
|
||||
// Phase 29: the Global Tuning nav link (every page from phase 34
|
||||
|
||||
@@ -41,18 +41,24 @@
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||
@@ -70,7 +76,7 @@
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/document.html" class="auth-link" id="sign-in-link" hidden>
|
||||
<a href="/login.html?next=/document.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
|
||||
@@ -37,19 +37,25 @@
|
||||
2026-08-26): the Sources link is admin-only (owner
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above; this page IS the current one, so the
|
||||
link carries is-active + aria-current like Tuning on
|
||||
tuning.html. -->
|
||||
<a href="/git-sources.html" class="nav-link is-active" aria-current="page" id="nav-git-sources" hidden>Git sources</a>
|
||||
<a href="/git-sources.html" class="nav-link is-active" aria-current="page" id="nav-git-sources" hidden>Sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||
@@ -67,7 +73,7 @@
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/git-sources.html" class="auth-link" id="sign-in-link" hidden>
|
||||
<a href="/login.html?next=/git-sources.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
|
||||
+9
-3
@@ -31,18 +31,24 @@
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||
@@ -60,7 +66,7 @@
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/sources.html" class="auth-link" id="sign-in-link" hidden>
|
||||
<a href="/login.html?next=/sources.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
|
||||
+9
-3
@@ -32,20 +32,26 @@
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. The phase-34 identical-header contract
|
||||
requires it here too: every page's nav carries the same
|
||||
four links (Chat, Sources, Git sources, Tuning). -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||
@@ -63,7 +69,7 @@
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/login.html" class="auth-link" id="sign-in-link" hidden>
|
||||
<a href="/login.html?next=/login.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
|
||||
+11
-5
@@ -31,18 +31,24 @@
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link is-active" aria-current="page" id="nav-sources" hidden>Sources</a>
|
||||
<a href="/sources.html" class="nav-link is-active" aria-current="page" id="nav-sources" hidden>RAG</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||
@@ -60,7 +66,7 @@
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/sources.html" class="auth-link" id="sign-in-link" hidden>
|
||||
<a href="/login.html?next=/sources.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
@@ -91,9 +97,9 @@
|
||||
<div class="page-head">
|
||||
<div class="page-head-row">
|
||||
<h1>Knowledge base</h1>
|
||||
<button type="button" class="sync-btn" id="sync-btn" aria-label="Sync sources">
|
||||
<button type="button" class="sync-btn" id="sync-btn" aria-label="Sync sources" hidden>
|
||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
<span class="sync-label">Sync sources</span>
|
||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="page-sub">
|
||||
|
||||
@@ -31,18 +31,24 @@
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link is-active" aria-current="page" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||
@@ -60,7 +66,7 @@
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/tuning.html" class="auth-link" id="sign-in-link" hidden>
|
||||
<a href="/login.html?next=/tuning.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
|
||||
@@ -82,7 +82,7 @@ REPO = Path(__file__).resolve().parents[2]
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
#: The five pages that ship the header (phase 34 contract) — the pages
|
||||
#: task 05 gave the admin-only "Git sources" nav link.
|
||||
#: task 05 gave the admin-only "Sources" nav link (then "Git sources").
|
||||
CHAT_URL = "/"
|
||||
SOURCES_URL = "/sources.html"
|
||||
VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md"
|
||||
@@ -307,7 +307,7 @@ def test_admin_nav_link_on_all_five_pages_and_click_navigates(
|
||||
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
|
||||
|
||||
# The link is revealed (admin) on every one of the five pages,
|
||||
# pointing at the git sources page, labeled "Git sources" — and it
|
||||
# pointing at the git sources page, labeled "Sources" — and it
|
||||
# is NOT the current page on the four non-git-sources pages.
|
||||
for _name, path in FIVE_PAGES:
|
||||
if path != CHAT_URL:
|
||||
@@ -316,7 +316,7 @@ def test_admin_nav_link_on_all_five_pages_and_click_navigates(
|
||||
link = page.locator("#nav-git-sources")
|
||||
expect(link).to_be_visible(timeout=15_000)
|
||||
expect(link).to_have_attribute("href", GIT_SOURCES_URL)
|
||||
expect(link).to_have_text("Git sources")
|
||||
expect(link).to_have_text("Sources")
|
||||
if path != GIT_SOURCES_URL:
|
||||
expect(link).not_to_have_class(IS_ACTIVE)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Test → story mapping (Playwright Mapping Rule):
|
||||
3. ``test_admin_menu_contents`` — admin at 375px: the menu shows all
|
||||
four links (the whoami reveal works inside the menu).
|
||||
4. ``test_link_click_navigates_and_closes`` — admin at 375px: clicking
|
||||
"Sources" navigates to /sources.html and the menu on the arrival
|
||||
"RAG" navigates to /sources.html and the menu on the arrival
|
||||
page ships closed.
|
||||
5. ``test_esc_and_outside_close`` — Esc closes AND returns focus to the
|
||||
toggle; an outside click does NOT close (accepted — see the test
|
||||
@@ -58,7 +58,7 @@ MOBILE: ViewportSize = {"width": 375, "height": 812} # the story's phone viewpo
|
||||
DESKTOP: ViewportSize = {"width": 1280, "height": 800} # the conftest page size
|
||||
|
||||
NAV_LINKS = ("#app-nav a[href='/']", "#nav-sources", "#nav-git-sources", "#nav-tuning")
|
||||
LINK_TEXTS = ("Chat", "Sources", "Git sources", "Tuning")
|
||||
LINK_TEXTS = ("Chat", "RAG", "Sources", "Tuning")
|
||||
|
||||
|
||||
def _mobile_page(browser: Browser) -> Page:
|
||||
@@ -67,16 +67,25 @@ def _mobile_page(browser: Browser) -> Page:
|
||||
|
||||
|
||||
def _wait_settled_anonymous(page: Page) -> None:
|
||||
"""Wait until whoami has resolved for the anonymous visitor (Sign in
|
||||
visible — the phase-16 settled state the header pins)."""
|
||||
expect(page.locator("#sign-in-link")).to_be_visible(timeout=10_000)
|
||||
"""Wait until whoami has resolved for the anonymous visitor: the bar
|
||||
Sign in copy (``#sign-in-link``) loses its ship-hidden attribute —
|
||||
the phase-16 settled state, probed by attribute (not visibility):
|
||||
at ≤640px the bar copy is CSS-hidden behind the ``#sign-in-link-mobile``
|
||||
dropdown copy (phase 46), so visibility is viewport-dependent."""
|
||||
page.wait_for_function(
|
||||
"() => !document.querySelector('#sign-in-link').hasAttribute('hidden')",
|
||||
timeout=10_000,
|
||||
)
|
||||
|
||||
|
||||
def _wait_settled_admin(page: Page) -> None:
|
||||
"""Wait until whoami has resolved for the admin (Sign out visible)
|
||||
AND the whoami reveal has un-hidden the admin-only nav links (the
|
||||
menu-contents assertions must run on a settled auth state)."""
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=10_000)
|
||||
"""Wait until whoami has resolved for the admin: the whoami reveal
|
||||
has un-hidden the admin-only nav links (the menu-contents assertions
|
||||
must run on a settled auth state). The nav link is the
|
||||
viewport-independent settled signal — the sign-out control is the
|
||||
bar copy on desktop but the #sign-out-btn-mobile dropdown copy at
|
||||
≤640px (phase-46 UX revision), so it is not a cross-viewport
|
||||
probe."""
|
||||
page.wait_for_function(
|
||||
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
|
||||
timeout=10_000,
|
||||
@@ -205,7 +214,7 @@ def test_admin_menu_contents(
|
||||
browser: Browser, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""AC2 (admin): at 375px the opened menu shows ALL FOUR links —
|
||||
Chat / Sources / Git sources / Tuning — i.e. the whoami reveal
|
||||
Chat / RAG / Sources / Tuning — i.e. the whoami reveal
|
||||
works inside the menu exactly as it does inline (one <nav>, one
|
||||
set of links, the same hidden attributes header.js drives)."""
|
||||
page = _mobile_page(browser)
|
||||
|
||||
@@ -19,19 +19,22 @@ sources, document viewer, global tuning, login): one shared markup block
|
||||
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) + #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;
|
||||
(four links, that order — the "Sources" link joined in phase 35 as
|
||||
"Git sources", owner permission 2026-08-26) + #sign-out-btn (with
|
||||
#sign-in-link
|
||||
hidden) — on all five pages, same id+class inventory, same DOM order.
|
||||
The #sync-btn (Sources page only) and the #new-chat-btn (chat page
|
||||
only) left the shared bar at owner request (2026-08-28 — they are
|
||||
page-specific now, so the per-page visible inventory differs for
|
||||
exactly those two); the #steering-toggle was removed from the navbar
|
||||
the same day; 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 (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).
|
||||
#nav-tuning hidden — locked A10 UI revision) + #sign-in-link (with
|
||||
#sign-out-btn hidden; the Sources page's #sync-btn stays ship-hidden)
|
||||
on all 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,
|
||||
@@ -47,9 +50,10 @@ 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).
|
||||
present, not triggered: #sync-btn is visible on /sources.html (its
|
||||
home since the owner rework 2026-08-28) 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 /
|
||||
@@ -65,7 +69,7 @@ Test → story mapping (Playwright Mapping Rule):
|
||||
3. ``test_viewer_row1_height_matches_chat_and_titlebar_present``
|
||||
4. ``test_viewer_back_link_honors_back_param``
|
||||
5. ``test_steering_surface_off_chat_on_tuning_page``
|
||||
6. ``test_sync_button_present_on_tuning_page_without_triggering``
|
||||
6. ``test_sync_button_present_on_sources_page_without_triggering``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -177,7 +181,11 @@ def _header_inventory(page: Page) -> list[str]:
|
||||
``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}"
|
||||
# Shared bar: brand + the four nav links + Sign in + Sign out = 7
|
||||
# (the #sync-btn / #new-chat-btn selectors keep matching nothing —
|
||||
# they left the bar with the owner rework 2026-08-28; the steering
|
||||
# toggle was removed the same day).
|
||||
assert len(inv) >= 7, f"header control inventory unexpectedly short: {inv}"
|
||||
return inv
|
||||
|
||||
|
||||
@@ -220,8 +228,9 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
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.
|
||||
# Phase 35: the fourth admin-only nav link (now "Sources",
|
||||
# shipped as "Git sources") is revealed on every page, between
|
||||
# RAG and Tuning.
|
||||
expect(page.locator("#nav-git-sources")).to_be_visible()
|
||||
expect(page.locator("#nav-tuning")).to_be_visible()
|
||||
# The steering toggle was removed from the navbar at owner
|
||||
@@ -229,7 +238,15 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
f"{name}: the steering toggle was removed from the navbar"
|
||||
)
|
||||
# The Sync button is a page-specific control (Sources page only
|
||||
# — owner rework 2026-08-28): visible on sources, absent from
|
||||
# the shared bar everywhere else.
|
||||
if name == "sources":
|
||||
expect(page.locator("#sync-btn")).to_be_visible()
|
||||
else:
|
||||
assert page.locator("#sync-btn").count() == 0, (
|
||||
f"{name}: #sync-btn left the shared bar (Sources page only)"
|
||||
)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||
else:
|
||||
@@ -252,7 +269,14 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
assert page.locator("#steering-panel").count() == 0, (
|
||||
f"{name}: the steering panel must be absent for anonymous"
|
||||
)
|
||||
# The New chat button is chat-page only (moved from the shared bar
|
||||
# to index.html's .chat-shell at owner request, 2026-08-28).
|
||||
if name == "chat":
|
||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||
else:
|
||||
assert page.locator("#new-chat-btn").count() == 0, (
|
||||
f"{name}: #new-chat-btn left the shared bar (chat page only)"
|
||||
)
|
||||
|
||||
_assert_landmarks(page, name)
|
||||
return _header_inventory(page)
|
||||
@@ -302,10 +326,12 @@ def _admin_login_page_inventory(page: Page, app_url: str) -> list[str]:
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
"login: the steering toggle was removed from the navbar"
|
||||
)
|
||||
expect(page.locator("#sync-btn")).to_be_visible()
|
||||
# Page-specific controls are NOT on the auth page (owner rework
|
||||
# 2026-08-28: sync → Sources page, new chat → chat page).
|
||||
assert page.locator("#sync-btn").count() == 0
|
||||
assert page.locator("#new-chat-btn").count() == 0
|
||||
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:
|
||||
@@ -482,14 +508,16 @@ def test_steering_surface_off_chat_on_tuning_page(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sync_button_present_on_tuning_page_without_triggering(
|
||||
def test_sync_button_present_on_sources_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)
|
||||
# The button's home is the Sources page (owner rework 2026-08-28 —
|
||||
# it left the shared navbar).
|
||||
login(page, app_url, next=SOURCES_URL)
|
||||
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
|
||||
btn = page.locator("#sync-btn")
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
"""Phase 48 story E2E (Playwright): the nav rename — "Sources" becomes
|
||||
"RAG", "Git sources" becomes "Sources".
|
||||
|
||||
Story: ``.agent/user_stories/nav-sources-rag-rename.md``
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_nav_rename_sources.py -v --no-cov
|
||||
|
||||
Owner request (2026-08-28): the two admin-only nav items read like the
|
||||
same thing, so they are relabeled — the document-catalog link
|
||||
(``#nav-sources`` → /sources.html) becomes **"RAG"** and the
|
||||
source-manager link (``#nav-git-sources`` → /git-sources.html) becomes
|
||||
**"Sources"**. Everything else is UNCHANGED (the phase's locked
|
||||
decision, label-only): element ids, hrefs, the physical nav order, the
|
||||
phase-16/19/35 ship-hidden/reveal contract, ``header.js`` behavior, and
|
||||
every other label on the pages — the document viewer's "Sources" back
|
||||
button (a different control, phase 13) and the "Sync sources" button
|
||||
(phase 32) in particular.
|
||||
|
||||
The six pages under test (all carry the one shared header, phase
|
||||
19/34): chat (/), the RAG catalog (/sources.html), the Sources manager
|
||||
(/git-sources.html), Tuning (/tuning.html), the login page
|
||||
(/login.html), and the document viewer (/document.html — seeded with
|
||||
one fixture document row first, the test_nav_consistency.py viewer
|
||||
pattern; #doc-title must settle before any bar assertion).
|
||||
|
||||
Contract under test (desktop viewport 1280×800, settled whoami state —
|
||||
every assertion waits for the initSharedHeader pass to land first):
|
||||
|
||||
* admin: on EACH of the six pages ``#nav-sources`` is visible with the
|
||||
exact text "RAG" and href /sources.html, ``#nav-git-sources`` is
|
||||
visible with the exact text "Sources" and href /git-sources.html,
|
||||
and the nav DOM order reads Chat, RAG, Sources, Tuning; the
|
||||
current page's link is the ONLY one carrying is-active +
|
||||
aria-current="page" (the login and viewer pages mark none —
|
||||
neither is a nav page).
|
||||
* from the chat page: clicking "RAG" (``#nav-sources``) lands on
|
||||
/sources.html with that link active; clicking "Sources"
|
||||
(``#nav-git-sources``) lands on /git-sources.html with that link
|
||||
active.
|
||||
* anonymous: on / and /login.html both links are PRESENT in the DOM
|
||||
(the ship-hidden contract — header.js toggles the hidden attribute,
|
||||
the markup is never removed) but hidden, and #sign-in-link is
|
||||
visible.
|
||||
* the rename did not leak: on /sources.html the Sync button still
|
||||
reads "Sync sources" (``#sync-label``), and on the settled viewer
|
||||
page the back button's span still reads "Sources" (href
|
||||
/sources.html).
|
||||
|
||||
Determinism note: the seed truncates documents/chunks/query_log/
|
||||
steering_notes and re-imports the fixture docs (mock embeddings) so the
|
||||
viewer URL resolves to "Kubernetes Homelab Cluster" on every run. No
|
||||
chat turn is submitted and #sync-btn is never clicked.
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_admin_labels_on_all_six_pages``
|
||||
2. ``test_click_navigates_with_marker``
|
||||
3. ``test_anonymous_sees_neither``
|
||||
4. ``test_untouched_controls_stay``
|
||||
"""
|
||||
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"
|
||||
|
||||
CHAT_URL = "/"
|
||||
SOURCES_URL = "/sources.html"
|
||||
GIT_SOURCES_URL = "/git-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 six pages of the app, in the story's order.
|
||||
SIX_PAGES = (
|
||||
("chat", CHAT_URL),
|
||||
("sources", SOURCES_URL),
|
||||
("git-sources", GIT_SOURCES_URL),
|
||||
("tuning", TUNING_URL),
|
||||
("login", LOGIN_URL),
|
||||
("viewer", VIEWER_URL),
|
||||
)
|
||||
|
||||
#: The locator of the link that carries the current-page marker on each
|
||||
#: page (None — login and viewer are not nav pages — marks none).
|
||||
CURRENT_LINK: dict[str, str | None] = {
|
||||
"chat": ".app-nav a[href='/']",
|
||||
"sources": "#nav-sources",
|
||||
"git-sources": "#nav-git-sources",
|
||||
"tuning": "#nav-tuning",
|
||||
"login": None,
|
||||
"viewer": None,
|
||||
}
|
||||
|
||||
#: The four primary nav links, in their physical DOM order — the labels
|
||||
#: after the phase-48 swap (ids/hrefs unchanged).
|
||||
NAV_LABELS = ("Chat", "RAG", "Sources", "Tuning")
|
||||
|
||||
#: The login.js script — route pattern for the redirect suppression.
|
||||
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
|
||||
|
||||
#: is-active as a word-boundary regex (to_have_class matches against the
|
||||
#: whole class string — the test_git_sources_admin.py convention).
|
||||
IS_ACTIVE = re.compile(r"\bis-active\b")
|
||||
|
||||
|
||||
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 + the fixture docs so the viewer URL resolves (the
|
||||
test_nav_consistency.py seeding pattern)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
||||
db.commit()
|
||||
_run_in_thread(_import_fixtures(mock_port))
|
||||
|
||||
|
||||
def _wait_settled_admin(page: Page) -> None:
|
||||
"""Wait for initSharedHeader's whoami toggle to land for a signed-in
|
||||
admin: the whoami reveal has un-hidden the admin-only nav links. The
|
||||
nav link is the viewport-independent settled signal (the auth pair is
|
||||
the bar copy on desktop but the dropdown copy at ≤640px, phase 46) —
|
||||
and the sign-in/out state settles in the SAME initSharedHeader pass.
|
||||
"""
|
||||
page.wait_for_function(
|
||||
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
|
||||
timeout=15_000,
|
||||
)
|
||||
|
||||
|
||||
def _wait_settled_anonymous(page: Page) -> None:
|
||||
"""Wait for the whoami toggle to land for an anonymous visitor: the
|
||||
bar Sign in copy (``#sign-in-link``) loses its ship-hidden attribute
|
||||
(probed by attribute — at ≤640px the bar copy is CSS-hidden behind
|
||||
the dropdown copy, phase 46)."""
|
||||
page.wait_for_function(
|
||||
"() => !document.querySelector('#sign-in-link').hasAttribute('hidden')",
|
||||
timeout=15_000,
|
||||
)
|
||||
|
||||
|
||||
def _assert_renamed_labels(page: Page, name: str) -> None:
|
||||
"""AC1 on one page: the swapped labels, the unchanged hrefs, and the
|
||||
nav DOM order (Chat, RAG, Sources, Tuning) on a settled admin bar."""
|
||||
rag = page.locator("#nav-sources")
|
||||
expect(rag).to_be_visible(timeout=15_000)
|
||||
expect(rag).to_have_text("RAG")
|
||||
expect(rag).to_have_attribute("href", "/sources.html")
|
||||
|
||||
git = page.locator("#nav-git-sources")
|
||||
expect(git).to_be_visible(timeout=15_000)
|
||||
expect(git).to_have_text("Sources")
|
||||
expect(git).to_have_attribute("href", "/git-sources.html")
|
||||
|
||||
# The four primary nav links (class nav-link) in physical DOM order.
|
||||
nav_texts = page.eval_on_selector_all(
|
||||
".app-nav a.nav-link", "els => els.map(e => e.textContent.trim())"
|
||||
)
|
||||
assert nav_texts == list(NAV_LABELS), (
|
||||
f"{name}: nav link order/labels are {nav_texts}, expected {list(NAV_LABELS)}"
|
||||
)
|
||||
# …and the full anchor sequence of the nav (it also carries the
|
||||
# phase-46 mobile sign-in copy) opens with the same four, in order.
|
||||
all_texts = page.eval_on_selector_all(
|
||||
".app-nav a", "els => els.map(e => e.textContent.trim())"
|
||||
)
|
||||
assert all_texts[:4] == list(NAV_LABELS), (
|
||||
f"{name}: .app-nav anchor sequence {all_texts} does not open with "
|
||||
f"Chat, RAG, Sources, Tuning"
|
||||
)
|
||||
|
||||
|
||||
def _assert_current_marker(page: Page, name: str) -> None:
|
||||
"""AC1 on one page: the current page's link carries is-active +
|
||||
aria-current="page" — and ONLY it does (login/viewer mark none)."""
|
||||
current = CURRENT_LINK[name]
|
||||
if current is None:
|
||||
assert page.locator(".app-nav a.is-active").count() == 0, (
|
||||
f"{name}: no nav page is current — no link may carry is-active"
|
||||
)
|
||||
assert page.locator('.app-nav a[aria-current="page"]').count() == 0, (
|
||||
f"{name}: no nav page is current — no link may carry aria-current"
|
||||
)
|
||||
return
|
||||
expect(page.locator(current)).to_have_class(IS_ACTIVE)
|
||||
expect(page.locator(current)).to_have_attribute("aria-current", "page")
|
||||
assert page.locator(".app-nav a.is-active").count() == 1, (
|
||||
f"{name}: exactly one nav link may carry is-active"
|
||||
)
|
||||
assert page.locator('.app-nav a[aria-current="page"]').count() == 1, (
|
||||
f"{name}: exactly one nav link may carry aria-current"
|
||||
)
|
||||
|
||||
|
||||
def _visit_admin_page(page: Page, app_url: str, name: str, url: str) -> None:
|
||||
"""Goto a page as the signed-in admin, wait for the settled header
|
||||
(and the document title on the viewer), assert the rename contract."""
|
||||
page.goto(app_url + url)
|
||||
_wait_settled_admin(page)
|
||||
if name == "viewer":
|
||||
# The document itself has settled (rendered, not Loading…/
|
||||
# not-found) before any bar assertion — the test_nav_consistency
|
||||
# viewer-pass pattern.
|
||||
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
||||
_assert_renamed_labels(page, name)
|
||||
_assert_current_marker(page, name)
|
||||
|
||||
|
||||
def _visit_login_as_admin(page: Page, app_url: str) -> None:
|
||||
"""The login page redirects a signed-in admin away (login.js —
|
||||
phase 16), so this ONE visit serves login.js with the redirect lines
|
||||
suppressed (a test-local route, the test_nav_consistency.py pattern;
|
||||
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)
|
||||
_visit_admin_page(page, app_url, "login", LOGIN_URL)
|
||||
finally:
|
||||
page.unroute(LOGIN_JS_ROUTE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Admin: the swapped labels, unchanged hrefs/order/markers, on all
|
||||
# six pages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_labels_on_all_six_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)
|
||||
|
||||
for name, url in SIX_PAGES:
|
||||
if name == "login":
|
||||
_visit_login_as_admin(page, app_url)
|
||||
else:
|
||||
_visit_admin_page(page, app_url, name, url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. The renamed links navigate: "RAG" → /sources.html (active),
|
||||
# "Sources" → /git-sources.html (active)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_click_navigates_with_marker(
|
||||
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)
|
||||
_wait_settled_admin(page)
|
||||
|
||||
# "RAG" (the renamed catalog label) → the RAG catalog page, where
|
||||
# #nav-sources is the active link.
|
||||
expect(page.locator("#nav-sources")).to_have_text("RAG")
|
||||
page.click("#nav-sources")
|
||||
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
|
||||
_wait_settled_admin(page)
|
||||
_assert_current_marker(page, "sources")
|
||||
|
||||
# "Sources" (the renamed manager label) → the Sources manager page,
|
||||
# where #nav-git-sources is the active link.
|
||||
page.goto(app_url + CHAT_URL)
|
||||
_wait_settled_admin(page)
|
||||
expect(page.locator("#nav-git-sources")).to_have_text("Sources")
|
||||
page.click("#nav-git-sources")
|
||||
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||||
_wait_settled_admin(page)
|
||||
_assert_current_marker(page, "git-sources")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Anonymous: both links present in the DOM (ship-hidden contract) but
|
||||
# hidden — #sign-in-link visible
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_sees_neither(
|
||||
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.
|
||||
for name, url in (("chat", CHAT_URL), ("login", LOGIN_URL)):
|
||||
page.goto(app_url + url)
|
||||
_wait_settled_anonymous(page)
|
||||
rag = page.locator("#nav-sources")
|
||||
git = page.locator("#nav-git-sources")
|
||||
# Present in the DOM (the markup ships, header.js toggles the
|
||||
# hidden attribute)…
|
||||
assert rag.count() == 1, f"{name}: #nav-sources must be in the DOM"
|
||||
assert git.count() == 1, f"{name}: #nav-git-sources must be in the DOM"
|
||||
# …and hidden for anonymous (the ship-hidden contract, unchanged
|
||||
# by the rename).
|
||||
expect(rag).to_be_hidden()
|
||||
expect(git).to_be_hidden()
|
||||
# The reduced bar's settled sign-in control is visible.
|
||||
expect(page.locator("#sign-in-link")).to_be_visible()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. The rename did not leak: the Sync button label and the viewer back
|
||||
# button label (different controls) are untouched
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_untouched_controls_stay(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_seed_db(mock_llm)
|
||||
|
||||
# On /sources.html (admin — the button ships hidden and the page
|
||||
# boot reveals it on the same cached whoami) the Sync button still
|
||||
# reads "Sync sources".
|
||||
login(page, app_url, next=SOURCES_URL)
|
||||
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
|
||||
_wait_settled_admin(page)
|
||||
expect(page.locator("#sync-btn")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#sync-label")).to_have_text("Sync sources")
|
||||
# Never clicked — a real sync is test_sync_button.py's job.
|
||||
|
||||
# On the settled viewer page the back button's span still reads
|
||||
# "Sources" (the viewer back link is a different control — phase 13;
|
||||
# the rename only touched the nav items).
|
||||
page.goto(app_url + VIEWER_URL)
|
||||
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")
|
||||
@@ -8,13 +8,15 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
Contract under test (owner report 2026-08-23, phase 19) — ONE bar per
|
||||
page, the same controls everywhere:
|
||||
|
||||
* chat / sources / viewer: brand + nav [Chat, Sources — admin only] +
|
||||
New Chat + Sign in / Sign out;
|
||||
* chat / sources / viewer: brand + nav [Chat, RAG — admin only, + the
|
||||
"Sources" link (phase 35, shipped as "Git sources")] + Sign in /
|
||||
Sign out. The New Chat button is chat-page only — it left the shared
|
||||
bar at owner request (2026-08-28, moved to index.html's .chat-shell);
|
||||
* document viewer: the standard bar (row 1) + back + title + meta in a
|
||||
second titlebar row (phase 34, owner confirmation 2026-08-26 — the
|
||||
viewer's old "no nav" single-row bar is superseded; it now carries
|
||||
the SAME nav contract as every other page);
|
||||
* the "Sources" nav link (``#nav-sources``) is HIDDEN for anonymous
|
||||
* the "RAG" nav link (``#nav-sources``) is HIDDEN for anonymous
|
||||
users on every page and shown for admin (phase-16 UX revision with
|
||||
owner permission; the soft-gate page and the A10 API split are
|
||||
untouched) — now on the viewer as well (phase 34);
|
||||
@@ -33,7 +35,7 @@ Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_anonymous_bar_on_all_pages``
|
||||
2. ``test_admin_bar_on_all_pages``
|
||||
3. ``test_sources_nav_hidden_for_anonymous_everywhere``
|
||||
4. ``test_new_chat_from_sources_clears_and_navigates``
|
||||
4. ``test_new_chat_button_is_chat_page_only``
|
||||
5. ``test_sign_out_from_viewer_returns_to_anonymous``
|
||||
6. ``test_mobile_bar_fits_and_heights_held``
|
||||
"""
|
||||
@@ -136,17 +138,33 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str, mobile: bool = Fa
|
||||
pinned by ``test_mobile_hamburger_nav.py`` (phase 46, task 03); this
|
||||
helper pins the bar-level contract only.
|
||||
"""
|
||||
# Settled auth state: exactly one of Sign in / Sign out is visible
|
||||
# (phase-16 semantics, now owned by the shared module).
|
||||
# Settled auth state: exactly one of Sign in / Sign out is
|
||||
# revealed (phase-16 semantics, now owned by the shared module).
|
||||
# Both probes are viewport-independent attribute checks — at ≤640px
|
||||
# the bar auth copies are CSS-hidden behind the #sign-in-link-mobile
|
||||
# / #sign-out-btn-mobile dropdown copies (phase-46 UX revision), so
|
||||
# visibility is not a cross-viewport probe.
|
||||
if admin:
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
page.wait_for_function(
|
||||
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
|
||||
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)
|
||||
page.wait_for_function(
|
||||
"() => !document.querySelector('#sign-in-link').hasAttribute('hidden')",
|
||||
timeout=15_000,
|
||||
)
|
||||
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||
|
||||
# New Chat is on the bar on every page kind (the owner's ask).
|
||||
# The New chat button is chat-page only (moved from the shared bar
|
||||
# to index.html's .chat-shell at owner request, 2026-08-28).
|
||||
if page_kind == "chat":
|
||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||
else:
|
||||
assert page.locator("#new-chat-btn").count() == 0, (
|
||||
f"{page_kind}: the New chat button is chat-page only"
|
||||
)
|
||||
|
||||
# Phase 34: EVERY page kind — viewer included — carries the SAME
|
||||
# nav contract: the Chat link always visible; the admin-only
|
||||
@@ -246,7 +264,7 @@ def test_admin_bar_on_all_pages(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. The Sources nav link: hidden for anonymous everywhere, revealed
|
||||
# 3. The RAG nav link: hidden for anonymous everywhere, revealed
|
||||
# after a real login (a toggle, not just initial state)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -282,51 +300,26 @@ def test_sources_nav_hidden_for_anonymous_everywhere(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. New Chat from a non-chat page: clear the conversation, land on the
|
||||
# chat empty state
|
||||
# 4. The New chat button is chat-page only (owner rework 2026-08-28 —
|
||||
# it left the shared bar with the 'go to the chat, fresh' behavior)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_new_chat_from_sources_clears_and_navigates(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
def test_new_chat_button_is_chat_page_only(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_seed_db(mock_llm)
|
||||
|
||||
# Seed the phase-14 conversation before any page script runs. The
|
||||
# init script runs on EVERY navigation, so it is scoped to the
|
||||
# sources page — the post-click navigation to "/" must start clean.
|
||||
page.add_init_script(
|
||||
"""(() => {
|
||||
if (location.pathname !== "/sources.html") return;
|
||||
try {
|
||||
localStorage.setItem("bor.chat.v1", JSON.stringify({
|
||||
v: 1,
|
||||
messages: [
|
||||
{ who: "user", text: "hello brain" },
|
||||
{ who: "brain", text: "hey there" }
|
||||
]
|
||||
}));
|
||||
} catch {}
|
||||
})();"""
|
||||
)
|
||||
# No non-chat page carries the button anymore…
|
||||
for path in (SOURCES_URL, VIEWER_URL, "/tuning.html", "/login.html", "/git-sources.html"):
|
||||
page.goto(app_url + path)
|
||||
expect(page.locator("#new-chat-btn")).to_have_count(0)
|
||||
|
||||
page.goto(app_url + SOURCES_URL)
|
||||
# The seeded conversation is in storage…
|
||||
assert (
|
||||
page.evaluate("() => localStorage.getItem('bor.chat.v1')") is not None
|
||||
), "init script must have seeded the phase-14 conversation key"
|
||||
|
||||
# New Chat from the sources page: a new chat means going to the
|
||||
# chat — fresh.
|
||||
page.click("#new-chat-btn")
|
||||
expect(page).to_have_url(app_url + "/", timeout=30_000)
|
||||
|
||||
# …and the chat lands on its empty state with the key removed.
|
||||
expect(page.locator("#empty-state")).to_be_visible()
|
||||
expect(page.locator(".msg")).to_have_count(0)
|
||||
assert page.evaluate("() => localStorage.getItem('bor.chat.v1')") is None, (
|
||||
"New Chat from a non-chat page must clear the localStorage key"
|
||||
)
|
||||
# …and the chat page has exactly one (visible, inside .chat-shell).
|
||||
page.goto(app_url + "/")
|
||||
expect(page.locator("#new-chat-btn")).to_have_count(1)
|
||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user