Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
"devDependencies": {
"@tailwindcss/cli": "^4.3.3",
"axe-core": "^4.13.0",
"axe-core": "^4.14.0",
"tailwindcss": "^4.3.3"
}
}
2 changes: 1 addition & 1 deletion src/pullbox/ui/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ <h2 class="mt-1 text-sm font-semibold text-pb-text">Activity</h2>
{#- User dropdown -#}
{% if user %}
<div x-data="{ open: false }" class="relative">
<button @click="open = !open" @click.outside="open = false" aria-label="User menu"
<button @click="open = !open" @click.outside="open = false" aria-label="User menu for {{ user.username }}"
class="flex items-center gap-2 px-3 py-2 rounded-lg font-sans font-medium text-sm text-pb-text-sec hover:bg-pb-card-hover hover:text-pb-text transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path stroke-linecap="round" stroke-linejoin="round"
Expand Down
2 changes: 1 addition & 1 deletion src/pullbox/ui/templates/partials/app_sidebar.html
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@
>
<a
href="/"
aria-label="Pullbox — home"
aria-label="Pullbox v{{ version }} — home"
data-testid="sidebar-logo-link"
class="app-sidebar-brand shrink-0 no-underline"
:class="showSidebarLabels ? '' : 'app-sidebar-brand-collapsed px-0'"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ <h3 class="add-series-result-title">
{% if result.existing_id %}
{{ comicvine_in_library_badge() }}
{% else %}
<a href="{{ result.preview_url|default('/story-arcs/catalog/' ~ result.metadata.provider_id) }}" class="btn-primary btn-sm add-series-result-add" aria-label="Preview {{ result.metadata.title }}">Preview members</a>
<a href="{{ result.preview_url|default('/story-arcs/catalog/' ~ result.metadata.provider_id) }}" class="btn-primary btn-sm add-series-result-add" aria-label="Preview members of {{ result.metadata.title }}">Preview members</a>
{% endif %}
{% endif %}
{% endcall %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@
<div class="mt-1 flex flex-wrap items-center justify-end gap-2">
<span data-testid="whats-new-issue-state" class="badge {{ 'badge-success' if release.local_issue.state == 'owned' else 'badge-muted' }}">{{ release.local_issue.label }}</span>
{% if release.local_issue.issue_id %}
<a data-testid="whats-new-local-issue" class="btn-ghost btn-sm whitespace-nowrap" href="/issues/{{ release.local_issue.issue_id }}" aria-label="View {{ release.series.title }} issue {{ release.issue_number }}">View issue</a>
<a data-testid="whats-new-local-issue" class="btn-ghost btn-sm whitespace-nowrap" href="/issues/{{ release.local_issue.issue_id }}" aria-label="View issue {{ release.issue_number }} of {{ release.series.title }}">View issue</a>
{% endif %}
{% if release.grab_selection %}
<button type="button" class="btn-primary btn-sm" data-testid="whats-new-grab"
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/test_gcd_api_arcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def test_gcd_api_search_review_reorder_skip_and_accessibility(
expect(page.locator("html")).to_have_attribute(
"data-theme", os_scheme if theme == "system" else theme
)
link = page.get_by_role("link", name="Preview Civil War", exact=True)
link = page.get_by_role("link", name="Preview members of Civil War", exact=True)
expect(link).to_be_visible()
assert_no_axe_violations(page, name=f"gcd-api-arc-search-{theme}-{width}", include=["#content"])
link.click()
Expand Down
6 changes: 4 additions & 2 deletions tests/e2e/test_gcd_local_arcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,11 @@ def test_gcd_search_review_reorder_skip_and_caveat(
page.set_viewport_size({"width": width, "height": 900})
page.goto(f"{seeded_server}/story-arcs/add?q=Civil+War&source=gcd_local")
page.evaluate("theme => applyTheme(theme)", theme)
expect(page.get_by_role("link", name="Preview Civil War [Marvel]", exact=True)).to_be_visible()
expect(
page.get_by_role("link", name="Preview members of Civil War [Marvel]", exact=True)
).to_be_visible()
assert_no_axe_violations(page, name=f"gcd-arc-search-{theme}-{width}", include=["#content"])
page.get_by_role("link", name="Preview Civil War [Marvel]", exact=True).click()
page.get_by_role("link", name="Preview members of Civil War [Marvel]", exact=True).click()
page.wait_for_url("**/story-arcs/catalog/gcd_local/4")
expect(
page.get_by_text("GCD publication order is a starting point", exact=False)
Expand Down
64 changes: 64 additions & 0 deletions tests/e2e/test_sidebar_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from __future__ import annotations

import json
import re
from typing import TYPE_CHECKING
from urllib.parse import parse_qs, urlparse

import pytest
Expand All @@ -12,12 +15,73 @@
from tests.e2e.pages.import_page import ImportPage
from tests.e2e.pages.series_list import SeriesListPage

if TYPE_CHECKING:
from playwright.sync_api import Page

pytestmark = pytest.mark.e2e


class TestSidebarShell:
"""Behavior-first E2E checks for the persistent sidebar shell."""

@pytest.mark.parametrize("theme", ["light", "dark"])
def test_shell_accessible_names_survive_collapsed_and_mobile_layouts(
self,
authed_page: Page,
seeded_server: str,
theme: str,
) -> None:
authed_page.set_viewport_size({"width": 1280, "height": 800})
authed_page.add_init_script(
f"localStorage.setItem('pullbox-theme', {json.dumps(theme)});"
"localStorage.setItem('sidebarOpen', 'true');"
)
shell = AppShellPage(authed_page, seeded_server)
shell.goto("/series")
expect(authed_page.locator("html")).to_have_attribute("data-theme", theme)

user_menu = authed_page.get_by_role("button", name=re.compile(r"^User menu"))
username = user_menu.locator("span")
logo_copy = authed_page.locator("[data-sidebar-logo-label]")
version = authed_page.locator("[data-testid='sidebar-brand-version']")
expect(username).to_be_visible()
expect(username).to_have_text("admin")
expect(logo_copy).to_be_visible()
expect(version).to_be_visible()
version_text = version.inner_text().strip()
assert version_text.startswith("v")
logo_name = f"Pullbox {version_text} — home"
expect(user_menu).to_have_accessible_name("User menu for admin")
expect(shell.logo_link).to_have_accessible_name(logo_name)

shell.collapse_toggle.click()
expect(logo_copy).to_be_hidden()
expect(shell.logo_link).to_be_visible()
expect(shell.logo_link).to_have_accessible_name(logo_name)
expect(user_menu).to_have_accessible_name("User menu for admin")
shell.collapse_toggle.click()
expect(logo_copy).to_be_visible()

authed_page.set_viewport_size({"width": 390, "height": 844})
authed_page.get_by_role("button", name="Open navigation menu", exact=True).click()
expect(shell.mobile_backdrop).to_be_visible()
expect(logo_copy).to_be_visible()
expect(version).to_be_visible()
expect(shell.logo_link).to_have_accessible_name(logo_name)
expect(username).to_be_hidden()
expect(user_menu).to_have_accessible_name("User menu for admin")

shell.mobile_backdrop.click(position={"x": 380, "y": 400})
expect(shell.mobile_backdrop).to_be_hidden()
sign_out = authed_page.get_by_role("button", name="Sign out", exact=True)
user_menu.focus()
expect(user_menu).to_be_focused()
user_menu.press("Enter")
expect(sign_out).to_be_visible()
user_menu.press("Enter")
expect(sign_out).to_be_hidden()
expect(user_menu).to_be_focused()

def test_sidebar_shell_recovers_when_initial_stylesheet_request_fails(
self,
authed_page,
Expand Down
8 changes: 4 additions & 4 deletions tests/e2e/test_story_arc_catalog_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def test_catalog_results_use_add_series_card_layout(
card = results.locator(".add-series-result-card").first
title = card.locator(".add-series-result-title").bounding_box()
meta = card.locator(".add-series-result-meta").bounding_box()
action = card.get_by_role("link", name="Preview Numbering Event").bounding_box()
action = card.get_by_role("link", name="Preview members of Numbering Event").bounding_box()
bounds = card.bounding_box()
assert title and meta and action and bounds
assert meta["y"] >= title["y"] + title["height"], "Metadata belongs below the title"
Expand Down Expand Up @@ -170,7 +170,7 @@ def test_catalog_results_use_add_series_card_layout(
assert page.locator("#content").evaluate(
"element => element.scrollWidth <= element.clientWidth"
)
expect(card.get_by_role("link", name="Preview Numbering Event")).to_be_visible()
expect(card.get_by_role("link", name="Preview members of Numbering Event")).to_be_visible()
assert_no_axe_violations(page, name="arc-search-mobile", include=["#content"])
page.screenshot(path=str(output / "mobile.png"), full_page=True)

Expand All @@ -194,9 +194,9 @@ def test_keyboard_catalog_add_and_refresh_preserve_reviewed_order(
expect(cover).to_have_attribute(
"src", "https://comicvine.gamespot.com/a/uploads/story-arcs/42.jpg"
)
expect(page.get_by_role("link", name="Preview Numbering Event")).to_be_visible()
expect(page.get_by_role("link", name="Preview members of Numbering Event")).to_be_visible()
query.press("Tab")
page.get_by_role("link", name="Preview Numbering Event").focus()
page.get_by_role("link", name="Preview members of Numbering Event").focus()
page.keyboard.press("Enter")
page.wait_for_url("**/story-arcs/catalog/comicvine_api/42")
expect(
Expand Down
3 changes: 3 additions & 0 deletions tests/e2e/test_whats_new_issue_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ def test_real_release_state_links_reflow_and_accessibility(
expect(linked.get_by_test_id("whats-new-local-issue")).to_have_attribute(
"href", f"/issues/{issue_ids[0]}"
)
expect(linked.get_by_test_id("whats-new-local-issue")).to_have_accessible_name(
"View issue 1 of Atlas Deluxe"
)
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth + 1")
assert_no_axe_violations(page, name=f"release-issue-state-{theme}-{width}")
page.screenshot(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,12 @@ async def control():
check_control=control,
progress=noop,
)
assert (path.read_bytes(), path.stat()) == original
actual = path.read_bytes(), path.stat()
assert actual[0] == original[0]
# Reading may update atime; identity, ownership, mode and size must not change.
assert actual[1][:7] == original[1][:7]
assert actual[1].st_mtime_ns == original[1].st_mtime_ns
assert actual[1].st_ctime_ns == original[1].st_ctime_ns


async def test_restart_recovers_published_result_without_repacking(identity_probe_db, tmp_path):
Expand Down
65 changes: 64 additions & 1 deletion tests/ui/test_sidebar_shell_ui_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,21 @@
import os
import re
import sys
from html.parser import HTMLParser
from pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch

import pytest

import pullbox
from pullbox.models.user import User

if TYPE_CHECKING:
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

pytest_plugins = ["conftest_security"]
Expand All @@ -37,6 +46,27 @@
)


class _ShellLabelParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.user_menus: list[dict[str, str | None]] = []
self.logo_links: list[dict[str, str | None]] = []
self.text: list[str] = []
self.has_injected_markup = False

def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
values = dict(attrs)
if tag == "button" and (values.get("aria-label") or "").startswith("User menu"):
self.user_menus.append(values)
if tag == "a" and values.get("data-testid") == "sidebar-logo-link":
self.logo_links.append(values)
if "data-label-x" in values:
self.has_injected_markup = True

def handle_data(self, data: str) -> None:
self.text.append(data)


@pytest.mark.asyncio
class TestSidebarShellRouteContracts:
"""Verify the shared sidebar shell renders a stable, standardized contract."""
Expand Down Expand Up @@ -175,7 +205,7 @@ async def test_series_page_renders_standardized_sidebar_shell(
"justify-center rounded-lg p-0"
) in response.text
assert re.search(
r'aria-label="User menu"[^>]*font-sans[^>]*font-medium',
r'aria-label="User menu for testuser"[^>]*font-sans[^>]*font-medium',
response.text,
)
assert (
Expand All @@ -196,6 +226,39 @@ async def test_series_page_renders_standardized_sidebar_shell(
assert "window.__autoSearching" not in response.text
assert 'x-data="{ sidebarOpen:' not in response.text

@pytest.mark.parametrize(
"username",
["testuser", 'reader" onfocus="alert(1)"><i data-label-x>&'],
ids=["normal", "escaped"],
)
async def test_shell_accessible_labels_include_rendered_text_and_escape_attributes(
self,
authenticated_client: AsyncClient,
sec_db: async_sessionmaker[AsyncSession],
sec_user: User,
username: str,
) -> None:
async with sec_db() as session:
stored_user = await session.get(User, sec_user.id)
assert stored_user is not None
stored_user.username = username
await session.commit()

response = await authenticated_client.get("/series")
assert response.status_code == 200
parsed = _ShellLabelParser()
parsed.feed(response.text)
parsed.close()

assert len(parsed.user_menus) == 1
assert len(parsed.logo_links) == 1
assert parsed.user_menus[0]["aria-label"] == f"User menu for {username}"
assert parsed.logo_links[0]["aria-label"] == f"Pullbox v{pullbox.__version__} — home"
assert username in parsed.text
assert f"v{pullbox.__version__}" in parsed.text
assert "onfocus" not in parsed.user_menus[0]
assert not parsed.has_injected_markup

async def test_reader_gate_hides_reading_sidebar_entry(
self,
authenticated_client,
Expand Down
Loading
Loading