diff --git a/docker-compose.yml b/docker-compose.yml index ed95783b..1be09d8d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,7 @@ services: # Mount OIDC development directory outside /var/www/html to avoid rsync conflicts # The post-installation hook will register /opt/apps as an additional app directory #- ./third_party:/opt/apps:ro - #- ./third_party/astrolabe:/opt/apps/astrolabe:ro + - ./third_party/astrolabe:/opt/apps/astrolabe:ro #- ./third_party/oidc:/opt/apps/oidc:ro environment: - NEXTCLOUD_TRUSTED_DOMAINS=app @@ -160,7 +160,21 @@ services: - TOKEN_STORAGE_DB=/app/data/tokens.db - ENABLE_SEMANTIC_SEARCH=true - - VECTOR_SYNC_SCAN_INTERVAL=60 + # Tuned cadence for the multi-user background-sync integration suite, + # which provisions a user, creates a note, then waits ~90s for it to be + # indexed. Two independent knobs matter here: + # * USER_POLL_INTERVAL gates how fast the background-sync user manager + # *discovers* a freshly-provisioned user (its scanner runs an initial + # scan immediately on start). The 60s default left too little of the + # 90s budget, so drop it to 5s for prompt discovery. + # * SCAN_INTERVAL gates *re-scan* churn. Each scan re-queues the user's + # whole corpus, so a very short interval (e.g. 5s) floods the single + # processor worker faster than it drains on slower CI runners + # (pending climbs, indexed stays 0). Keep it moderate: the immediate + # initial scan already indexes the corpus once; 30s re-scans avoid + # the flood while still catching a note created just after that scan. + - VECTOR_SYNC_USER_POLL_INTERVAL=5 + - VECTOR_SYNC_SCAN_INTERVAL=30 - VECTOR_SYNC_PROCESSOR_WORKERS=1 # OAuth credentials for background sync (optional - uses DCR if not provided) diff --git a/tests/integration/test_astrolabe_multi_user_background_sync.py b/tests/integration/test_astrolabe_multi_user_background_sync.py index eeb32c23..21477eb4 100644 --- a/tests/integration/test_astrolabe_multi_user_background_sync.py +++ b/tests/integration/test_astrolabe_multi_user_background_sync.py @@ -18,17 +18,59 @@ password minted + forwarded to MCP → background sync active → DB verificatio """ import logging +import re import subprocess import tempfile import anyio import pytest -from playwright.async_api import Page +from playwright.async_api import Locator, Page logger = logging.getLogger(__name__) pytestmark = [pytest.mark.integration, pytest.mark.multi_user_basic] +# Accessible-name fallbacks for the background-sync buttons. The stable element +# ids (#mcp-enable-background-button / #mcp-revoke-background-button) are the +# primary, refactor-proof hook restored in the Astrolabe frontend; resolving by +# button text as well means a future id rename degrades to a slower-but-working +# lookup instead of a hard 5s Playwright timeout. +_ENABLE_NAME = re.compile("Enable background indexing", re.IGNORECASE) +_REVOKE_NAME = re.compile("Disable background indexing", re.IGNORECASE) + + +async def _resolve_settings_button( + page: Page, button_id: str, name: re.Pattern, *, timeout: int = 5000 +) -> Locator: + """Resolve a background-sync settings button, preferring its stable id. + + Waits up to ``timeout`` for ``button_id`` to become visible; if it never + appears, falls back to the button's accessible name. Returning the id + locator unchanged when present keeps the match unambiguous (the id button + also matches by text, so a combined ``.or_()`` would strict-mode-violate). + """ + by_id = page.locator(button_id) + try: + await by_id.wait_for(timeout=timeout, state="visible") + return by_id + except Exception: + logger.warning( + "%s not found; falling back to accessible-name lookup", button_id + ) + by_name = page.get_by_role("button", name=name) + await by_name.wait_for(timeout=timeout, state="visible") + return by_name + + +async def _background_sync_enabled(page: Page) -> bool: + """True when the settings page is in the enabled state (revoke control shown). + + Checks the stable id first, then the accessible name as a fallback. + """ + if await page.locator("#mcp-revoke-background-button").count() > 0: + return True + return await page.get_by_role("button", name=_REVOKE_NAME).count() > 0 + async def login_to_nextcloud(page: Page, username: str, password: str): """Helper function to login to Nextcloud via Playwright. @@ -113,19 +155,20 @@ async def enable_background_sync(page: Page, username: str) -> bool: ) await anyio.sleep(1) - if await page.locator("#mcp-revoke-background-button").count() > 0: + if await _background_sync_enabled(page): logger.info("✓ Background indexing already enabled for %s", username) return True - enable_button = page.locator("#mcp-enable-background-button") - await enable_button.wait_for(timeout=5000, state="visible") + enable_button = await _resolve_settings_button( + page, "#mcp-enable-background-button", _ENABLE_NAME + ) await enable_button.click() logger.info("Clicked 'Enable background indexing' for %s", username) # On success the page JS reloads to the enabled state (revoke form shown). try: - await page.locator("#mcp-revoke-background-button").wait_for( - timeout=15000, state="visible" + await _resolve_settings_button( + page, "#mcp-revoke-background-button", _REVOKE_NAME, timeout=15000 ) logger.info("✓ Background indexing enabled for %s", username) return True @@ -507,21 +550,19 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool: # Wait for page to load await anyio.sleep(1) - # The revoke form (#mcp-revoke-background-form) is only rendered while - # background indexing is enabled. - revoke_button = page.locator("#mcp-revoke-background-button") - try: - if await revoke_button.count() == 0: - logger.warning( - "Background indexing not enabled for %s, nothing to revoke", username - ) - return False - except Exception: - logger.warning("Could not find revoke button for %s", username) + # The revoke control (#mcp-revoke-background-button, inside the + # #mcp-revoke-background-form container) is only rendered while background + # indexing is enabled. + if not await _background_sync_enabled(page): + logger.warning( + "Background indexing not enabled for %s, nothing to revoke", username + ) return False try: - await revoke_button.wait_for(timeout=5000, state="visible") + revoke_button = await _resolve_settings_button( + page, "#mcp-revoke-background-button", _REVOKE_NAME + ) logger.info("Found 'Disable background indexing' button") except Exception: screenshot_path = ( @@ -586,9 +627,10 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool: pass # After revoke + reload the settings page returns to the un-provisioned - # state: the revoke button is gone and the app-password input is shown again. + # state: the revoke button is gone and the enable button is shown again. try: - if await page.locator("#mcp-revoke-background-button").is_visible(timeout=2000): + await anyio.sleep(2) + if await _background_sync_enabled(page): logger.error("Revoke button still visible for %s after revoke!", username) screenshot_path = ( f"{tempfile.gettempdir()}/astrolabe_revoke_still_enabled_{username}.png" diff --git a/third_party/astrolabe b/third_party/astrolabe index 0d9edc64..af55853a 160000 --- a/third_party/astrolabe +++ b/third_party/astrolabe @@ -1 +1 @@ -Subproject commit 0d9edc647dc5eae04292e7e048c7590c93763d93 +Subproject commit af55853adfe1041dbae289a668f2e67e3ba2ed90