diff --git a/integ_tests/test_scenes.py b/integ_tests/test_scenes.py new file mode 100644 index 00000000..a2cd1ad6 --- /dev/null +++ b/integ_tests/test_scenes.py @@ -0,0 +1,122 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2026-2026 Max Berger +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +Integration tests for scene navigation +""" + +import pathlib +from typing import Any, Generator + +import pytest +from playwright.sync_api import Page, expect + +from integ_tests.common import create_collection, login, start_radicale_server + + +@pytest.fixture +def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + yield from start_radicale_server(tmp_path) + + +def test_navigation_create_collection_cancel(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + expect(page.locator("#collectionsscene")).to_be_visible() + + page.click('a[data-name="new"]') + expect(page.locator("#createcollectionscene")).to_be_visible() + + page.click('#createcollectionscene button[data-name="cancel"]') + expect(page.locator("#createcollectionscene")).to_be_hidden() + expect(page.locator("#collectionsscene")).to_be_visible() + + +def test_navigation_create_collection_submit(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + expect(page.locator("#collectionsscene")).to_be_visible() + + page.click('a[data-name="new"]') + expect(page.locator("#createcollectionscene")).to_be_visible() + + page.locator('#createcollectionscene input[data-name="displayname"]').fill( + "Nav Test Col" + ) + page.click('#createcollectionscene button[data-name="submit"]') + + expect(page.locator("#createcollectionscene")).to_be_hidden() + expect(page.locator("#collectionsscene")).to_be_visible() + expect(page.locator("article:has-text('Nav Test Col')")).to_be_visible() + + +def test_navigation_delete_collection_cancel(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + expect(page.locator("#collectionsscene")).to_be_visible() + + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="delete"]', force=True) + expect(page.locator("#deletecollectionscene")).to_be_visible() + + page.click('#deletecollectionscene button[data-name="cancel"]') + expect(page.locator("#deletecollectionscene")).to_be_hidden() + expect(page.locator("#collectionsscene")).to_be_visible() + + +def test_navigation_delete_collection_confirm(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + expect(page.locator("#collectionsscene")).to_be_visible() + + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="delete"]', force=True) + expect(page.locator("#deletecollectionscene")).to_be_visible() + + # We need to fill the confirmation text + confirmation_text = page.locator( + "#deletecollectionscene [data-name='deleteconfirmationtext']" + ).inner_text() + page.locator("#deletecollectionscene input[data-name='confirmationtxt']").fill( + confirmation_text + ) + page.click('#deletecollectionscene button[data-name="delete"]') + + expect(page.locator("#deletecollectionscene")).to_be_hidden() + expect(page.locator("#collectionsscene")).to_be_visible() + expect(page.locator("article:not(.hidden)")).to_have_count(0) + + +def test_navigation_refresh_button(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + expect(page.locator("#collectionsscene")).to_be_visible() + + page.click('#logoutview a[data-name="refresh"]') + # It shows LoadingScene briefly then back to CollectionsScene + expect(page.locator("#collectionsscene")).to_be_visible() + + +def test_login_logout_login(page: Page, radicale_server: str) -> None: + # 1. First login + login(page, radicale_server) + expect(page.locator("#collectionsscene")).to_be_visible() + + # 2. Logout + page.click('#logoutview a[data-name="logout"]') + expect(page.locator("#loginscene")).to_be_visible() + expect(page.locator("#collectionsscene")).to_be_hidden() + + # 3. Second login + login(page, radicale_server) + expect(page.locator("#collectionsscene")).to_be_visible() diff --git a/integ_tests/test_sharing.py b/integ_tests/test_sharing.py index f1d53c58..ecc667c9 100644 --- a/integ_tests/test_sharing.py +++ b/integ_tests/test_sharing.py @@ -48,7 +48,9 @@ def test_create_and_delete_share_by_key(page: Page, radicale_server: str) -> Non page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") ).to_have_count(1) expect( - page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden) img[alt='RO']") + page.locator( + "tr[data-name='sharetokenrowtemplate']:not(.hidden) span[data-name='ro']" + ) ).to_be_visible() page.once("dialog", lambda dialog: dialog.accept()) page.click('tr:not(.hidden) button[data-name="delete"]', strict=True) @@ -62,7 +64,9 @@ def test_create_and_delete_share_by_key(page: Page, radicale_server: str) -> Non page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") ).to_have_count(1) expect( - page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden) img[alt='RW']") + page.locator( + "tr[data-name='sharetokenrowtemplate']:not(.hidden) span[data-name='rw']" + ) ).to_be_visible() page.once("dialog", lambda dialog: dialog.accept()) page.click('tr:not(.hidden) button[data-name="delete"]', strict=True) @@ -89,7 +93,9 @@ def test_create_and_delete_share_by_map(page: Page, radicale_server: str) -> Non page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") ).to_have_count(1) expect( - page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden) img[alt='RO']") + page.locator( + "tr[data-name='sharemaprowtemplate']:not(.hidden) span[data-name='ro']" + ) ).to_be_visible() page.once("dialog", lambda dialog: dialog.accept()) page.click('tr:not(.hidden) button[data-name="delete"]', strict=True) @@ -105,7 +111,9 @@ def test_create_and_delete_share_by_map(page: Page, radicale_server: str) -> Non page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") ).to_have_count(1) expect( - page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden) img[alt='RW']") + page.locator( + "tr[data-name='sharemaprowtemplate']:not(.hidden) span[data-name='rw']" + ) ).to_be_visible() page.once("dialog", lambda dialog: dialog.accept()) page.click('tr:not(.hidden) button[data-name="delete"]', strict=True) @@ -196,7 +204,9 @@ def test_edit_share_by_token(page: Page, radicale_server: str) -> None: page.click('button[data-name="sharebytoken"]') page.click('#newshare button[data-name="submit"]') expect( - page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden) img[alt='RO']") + page.locator( + "tr[data-name='sharetokenrowtemplate']:not(.hidden) span[data-name='ro']" + ) ).to_be_visible() # Edit to RW @@ -207,7 +217,9 @@ def test_edit_share_by_token(page: Page, radicale_server: str) -> None: # Verify RW expect( - page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden) img[alt='RW']") + page.locator( + "tr[data-name='sharetokenrowtemplate']:not(.hidden) span[data-name='rw']" + ) ).to_be_visible() @@ -223,7 +235,9 @@ def test_edit_share_by_map(page: Page, radicale_server: str) -> None: page.locator('input[data-name="sharehref"]').fill("mapped") page.click('#newshare button[data-name="submit"]') expect( - page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden) img[alt='RO']") + page.locator( + "tr[data-name='sharemaprowtemplate']:not(.hidden) span[data-name='ro']" + ) ).to_be_visible() # Edit map share @@ -234,17 +248,19 @@ def test_edit_share_by_map(page: Page, radicale_server: str) -> None: # Change permissions and enabled status page.click('label[for="newshare_attr_permissions_rw"]') - page.uncheck('input[data-name="enabled"]') + page.uncheck('#newshare input[data-name="enabled"]') page.click('#newshare button[data-name="submit"]') # Verify changes expect( - page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden) img[alt='RW']") + page.locator( + "tr[data-name='sharemaprowtemplate']:not(.hidden) span[data-name='rw']" + ) ).to_be_visible() # If disabled, it might not show up or show differently, but our current UI doesn't visually distinguish enabled/disabled in the list yet # Let's verify by re-opening edit scene page.click('tr:not(.hidden) button[data-name="edit"]') - expect(page.locator('input[data-name="enabled"]')).not_to_be_checked() + expect(page.locator('#newshare input[data-name="enabled"]')).not_to_be_checked() page.click('#newshare button[data-name="cancel"]') @@ -279,3 +295,109 @@ def test_share_by_map_validation(page: Page, radicale_server: str) -> None: expect( page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") ).to_have_count(1) + + +def test_incoming_shares(page: Page, radicale_server: str) -> None: + # 1. Admin logs in and creates a map share for 'max' + login(page, radicale_server) + create_collection(page, radicale_server) + + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + page.click('button[data-name="sharebymap"]') + page.locator('input[data-name="shareuser"]').fill("max") + page.locator('input[data-name="sharehref"]').fill("mapped") + page.click('#newshare button[data-name="submit"]') + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") + ).to_have_count(1) + page.click('#sharecollectionscene button[data-name="cancel"]') + + # 2. Admin logs out + page.click('a[data-name="logout"]') + + # 3. Max logs in + page.fill('#loginscene input[data-name="user"]', "max") + page.fill('#loginscene input[data-name="password"]', "maxpassword") + page.click('button:has-text("Next")') + + # 4. Max sees the incoming share + page.click('a[data-name="incomingshares"]') + expect(page.locator("#incomingsharingscene")).to_be_visible() + expect( + page.locator("tr[data-name='incomingsharerowtemplate']:not(.hidden)") + ).to_have_count(1) + + expect( + page.locator( + "tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='pathortoken']" + ) + ).to_have_value("mapped") + + # 5. Max enables and shows the share + # Initially, it's disabled and not shown (security by default) + expect( + page.locator( + "tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='enabled']" + ) + ).not_to_be_checked() + expect( + page.locator( + "tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']" + ) + ).not_to_be_checked() + expect( + page.locator( + "tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']" + ) + ).to_be_disabled() + + # Enable it + page.check( + "tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='enabled']" + ) + expect( + page.locator( + "tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']" + ) + ).not_to_be_disabled() + + # Show it + page.check( + "tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']" + ) + expect( + page.locator( + "tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']" + ) + ).to_be_checked() + + # 6. Assert no error was shown + expect(page.locator('#incomingsharingscene span[data-name="error"]')).to_be_hidden() + + page.click('#incomingsharingscene button[data-name="cancel"]') + expect(page.locator("#incomingsharingscene")).to_be_hidden() + + +def test_no_incoming_shares_message(page: Page, radicale_server: str) -> None: + # 1. Max logs in + page.goto(radicale_server) + page.fill('#loginscene input[data-name="user"]', "max") + page.fill('#loginscene input[data-name="password"]', "maxpassword") + page.click('button:has-text("Next")') + + # 2. Max goes to incoming shares scene + page.click('a[data-name="incomingshares"]') + expect(page.locator("#incomingsharingscene")).to_be_visible() + + # 3. Verify that the table is hidden and the message is visible + expect(page.locator("#incomingsharingscene table")).to_be_hidden() + expect( + page.locator('#incomingsharingscene [data-name="nosharesmessage"]') + ).to_be_visible() + expect( + page.locator('#incomingsharingscene [data-name="nosharesmessage"]') + ).to_have_text("No incoming shares") + + page.click('#incomingsharingscene button[data-name="cancel"]') + expect(page.locator("#incomingsharingscene")).to_be_hidden() diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css index 94ae598e..de3c9210 100644 --- a/radicale/web/internal_data/css/main.css +++ b/radicale/web/internal_data/css/main.css @@ -501,4 +501,20 @@ button.inline { #logoutview span { padding: 0 5px; } -} \ No newline at end of file +} + +.pill { + display: inline-block; + padding: 0.25em 0.6em; + font-size: 75%; + font-weight: 700; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 0.375rem; + background-color: #6c757d; + color: #fff; + margin: 0 2px; + width: 1.4em; +} diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index ca3a447a..02d32749 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -66,6 +66,9 @@ ⬆️ + + 🔗 +