From af27ce67a009b1ee46d54981631c52add4ea01e0 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:08:01 -0700 Subject: [PATCH 1/7] extend assert_no_spinner to support other types of spinning things --- .../src/aio_lanraragi_tests/utils/playwright.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/integration_tests/src/aio_lanraragi_tests/utils/playwright.py b/integration_tests/src/aio_lanraragi_tests/utils/playwright.py index 45e589ea..b74d9408 100644 --- a/integration_tests/src/aio_lanraragi_tests/utils/playwright.py +++ b/integration_tests/src/aio_lanraragi_tests/utils/playwright.py @@ -73,9 +73,16 @@ async def switch_display_mode(page: playwright.async_api._generated.Page, mode: await page.locator("ul.context-menu-list").wait_for(state="hidden") async def assert_no_spinner(page: playwright.async_api.Page, timeout_ms: int = 3000): - """Assert that the reader loading spinner is gone within timeout_ms.""" + """ + Assert that no spinners are active that can indicate something is loading when it shouldn't. + """ await page.wait_for_function( - """() => !document.querySelector('#i3.loading')""", + """() => { + const readerSpinning = document.querySelector('#i3.loading') !== null; + const indexProc = document.querySelector('#progress'); + const indexSpinning = indexProc !== null && indexProc.offsetParent !== null; + return !readerSpinning && !indexSpinning; + }""", timeout=timeout_ms, ) From 7e8bfca9ee4e86b4d355183c57a5ec437c0f7d51 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:41:44 -0700 Subject: [PATCH 2/7] ban error toasts --- .../aio_lanraragi_tests/utils/playwright.py | 10 ++++ integration_tests/tests/simple/test_index.py | 52 +++++++++++++++++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/integration_tests/src/aio_lanraragi_tests/utils/playwright.py b/integration_tests/src/aio_lanraragi_tests/utils/playwright.py index b74d9408..cc4d59ad 100644 --- a/integration_tests/src/aio_lanraragi_tests/utils/playwright.py +++ b/integration_tests/src/aio_lanraragi_tests/utils/playwright.py @@ -86,6 +86,16 @@ async def assert_no_spinner(page: playwright.async_api.Page, timeout_ms: int = 3 timeout=timeout_ms, ) +async def assert_toasts_ok(page: playwright.async_api.Page): + """ + Assert that none of LRR toast messages have severity error. + """ + error_toasts = page.locator(".Toastify__toast--error") + count = await error_toasts.count() + if count: + messages = [(await error_toasts.nth(i).inner_text()).strip() for i in range(count)] + raise AssertionError(f"Expected no error toasts, found {count}: {messages}") + async def get_image_bytes_from_responses( responses: list[playwright.async_api._generated.Response], img_src: str, diff --git a/integration_tests/tests/simple/test_index.py b/integration_tests/tests/simple/test_index.py index 8121a46e..8c747c4f 100644 --- a/integration_tests/tests/simple/test_index.py +++ b/integration_tests/tests/simple/test_index.py @@ -29,6 +29,7 @@ from aio_lanraragi_tests.utils.playwright import ( assert_browser_responses_ok, assert_console_logs_ok, + assert_toasts_ok, switch_display_mode, ) @@ -104,6 +105,7 @@ async def test_header_click_sort( # switch to compact/table mode await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) responses.clear() console_evts.clear() @@ -189,6 +191,7 @@ async def on_asc_response(response: playwright.async_api._generated.Response) -> await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -333,6 +336,7 @@ async def assert_header_sort(page: playwright.async_api._generated.Page, header_ await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) responses.clear() console_evts.clear() # <<<<< 2 COLUMNS (DEFAULT: ARTIST, SERIES) <<<<< @@ -371,6 +375,7 @@ async def assert_header_sort(page: playwright.async_api._generated.Page, header_ await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) responses.clear() console_evts.clear() # <<<<< 3 COLUMNS (CHANGE COLUMN COUNT) <<<<< @@ -435,6 +440,7 @@ async def assert_header_sort(page: playwright.async_api._generated.Page, header_ await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) # <<<<< EDIT COLUMN 3 NAMESPACE <<<<< finally: await bc.close() @@ -450,13 +456,20 @@ async def test_index_page(lrr_client: LRRClient) -> None: """ Test that the index page loads without errors. - 1. Navigate to index page. - 2. Expect no HTTP errors and no console errors. + 1. Verify the server has no archives. + 2. Navigate to index page. + 3. Expect the empty-library carousel search to return 200. + 4. Expect no HTTP errors and no console errors. """ # >>>>> TEST CONNECTION STAGE >>>>> _, error = await lrr_client.misc_api.get_server_info() assert not error, f"Failed to connect to the LANraragi server (status {error.status}): {error.error}" + + response, error = await lrr_client.archive_api.get_all_archives() + assert not error, f"Failed to get all archives (status {error.status}): {error.error}" + assert len(response.data) == 0, "Server contains archives!" + del response, error # <<<<< TEST CONNECTION STAGE <<<<< # >>>>> UI STAGE >>>>> @@ -469,19 +482,44 @@ async def test_index_page(lrr_client: LRRClient) -> None: responses: list[playwright.async_api._generated.Response] = [] console_evts: list[playwright.async_api._generated.ConsoleMessage] = [] - page.on("response", lambda response: responses.append(response)) + carousel_search_future: asyncio.Future = asyncio.get_event_loop().create_future() + + async def on_response(response: playwright.async_api._generated.Response) -> None: + responses.append(response) + if carousel_search_future.done(): + return + if response.request.method != "GET": + return + if "/api/search?" not in response.url: + return + if "draw=" in response.url or "random" in response.url or "cache" in response.url: + return + carousel_search_future.set_result(response) + + page.on("response", on_response) page.on("console", lambda console: console_evts.append(console)) await page.goto(lrr_client.lrr_base_url) await page.wait_for_load_state("domcontentloaded") await page.wait_for_load_state("networkidle") + carousel_search_response = await asyncio.wait_for(carousel_search_future, timeout=10) + assert carousel_search_response.status == 200, ( + "Expected empty-library carousel search to return 200, " + f"got {carousel_search_response.status}: {carousel_search_response.url}" + ) + carousel_search_body = json.loads(await carousel_search_response.text()) + assert carousel_search_body["recordsTotal"] == 0 + assert carousel_search_body["recordsFiltered"] == 0 + assert carousel_search_body["data"] == [] + # dismiss new version overlay if present if "New Version Release Notes" in await page.content(): await page.keyboard.press("Escape") await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -619,6 +657,7 @@ async def on_search_response(response: playwright.async_api._generated.Response) # switch to compact/table mode and verify custom column headers are visible await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) responses.clear() console_evts.clear() @@ -633,6 +672,7 @@ async def on_search_response(response: playwright.async_api._generated.Response) # switch back to thumbnail mode and select series sort await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) responses.clear() console_evts.clear() @@ -675,6 +715,7 @@ async def on_series_search_response(response: playwright.async_api._generated.Re # switch back to title sort await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) responses.clear() console_evts.clear() @@ -688,6 +729,7 @@ async def on_series_search_response(response: playwright.async_api._generated.Re await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -782,6 +824,7 @@ async def test_search_autocomplete_namespace_exclusion( await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -858,6 +901,7 @@ async def test_search_autocomplete_namespace_exclusion( await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -1012,6 +1056,7 @@ async def on_unpin_response(response: playwright.async_api._generated.Response) await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) responses.clear() console_evts.clear() @@ -1058,6 +1103,7 @@ async def on_unpin_response(response: playwright.async_api._generated.Response) await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() From 3df607984c4ab034c33482f52f2e0d09caa98516 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:21:17 -0700 Subject: [PATCH 3/7] enforce additional guards for all playwright tests, require user actions instead of API calls --- .../aio_lanraragi_tests/utils/playwright.py | 81 ++++++++ integration_tests/tests/simple/test_edit.py | 19 +- integration_tests/tests/simple/test_index.py | 181 ++++-------------- integration_tests/tests/simple/test_light.py | 2 + integration_tests/tests/simple/test_reader.py | 50 ++--- integration_tests/tests/test_auth.py | 13 ++ integration_tests/tests/test_datatables.py | 43 ++--- integration_tests/tests/test_openapi.py | 2 + 8 files changed, 180 insertions(+), 211 deletions(-) diff --git a/integration_tests/src/aio_lanraragi_tests/utils/playwright.py b/integration_tests/src/aio_lanraragi_tests/utils/playwright.py index cc4d59ad..b54a00da 100644 --- a/integration_tests/src/aio_lanraragi_tests/utils/playwright.py +++ b/integration_tests/src/aio_lanraragi_tests/utils/playwright.py @@ -1,4 +1,6 @@ import logging +import re +import time from urllib.parse import urlparse import playwright.async_api._generated @@ -110,3 +112,82 @@ async def get_image_bytes_from_responses( if resp.request.method == "GET" and resp.url == img_src and resp.status == 200: return await resp.body() raise AssertionError(f"Could not find browser response for img src={img_src!r}") + + +async def read_rendered_titles( + page: playwright.async_api._generated.Page, + expected_count: int, + expected_titles: list[str] | None = None, + timeout_ms: int = 10000, +) -> list[str]: + """ + Read archive titles from the rendered rows, in display order. + + Wraps `read_rendered_entries` and drops the arcid; the caller asserts on the titles. + """ + entries = await read_rendered_entries(page, expected_count, expected_titles, timeout_ms) + titles: list[str] = [] + for title, _ in entries: + titles.append(title) + return titles + + +async def wait_for_input_value( + page: playwright.async_api._generated.Page, + locator: playwright.async_api._generated.Locator, + expected: str, + timeout_ms: int = 10000, +) -> str: + """ + Return the first `expected` observation or the last observation on timeout. + """ + deadline = time.monotonic() + (timeout_ms / 1000) + value = "" + while True: + try: + value = await locator.input_value(timeout=1000) + except Exception: # noqa: BLE001 - element may be mid-rerender + value = "" + if value == expected or time.monotonic() >= deadline: + return value + await page.wait_for_timeout(200) + + +async def read_rendered_entries( + page: playwright.async_api._generated.Page, + expected_count: int, + expected_titles: list[str] | None = None, + timeout_ms: int = 10000, +) -> list[tuple[str, str]]: + """ + Read (title, arcid) for each archive rendered in the current view, in display order. + + Both come from the archive link a user clicks: its text is the title, its href carries the + id. Lets a test check rendered order and identity without reading the search response. + + Polls until the view holds `expected_count` links and, when `expected_titles` is given, + until the rendered order matches it. When `expected_titles` is given it also supplies the + expected count. On timeout the entries actually rendered are returned, so the caller's + assertion reports the real mismatch. + """ + if expected_titles is not None: + expected_count = len(expected_titles) + deadline = time.monotonic() + (timeout_ms / 1000) + while True: + grid = page.locator('#thumbs_container .id2 a[href*="/reader?id="]') + links = grid if await grid.count() else page.locator('td.title a[href*="/reader?id="]') + count = await links.count() + expired = time.monotonic() >= deadline + if count == expected_count or expired: + entries: list[tuple[str, str]] = [] + titles: list[str] = [] + for i in range(count): + link = links.nth(i) + title = (await link.inner_text()).strip() + href = await link.get_attribute("href") or "" + match = re.search(r"[?&]id=([^&]+)", href) + entries.append((title, match.group(1) if match else "")) + titles.append(title) + if expired or expected_titles is None or titles == expected_titles: + return entries + await page.wait_for_timeout(200) diff --git a/integration_tests/tests/simple/test_edit.py b/integration_tests/tests/simple/test_edit.py index de22c3e8..6f9e2467 100644 --- a/integration_tests/tests/simple/test_edit.py +++ b/integration_tests/tests/simple/test_edit.py @@ -31,6 +31,7 @@ from aio_lanraragi_tests.utils.playwright import ( assert_browser_responses_ok, assert_console_logs_ok, + assert_toasts_ok, ) LOGGER = logging.getLogger(__name__) @@ -202,6 +203,7 @@ async def test_tank_edit_archive_title_escape( await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -225,7 +227,7 @@ async def test_tankoubon_edit_page_save( 1. Upload 3 archives, create a tank, add archives to it. 2. Open /edit?id=TANK_xxx in browser, wait for form. 3. Change the title input, click Save Metadata. - 4. Capture the PUT /api/tankoubons/{id} response: expect 200. + 4. Expect the save success toast. 5. Re-fetch the tank via API, assert new name persisted. 6. Expect no HTTP errors, no console errors, no server error logs. """ @@ -287,23 +289,14 @@ async def test_tankoubon_edit_page_save( await page.keyboard.press("Escape") await asyncio.sleep(0.3) - put_future: asyncio.Future = asyncio.get_event_loop().create_future() - async def on_put(response: playwright.async_api._generated.Response) -> None: - if put_future.done(): - return - if response.request.method == "PUT" and f"/api/tankoubons/{tank_id}" in response.url: - put_future.set_result(response) - page.on("response", on_put) - await page.locator("#title").fill(new_name) await page.locator("#save-metadata").click() - - put_response = await asyncio.wait_for(put_future, timeout=10) - assert put_response.status == 200, f"Tank PUT returned {put_response.status}: {await put_response.text()}" - + # wait for the save toast before leaving the browser context + await page.locator(".Toastify__toast--success").first.wait_for(state="visible", timeout=10000) await page.wait_for_load_state("networkidle") await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() diff --git a/integration_tests/tests/simple/test_index.py b/integration_tests/tests/simple/test_index.py index 8c747c4f..3a2810f3 100644 --- a/integration_tests/tests/simple/test_index.py +++ b/integration_tests/tests/simple/test_index.py @@ -4,10 +4,8 @@ """ import asyncio -import json import logging import tempfile -from collections.abc import Callable from pathlib import Path import playwright.async_api @@ -29,8 +27,11 @@ from aio_lanraragi_tests.utils.playwright import ( assert_browser_responses_ok, assert_console_logs_ok, + assert_no_spinner, assert_toasts_ok, + read_rendered_titles, switch_display_mode, + wait_for_input_value, ) LOGGER = logging.getLogger(__name__) @@ -50,12 +51,10 @@ async def test_header_click_sort( 2. Open index page, switch to compact/table mode. 3. Click the Title column header (default is already asc, so first click toggles to desc). - Expect the header gains sorting_desc class. - - Capture the search response triggered by the header click. - - Expect archives sorted by title descending. + - Expect the rendered archives sorted by title descending. 4. Click the Title header again. - Expect the header gains sorting_asc class. - - Capture the search response. - - Expect archives sorted by title ascending. + - Expect the rendered archives sorted by title ascending. 5. Expect no HTTP errors, no console errors, no server error logs. """ @@ -125,20 +124,7 @@ async def test_header_click_sort( await page.wait_for_timeout(200) # click title header to sort descending (default is already asc) - search_future: asyncio.Future = asyncio.get_event_loop().create_future() - async def on_desc_response(response: playwright.async_api._generated.Response) -> None: - if search_future.done(): - return - if "/search" not in response.url or response.request.method != "GET" or response.status != 200: - return - body = json.loads(await response.text()) - if "data" in body and len(body["data"]) == 3: - search_future.set_result(body) - page.on("response", on_desc_response) - await title_header.click() - search_response_body = await asyncio.wait_for(search_future, timeout=10) - page.remove_listener("response", on_desc_response) await page.wait_for_load_state("networkidle") # verify header has sorting_desc class @@ -147,11 +133,10 @@ async def on_desc_response(response: playwright.async_api._generated.Response) - f"Expected sorting_desc on title header after click, got class={header_class!r}" ) - # verify title descending order: C, B, A - sorted_titles = [] - for entry in search_response_body["data"]: - sorted_titles.append(entry["title"]) - assert sorted_titles == ["archive C", "archive B", "archive A"], ( + # verify title descending order as rendered: C, B, A + expected_desc = ["archive C", "archive B", "archive A"] + sorted_titles = await read_rendered_titles(page, 3, expected_desc) + assert sorted_titles == expected_desc, ( f"Expected descending title sort, got: {sorted_titles}" ) @@ -159,20 +144,7 @@ async def on_desc_response(response: playwright.async_api._generated.Response) - responses.clear() console_evts.clear() - search_future = asyncio.get_event_loop().create_future() - async def on_asc_response(response: playwright.async_api._generated.Response) -> None: - if search_future.done(): - return - if "/search" not in response.url or response.request.method != "GET" or response.status != 200: - return - body = json.loads(await response.text()) - if "data" in body and len(body["data"]) == 3: - search_future.set_result(body) - page.on("response", on_asc_response) - await title_header.click() - search_response_body = await asyncio.wait_for(search_future, timeout=10) - page.remove_listener("response", on_asc_response) await page.wait_for_load_state("networkidle") # verify header has sorting_asc class @@ -181,11 +153,10 @@ async def on_asc_response(response: playwright.async_api._generated.Response) -> f"Expected sorting_asc on title header after second click, got class={header_class!r}" ) - # verify title ascending order: A, B, C - sorted_titles = [] - for entry in search_response_body["data"]: - sorted_titles.append(entry["title"]) - assert sorted_titles == ["archive A", "archive B", "archive C"], ( + # verify title ascending order as rendered: A, B, C + expected_asc = ["archive A", "archive B", "archive C"] + sorted_titles = await read_rendered_titles(page, 3, expected_asc) + assert sorted_titles == expected_asc, ( f"Expected ascending title sort, got: {sorted_titles}" ) @@ -260,32 +231,17 @@ async def test_compact_column_sort_with_three_columns( await trigger_stat_rebuild(lrr_client) # <<<<< STAT REBUILD STAGE <<<<< - async def capture_search_response(page: playwright.async_api._generated.Page, num_expected: int) -> tuple[asyncio.Future, Callable]: - future: asyncio.Future = asyncio.get_event_loop().create_future() - async def on_response(response: playwright.async_api._generated.Response) -> None: - if future.done(): - return - if "/search" not in response.url or response.request.method != "GET" or response.status != 200: - return - body = json.loads(await response.text()) - if "data" in body and len(body["data"]) == num_expected: - future.set_result(body) - page.on("response", on_response) - return future, on_response - async def assert_header_sort(page: playwright.async_api._generated.Page, header_locator: playwright.async_api._generated.Locator, expected_titles: list[str], expected_direction: str) -> None: - future, listener = await capture_search_response(page, num_archives) await header_locator.click() - body = await asyncio.wait_for(future, timeout=10) - page.remove_listener("response", listener) await page.wait_for_load_state("networkidle") + await assert_no_spinner(page) header_class = await header_locator.get_attribute("class") or "" assert f"sorting_{expected_direction}" in header_class, ( f"Expected sorting_{expected_direction} on header, got class={header_class!r}" ) - titles = [entry["title"] for entry in body["data"]] + titles = await read_rendered_titles(page, num_archives, expected_titles) assert titles == expected_titles, ( f"Expected {expected_direction} order {expected_titles}, got {titles}" ) @@ -458,7 +414,7 @@ async def test_index_page(lrr_client: LRRClient) -> None: 1. Verify the server has no archives. 2. Navigate to index page. - 3. Expect the empty-library carousel search to return 200. + 3. Expect the index to render no archive entries. 4. Expect no HTTP errors and no console errors. """ @@ -482,36 +438,18 @@ async def test_index_page(lrr_client: LRRClient) -> None: responses: list[playwright.async_api._generated.Response] = [] console_evts: list[playwright.async_api._generated.ConsoleMessage] = [] - carousel_search_future: asyncio.Future = asyncio.get_event_loop().create_future() - - async def on_response(response: playwright.async_api._generated.Response) -> None: - responses.append(response) - if carousel_search_future.done(): - return - if response.request.method != "GET": - return - if "/api/search?" not in response.url: - return - if "draw=" in response.url or "random" in response.url or "cache" in response.url: - return - carousel_search_future.set_result(response) - - page.on("response", on_response) + page.on("response", lambda response: responses.append(response)) page.on("console", lambda console: console_evts.append(console)) await page.goto(lrr_client.lrr_base_url) await page.wait_for_load_state("domcontentloaded") await page.wait_for_load_state("networkidle") + await assert_no_spinner(page) - carousel_search_response = await asyncio.wait_for(carousel_search_future, timeout=10) - assert carousel_search_response.status == 200, ( - "Expected empty-library carousel search to return 200, " - f"got {carousel_search_response.status}: {carousel_search_response.url}" + rendered = await read_rendered_titles(page, 0, timeout_ms=2000) + assert rendered == [], ( + f"Expected no archives rendered on an empty library, got: {rendered}" ) - carousel_search_body = json.loads(await carousel_search_response.text()) - assert carousel_search_body["recordsTotal"] == 0 - assert carousel_search_body["recordsFiltered"] == 0 - assert carousel_search_body["data"] == [] # dismiss new version overlay if present if "New Version Release Notes" in await page.content(): @@ -539,9 +477,9 @@ async def test_custom_column_sort_display( 1. Upload 3 archives with distinct artist/series tags, rebuild stat hash. 2. Open index page, wait for stat-driven namespace options to populate the sort dropdown. Expect "title", "artist", "series" present. - 3. Select "artist" from dropdown, capture the search response. + 3. Select "artist" from dropdown. - Expect dropdown retains "artist" after DataTables re-draw. - - Expect archives sorted by artist namespace (artist:Bob last in asc). + - Expect the rendered archives sorted by artist namespace (artist:Bob last in asc). 4. Switch to compact/table mode. - Expect custom column headers (#customheader1, #customheader2) visible. 5. Switch back to thumbnail mode, select "series" from dropdown. @@ -622,33 +560,18 @@ async def test_custom_column_sort_display( responses.clear() console_evts.clear() - # set up a future to capture the search response triggered by the sort change - search_future: asyncio.Future = asyncio.get_event_loop().create_future() - async def on_search_response(response: playwright.async_api._generated.Response) -> None: - if search_future.done(): - return - if "/search" not in response.url or response.request.method != "GET" or response.status != 200: - return - body = json.loads(await response.text()) - if "data" in body: - search_future.set_result(body) - page.on("response", on_search_response) - await sort_dropdown.select_option("artist") - search_response_body = await asyncio.wait_for(search_future, timeout=10) - page.remove_listener("response", on_search_response) await page.wait_for_load_state("networkidle") + await assert_no_spinner(page) sort_value = await sort_dropdown.input_value() assert sort_value == "artist", ( f"Sort dropdown should show 'artist' after selecting it. Got '{sort_value}'." ) - # verify the search response reflects artist-sorted order - sorted_titles = [] - for entry in search_response_body["data"]: - sorted_titles.append(entry["title"]) - assert len(sorted_titles) == 3, f"Expected 3 archives in search response, got {len(sorted_titles)}" + # verify the rendered order reflects artist-sorted order + sorted_titles = await read_rendered_titles(page, 3) + assert len(sorted_titles) == 3, f"Expected 3 archives rendered, got {len(sorted_titles)}" # archives 0,1 have artist:Alice, archive 2 has artist:Bob; asc order => Alice first assert sorted_titles[-1] == "test archive 2", ( f"Expected 'test archive 2' (artist:Bob) last in ascending artist sort, got order: {sorted_titles}" @@ -681,32 +604,18 @@ async def on_search_response(response: playwright.async_api._generated.Response) await page.wait_for_timeout(500) # select series from dropdown and verify sort order - search_future = asyncio.get_event_loop().create_future() - async def on_series_search_response(response: playwright.async_api._generated.Response) -> None: - if search_future.done(): - return - if "/search" not in response.url or response.request.method != "GET" or response.status != 200: - return - body = json.loads(await response.text()) - if "data" in body and len(body["data"]) == 3: - search_future.set_result(body) - page.on("response", on_series_search_response) - await sort_dropdown.select_option("series") - search_response_body = await asyncio.wait_for(search_future, timeout=10) - page.remove_listener("response", on_series_search_response) await page.wait_for_load_state("networkidle") + await assert_no_spinner(page) sort_value = await sort_dropdown.input_value() assert sort_value == "series", ( f"Sort dropdown should show 'series' after selecting it. Got '{sort_value}'." ) - # verify the search response reflects series-sorted order - sorted_titles = [] - for entry in search_response_body["data"]: - sorted_titles.append(entry["title"]) - assert len(sorted_titles) == 3, f"Expected 3 archives in search response, got {len(sorted_titles)}" + # verify the rendered order reflects series-sorted order + sorted_titles = await read_rendered_titles(page, 3) + assert len(sorted_titles) == 3, f"Expected 3 archives rendered, got {len(sorted_titles)}" # archive 2 has series:Another, archives 0,1 have series:Test; asc => Another first assert sorted_titles[0] == "test archive 2", ( f"Expected 'test archive 2' (series:Another) first in ascending series sort, got order: {sorted_titles}" @@ -1000,18 +909,8 @@ async def test_category_context_menu( pin_text = await pin_item.locator("span").first.text_content() assert pin_text == "Pin", f"Expected 'Pin', got {pin_text!r}" - # click pin, wait for PUT response - put_future: asyncio.Future = asyncio.get_event_loop().create_future() - async def on_pin_response(response: playwright.async_api._generated.Response) -> None: - if put_future.done(): - return - if f"/api/categories/{static_cat_id}" in response.url and response.request.method == "PUT": - put_future.set_result(response.status) - page.on("response", on_pin_response) + # click pin, then wait for the button to re-render with the pin marker await pin_item.click() - pin_status = await asyncio.wait_for(put_future, timeout=10) - page.remove_listener("response", on_pin_response) - assert pin_status == 200, f"Pin PUT returned status {pin_status}" await page.wait_for_load_state("networkidle") await page.wait_for_timeout(500) @@ -1025,20 +924,12 @@ async def on_pin_response(response: playwright.async_api._generated.Response) -> pin_text = await pin_item.locator("span").first.text_content() assert pin_text == "Unpin", f"Expected 'Unpin' after pin, got {pin_text!r}" - # click unpin - put_future = asyncio.get_event_loop().create_future() - async def on_unpin_response(response: playwright.async_api._generated.Response) -> None: - if put_future.done(): - return - if f"/api/categories/{static_cat_id}" in response.url and response.request.method == "PUT": - put_future.set_result(response.status) - page.on("response", on_unpin_response) + # click unpin, then wait for the pin marker to clear from the button await pin_item.click() - pin_status = await asyncio.wait_for(put_future, timeout=10) - page.remove_listener("response", on_unpin_response) - assert pin_status == 200, f"Unpin PUT returned status {pin_status}" await page.wait_for_load_state("networkidle") - await page.wait_for_timeout(500) + await wait_for_input_value( + page, page.locator(f".favtag-btn#{static_cat_id}"), "ctx-static", + ) # right-click again, verify "Pin" restored static_btn = page.locator(f".favtag-btn#{static_cat_id}") diff --git a/integration_tests/tests/simple/test_light.py b/integration_tests/tests/simple/test_light.py index 4abbc95a..e495d1d0 100644 --- a/integration_tests/tests/simple/test_light.py +++ b/integration_tests/tests/simple/test_light.py @@ -43,6 +43,7 @@ from aio_lanraragi_tests.utils.playwright import ( assert_browser_responses_ok, assert_console_logs_ok, + assert_toasts_ok, ) LOGGER = logging.getLogger(__name__) @@ -493,6 +494,7 @@ async def test_webkit_search_bar(lrr_client: LRRClient, semaphore: asyncio.Semap # check browser traffic is OK. await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() diff --git a/integration_tests/tests/simple/test_reader.py b/integration_tests/tests/simple/test_reader.py index 2db2cd05..0233f6bf 100644 --- a/integration_tests/tests/simple/test_reader.py +++ b/integration_tests/tests/simple/test_reader.py @@ -4,7 +4,6 @@ """ import asyncio -import json import logging import tempfile from pathlib import Path @@ -27,7 +26,9 @@ assert_browser_responses_ok, assert_console_logs_ok, assert_no_spinner, + assert_toasts_ok, get_image_bytes_from_responses, + read_rendered_entries, ) LOGGER = logging.getLogger(__name__) @@ -127,6 +128,7 @@ async def test_slideshow( await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -235,6 +237,7 @@ def expected_filename(page_index: int) -> str: await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -306,22 +309,15 @@ async def test_archive_navigation( LOGGER.info("Closing new releases overlay.") await page.keyboard.press("Escape") - # Collect the datatables search response from the network waterfall - # to determine the display order of archives. - search_response_body = None - for resp in responses: - if "/search" not in resp.url or resp.request.method != "GET" or resp.status != 200: - continue - body = json.loads(await resp.text()) - if "data" in body and len(body["data"]) == 3: - search_response_body = body - break - assert search_response_body is not None, "Did not find datatables search response in network waterfall" + # Read the display order of archives as rendered on the index. + await assert_no_spinner(page) + dt_entries = await read_rendered_entries(page, 3) + assert len(dt_entries) == 3, f"Expected 3 archives rendered on the index, got {len(dt_entries)}" dt_arcids = [] dt_titles = [] - for entry in search_response_body["data"]: - dt_arcids.append(entry["arcid"]) - dt_titles.append(entry["title"]) + for title, arcid in dt_entries: + dt_arcids.append(arcid) + dt_titles.append(title) LOGGER.info(f"Datatables archive order: {list(zip(dt_titles, dt_arcids))}") # Assert and clear index page responses before navigating to reader. @@ -447,6 +443,7 @@ async def test_archive_navigation( # check browser traffic is OK. await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -513,22 +510,15 @@ async def test_slideshow_continue_navigation( LOGGER.info("Closing new releases overlay.") await page.keyboard.press("Escape") - # Collect the datatables search response from the network waterfall - # to determine the display order of archives. - search_response_body = None - for resp in responses: - if "/search" not in resp.url or resp.request.method != "GET" or resp.status != 200: - continue - body = json.loads(await resp.text()) - if "data" in body and len(body["data"]) == 3: - search_response_body = body - break - assert search_response_body is not None, "Did not find datatables search response in network waterfall" + # Read the display order of archives as rendered on the index. + await assert_no_spinner(page) + dt_entries = await read_rendered_entries(page, 3) + assert len(dt_entries) == 3, f"Expected 3 archives rendered on the index, got {len(dt_entries)}" dt_arcids = [] dt_titles = [] - for entry in search_response_body["data"]: - dt_arcids.append(entry["arcid"]) - dt_titles.append(entry["title"]) + for title, arcid in dt_entries: + dt_arcids.append(arcid) + dt_titles.append(title) LOGGER.info(f"Datatables archive order: {list(zip(dt_titles, dt_arcids))}") # Assert and clear index page responses before navigating to reader. @@ -616,6 +606,7 @@ async def test_slideshow_continue_navigation( # check browser traffic is OK. await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -835,6 +826,7 @@ async def test_toc_reader( await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() diff --git a/integration_tests/tests/test_auth.py b/integration_tests/tests/test_auth.py index 0c8c776a..d06e8451 100644 --- a/integration_tests/tests/test_auth.py +++ b/integration_tests/tests/test_auth.py @@ -35,6 +35,7 @@ from aio_lanraragi_tests.utils.playwright import ( assert_browser_responses_ok, assert_console_logs_ok, + assert_toasts_ok, ) LOGGER = logging.getLogger(__name__) @@ -190,7 +191,9 @@ def endpoint_permission_granted(endpoint_is_public: bool) -> bool: # capture all network and console traffic responses: list[playwright.async_api._generated.Response] = [] + console_evts: list[playwright.async_api._generated.ConsoleMessage] = [] page.on("response", lambda response: responses.append(response)) + page.on("console", lambda console: console_evts.append(console)) await page.goto(lrr_client.lrr_base_url) await page.wait_for_load_state("networkidle") @@ -198,6 +201,8 @@ def endpoint_permission_granted(endpoint_is_public: bool) -> bool: # check browser traffic is OK. await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) + await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -231,6 +236,8 @@ async def test_ui_nofunmode_login_right_password(environment: AbstractLRRDeploym try: page = await browser.new_page() + console_evts: list[playwright.async_api._generated.ConsoleMessage] = [] + page.on("console", lambda console: console_evts.append(console)) # capture all network and console traffic responses: list[playwright.async_api._generated.Response] = [] @@ -248,6 +255,8 @@ async def test_ui_nofunmode_login_right_password(environment: AbstractLRRDeploym # check browser traffic is OK. await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) + await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -289,6 +298,7 @@ async def test_ui_nofunmode_login_empty_password(environment: AbstractLRRDeploym # check browser traffic is OK. await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -331,6 +341,7 @@ async def test_ui_nofunmode_login_wrong_password(environment: AbstractLRRDeploym # check browser traffic is OK. await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -397,6 +408,7 @@ async def test_ui_enable_nofunmode(environment: AbstractLRRDeploymentContext, is # check browser traffic is OK. await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -424,6 +436,7 @@ async def test_ui_enable_nofunmode(environment: AbstractLRRDeploymentContext, is # check browser traffic is OK. await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() diff --git a/integration_tests/tests/test_datatables.py b/integration_tests/tests/test_datatables.py index cf92ca1c..337e60d6 100644 --- a/integration_tests/tests/test_datatables.py +++ b/integration_tests/tests/test_datatables.py @@ -4,7 +4,6 @@ """ import asyncio -import json import logging import tempfile from collections.abc import AsyncGenerator, Generator @@ -25,6 +24,8 @@ assert_browser_responses_ok, assert_console_logs_ok, assert_no_spinner, + assert_toasts_ok, + read_rendered_entries, ) LOGGER = logging.getLogger(__name__) @@ -132,18 +133,15 @@ async def test_reader_to_index_cross_dt( if "New Version Release Notes" in await page.content(): await page.keyboard.press("Escape") - # Collect DT order (pagesize=3, so first page shows 3 archives). - search_response_body = None - for resp in responses: - if "/search" not in resp.url or resp.request.method != "GET" or resp.status != 200: - continue - body = json.loads(await resp.text()) - if "data" in body and len(body["data"]) == 3: - search_response_body = body - break - assert search_response_body is not None, "Did not find datatables search response with 3 archives" - dt_arcids = [entry["arcid"] for entry in search_response_body["data"]] - dt_titles = [entry["title"] for entry in search_response_body["data"]] + # Collect DT order as rendered (pagesize=3, so first page shows 3 archives). + await assert_no_spinner(page) + dt_entries = await read_rendered_entries(page, 3) + assert len(dt_entries) == 3, f"Expected 3 archives rendered on DT page 1, got {len(dt_entries)}" + dt_titles = [] + dt_arcids = [] + for title, arcid in dt_entries: + dt_titles.append(title) + dt_arcids.append(arcid) LOGGER.info(f"DT page 1 order: {list(zip(dt_titles, dt_arcids))}") responses.clear() console_evts.clear() @@ -183,6 +181,7 @@ async def test_reader_to_index_cross_dt( await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -238,17 +237,11 @@ async def test_forward_history_after_redraw( if "New Version Release Notes" in await page.content(): await page.keyboard.press("Escape") - # Find the archive in DT results. - search_response_body = None - for resp in responses: - if "/search" not in resp.url or resp.request.method != "GET" or resp.status != 200: - continue - body = json.loads(await resp.text()) - if "data" in body and len(body["data"]) == 1: - search_response_body = body - break - assert search_response_body is not None, "Did not find search response with 1 archive" - dt_title = search_response_body["data"][0]["title"] + # Find the archive as rendered in DT results. + await assert_no_spinner(page) + dt_entries = await read_rendered_entries(page, 1) + assert len(dt_entries) == 1, f"Expected 1 archive rendered, got {len(dt_entries)}" + dt_title = dt_entries[0][0] responses.clear() console_evts.clear() @@ -281,6 +274,7 @@ async def test_forward_history_after_redraw( await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() @@ -351,6 +345,7 @@ async def test_back_stack_no_growth_on_reload( await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() diff --git a/integration_tests/tests/test_openapi.py b/integration_tests/tests/test_openapi.py index 2a7338bb..53144242 100644 --- a/integration_tests/tests/test_openapi.py +++ b/integration_tests/tests/test_openapi.py @@ -33,6 +33,7 @@ from aio_lanraragi_tests.utils.playwright import ( assert_browser_responses_ok, assert_console_logs_ok, + assert_toasts_ok, ) LOGGER = logging.getLogger(__name__) @@ -337,6 +338,7 @@ async def test_validation_carousel_search(request: pytest.FixtureRequest, resour await assert_browser_responses_ok(responses, client, logger=LOGGER) await assert_console_logs_ok(console_evts, client.lrr_base_url) + await assert_toasts_ok(page) finally: await bc.close() await browser.close() From 314fda154d7c12560c72a31d55267f9a2487acf4 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:32:32 -0700 Subject: [PATCH 4/7] Draft PlaywrightTestContextManager --- .../aio_lanraragi_tests/utils/playwright.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/integration_tests/src/aio_lanraragi_tests/utils/playwright.py b/integration_tests/src/aio_lanraragi_tests/utils/playwright.py index b54a00da..c006731f 100644 --- a/integration_tests/src/aio_lanraragi_tests/utils/playwright.py +++ b/integration_tests/src/aio_lanraragi_tests/utils/playwright.py @@ -1,6 +1,8 @@ +import contextlib import logging import re import time +from typing import TypeVar from urllib.parse import urlparse import playwright.async_api._generated @@ -8,6 +10,38 @@ LOGGER = logging.getLogger(__name__) +_PlaywrightTestContextManagerLike = TypeVar('_PlaywrightTestContextManagerLike', bound='PlaywrightTestContextManager') +class PlaywrightTestContextManager(contextlib.AbstractAsyncContextManager): + """ + Async context manager for all LRR playwright related testing. Manages the following lifecycle: + + ```python + async with playwright.async_api.async_playwright() as p: + browser = await p.chromium.launch() + bc = await browser.new_context() + + try: + responses: list[playwright.async_api._generated.Response] = [] + console_evts: list[playwright.async_api._generated.ConsoleMessage] = [] + failed_requests: list[playwright.async_api._generated.Request] = [] + page.on("response", lambda response: responses.append(response)) + page.on("console", lambda console: console_evts.append(console)) + page.on("requestfailed", lambda request: failed_requests.append(request)) + finally: + await bc.close() + await browser.close() + ``` + + User of this context manager gets: + + - `page`: the page with all this tracking enabled by default. + - `assert_ok`: assert everything is OK. + - `assert_requests_ok`: assert only requests are OK. + - `assert_http_ok`: assert only browser HTTP responses are OK. + - `assert_console_ok`: assert only console logs are OK. + - `assert_toasts_ok`: assert only toasts are OK. + """ + async def assert_browser_responses_ok(responses: list[playwright.async_api._generated.Response], lrr_client: LRRClient, logger: logging.Logger=LOGGER): """ Assert that all responses captured during a Playwright browser session were normal. This means: From 86a4254e6342fe6fe42355f510f070935593f334 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:14:38 -0700 Subject: [PATCH 5/7] implement pcm and add browser_context/clear --- .../aio_lanraragi_tests/utils/playwright.py | 181 +++++++++++++++++- 1 file changed, 174 insertions(+), 7 deletions(-) diff --git a/integration_tests/src/aio_lanraragi_tests/utils/playwright.py b/integration_tests/src/aio_lanraragi_tests/utils/playwright.py index c006731f..9e5542a3 100644 --- a/integration_tests/src/aio_lanraragi_tests/utils/playwright.py +++ b/integration_tests/src/aio_lanraragi_tests/utils/playwright.py @@ -2,7 +2,8 @@ import logging import re import time -from typing import TypeVar +from types import TracebackType +from typing import TypeVar, override from urllib.parse import urlparse import playwright.async_api._generated @@ -35,21 +36,156 @@ class PlaywrightTestContextManager(contextlib.AbstractAsyncContextManager): User of this context manager gets: - `page`: the page with all this tracking enabled by default. + - `browser_context`: the owning browser context. - `assert_ok`: assert everything is OK. - `assert_requests_ok`: assert only requests are OK. - `assert_http_ok`: assert only browser HTTP responses are OK. - `assert_console_ok`: assert only console logs are OK. - `assert_toasts_ok`: assert only toasts are OK. + - `clear`: drop captured traffic, for tests that assert per stage. + + User guide: + + ```python + async with PlaywrightTestContextManager(lrr_client) as pcm: + page = pcm.page + await page.do_stuff() + # ... + await pcm.assert_ok() + ``` """ -async def assert_browser_responses_ok(responses: list[playwright.async_api._generated.Response], lrr_client: LRRClient, logger: logging.Logger=LOGGER): + @property + def logger(self) -> logging.Logger: + return self._logger + + @logger.setter + def logger(self, logger: logging.Logger): + self._logger = logger + + @property + def page(self) -> playwright.async_api._generated.Page: + """ + The page under test, with response, console and failed-request tracking attached. + """ + if self._page is None: + raise RuntimeError("page is only available inside the context manager.") + return self._page + + @property + def browser_context(self) -> playwright.async_api._generated.BrowserContext: + """ + The owning browser context. + """ + if self._browser_context is None: + raise RuntimeError("browser_context is only available inside the context manager.") + return self._browser_context + + def __init__( + self, + lrr_client: LRRClient, + browser_type: str="chromium", + logger: logging.Logger=LOGGER, + ): + """ + `browser_type` selects the Playwright browser; use the chromium default unless the test + is browser-specific. + """ + self.logger = logger + self._lrr_client: LRRClient = lrr_client + self._browser_type: str = browser_type + + self._playwright: playwright.async_api._generated.Playwright | None = None + self._browser: playwright.async_api._generated.Browser | None = None + self._browser_context: playwright.async_api._generated.BrowserContext | None = None + self._page: playwright.async_api._generated.Page | None = None + + self.responses: list[playwright.async_api._generated.Response] = [] + self.console_evts: list[playwright.async_api._generated.ConsoleMessage] = [] + self.failed_requests: list[playwright.async_api._generated.Request] = [] + + def clear(self) -> None: + """ + Drop all captured traffic, so a later assertion only covers the stage that follows. + """ + self.responses.clear() + self.console_evts.clear() + self.failed_requests.clear() + + async def assert_requests_ok(self) -> None: + await assert_no_failed_requests(self.failed_requests, self._lrr_client, logger=self.logger) + + async def assert_http_ok(self) -> None: + await assert_browser_responses_ok(self.responses, self._lrr_client, logger=self.logger) + + async def assert_console_ok(self) -> None: + await assert_console_logs_ok(self.console_evts, self._lrr_client.lrr_base_url) + + async def assert_toasts_ok(self) -> None: + await assert_toasts_ok(self.page) + + async def assert_ok(self) -> None: + """ + Assert no failed requests, no HTTP errors, no console errors and no error toasts. + """ + await self.assert_requests_ok() + await self.assert_http_ok() + await self.assert_console_ok() + await self.assert_toasts_ok() + + @override + async def __aenter__(self: _PlaywrightTestContextManagerLike) -> _PlaywrightTestContextManagerLike: + self._playwright = await playwright.async_api.async_playwright().start() + try: + browser_launcher: playwright.async_api._generated.BrowserType = getattr(self._playwright, self._browser_type) + self._browser = await browser_launcher.launch() + self._browser_context = await self._browser.new_context() + self._page = await self._browser_context.new_page() + except BaseException: + await self._teardown() + raise + + self._page.on("response", lambda response: self.responses.append(response)) + self._page.on("console", lambda console: self.console_evts.append(console)) + self._page.on("requestfailed", lambda request: self.failed_requests.append(request)) + return self + + @override + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool: + if exc_type: + self.logger.error(f"Exception occurred: {exc_type.__name__}: {exc_value}") + await self._teardown() + return False + + async def _teardown(self) -> None: + try: + if self._browser_context: + await self._browser_context.close() + finally: + try: + if self._browser: + await self._browser.close() + finally: + if self._playwright: + await self._playwright.stop() + +async def assert_browser_responses_ok( + responses: list[playwright.async_api._generated.Response], + lrr_client: LRRClient, + logger: logging.Logger=LOGGER +): """ Assert that all responses captured during a Playwright browser session were normal. This means: - Any LRR-side URL returned a 2xx, 3xx, or 401 (unauthenticated) status code. """ - lrr_hostname = urlparse(lrr_client.lrr_host).hostname - hostnames = {lrr_hostname} if lrr_hostname != '127.0.0.1' else {'127.0.0.1', 'localhost'} + lrr_hostname: str = urlparse(lrr_client.lrr_host).hostname or '' + hostnames: set[str] = {'127.0.0.1', 'localhost'} if lrr_hostname == '127.0.0.1' else {lrr_hostname} for response in responses: url = response.url @@ -78,6 +214,33 @@ async def assert_browser_responses_ok(responses: list[playwright.async_api._gene elif status >= 400: logger.warning(f"Status {status} with {response.request.method} {response.url}") +async def assert_no_failed_requests(requests: list[playwright.async_api._generated.Request], lrr_client: LRRClient, logger: logging.Logger=LOGGER): + """ + Assert that no LRR-side request failed before it received a response. This means: + + - Any LRR-side URL that never reached the server, e.g. net::ERR_CONNECTION_FAILED. + + These never produce a response, so assert_browser_responses_ok cannot see them. Cancelled + requests (net::ERR_ABORTED) are tolerated. + """ + lrr_hostname: str = urlparse(lrr_client.lrr_host).hostname or '' + hostnames: set[str] = {'127.0.0.1', 'localhost'} if lrr_hostname == '127.0.0.1' else {lrr_hostname} + + for request in requests: + url = request.url + failure = request.failure + + parsed = urlparse(url) + hostname = parsed.hostname + + if failure == "net::ERR_ABORTED": + logger.debug(f"Skipping cancelled request {url}") + continue + + if hostname in hostnames: + raise AssertionError(f"Request failed with {failure}: {request.method} {url}") + logger.warning(f"Request failed with {failure}: {request.method} {url}") + async def assert_console_logs_ok( console_evts: list[playwright.async_api._generated.ConsoleMessage], lrr_base_url: str @@ -128,9 +291,13 @@ async def assert_toasts_ok(page: playwright.async_api.Page): """ error_toasts = page.locator(".Toastify__toast--error") count = await error_toasts.count() - if count: - messages = [(await error_toasts.nth(i).inner_text()).strip() for i in range(count)] - raise AssertionError(f"Expected no error toasts, found {count}: {messages}") + for i in range(count): + text = (await error_toasts.nth(i).inner_text()).strip() + if "github" in text.lower(): + LOGGER.warning(f"Skipping external GitHub error toast: {text}") + continue + + raise AssertionError(f"Expected no error toasts, found: {text}") async def get_image_bytes_from_responses( responses: list[playwright.async_api._generated.Response], From 429800752d1b998a6fe5815fdbe443ca879f0778 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:40:08 -0700 Subject: [PATCH 6/7] bump post version --- integration_tests/pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/integration_tests/pyproject.toml b/integration_tests/pyproject.toml index 6da26024..9ab9b599 100644 --- a/integration_tests/pyproject.toml +++ b/integration_tests/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "aio-lanraragi-integration-tests" -version = "0.1.24.post4" +version = "0.1.24.post5" description = "Integration tests for aio-lanraragi" requires-python = ">=3.12, <=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index c64aa0cd..dc164385 100644 --- a/uv.lock +++ b/uv.lock @@ -51,7 +51,7 @@ dev = [ [[package]] name = "aio-lanraragi-integration-tests" -version = "0.1.24.post4" +version = "0.1.24.post5" source = { editable = "integration_tests" } dependencies = [ { name = "aio-lanraragi" }, From 597e7e357848a9450e4f22ab28d65877385f094e Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:52:24 -0700 Subject: [PATCH 7/7] assert no spinners in test_header_click_sort --- integration_tests/tests/simple/test_index.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/integration_tests/tests/simple/test_index.py b/integration_tests/tests/simple/test_index.py index 3a2810f3..f8df8a46 100644 --- a/integration_tests/tests/simple/test_index.py +++ b/integration_tests/tests/simple/test_index.py @@ -101,6 +101,8 @@ async def test_header_click_sort( await page.keyboard.press("Escape") await asyncio.sleep(0.3) + await assert_no_spinner(page) + # switch to compact/table mode await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) @@ -126,6 +128,7 @@ async def test_header_click_sort( # click title header to sort descending (default is already asc) await title_header.click() await page.wait_for_load_state("networkidle") + await assert_no_spinner(page) # verify header has sorting_desc class header_class = await title_header.get_attribute("class") or "" @@ -146,6 +149,7 @@ async def test_header_click_sort( await title_header.click() await page.wait_for_load_state("networkidle") + await assert_no_spinner(page) # verify header has sorting_asc class header_class = await title_header.get_attribute("class") or ""