Run tests in CI in parallel (#1254)

* Run tests in CI in parallel

* make tests automatically open/close playwright

* fix parallel tests and screenshots

* fix capturing screenshots for non-failing tests

* cleanup

* cleanup

* format

* log js errors

* provide screenshots as artifacts

* remove old scripts
This commit is contained in:
Sascha Ißbrücker
2026-01-01 01:46:31 +01:00
committed by GitHub
parent df595f2219
commit 38d450a916
23 changed files with 1073 additions and 1141 deletions

View File

@@ -30,7 +30,7 @@ jobs:
uv sync uv sync
mkdir data mkdir data
- name: Run tests - name: Run tests
run: uv run manage.py test bookmarks.tests run: uv run pytest -n auto
e2e_tests: e2e_tests:
name: E2E Tests name: E2E Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -59,4 +59,10 @@ jobs:
npm run build npm run build
uv run manage.py collectstatic uv run manage.py collectstatic
- name: Run tests - name: Run tests
run: uv run manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py" run: uv run pytest bookmarks/tests_e2e -n auto -o "python_files=e2e_test_*.py"
- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-screenshots
path: test-results/screenshots

1
.gitignore vendored
View File

@@ -60,6 +60,7 @@ coverage.xml
*.cover *.cover
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
test-results/
# Translations # Translations
*.mo *.mo

View File

@@ -1,73 +1,69 @@
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
class A11yNavigationFocusTest(LinkdingE2ETestCase): class A11yNavigationFocusTest(LinkdingE2ETestCase):
def test_initial_page_load_focus(self): def test_initial_page_load_focus(self):
with sync_playwright() as p: # First page load should keep focus on the body
# First page load should keep focus on the body page = self.open(reverse("linkding:bookmarks.index"))
page = self.open(reverse("linkding:bookmarks.index"), p) focused_tag = page.evaluate("document.activeElement?.tagName")
focused_tag = page.evaluate("document.activeElement?.tagName") self.assertEqual("BODY", focused_tag)
self.assertEqual("BODY", focused_tag)
page.goto(self.live_server_url + reverse("linkding:bookmarks.archived")) page.goto(self.live_server_url + reverse("linkding:bookmarks.archived"))
focused_tag = page.evaluate("document.activeElement?.tagName") focused_tag = page.evaluate("document.activeElement?.tagName")
self.assertEqual("BODY", focused_tag) self.assertEqual("BODY", focused_tag)
page.goto(self.live_server_url + reverse("linkding:settings.general")) page.goto(self.live_server_url + reverse("linkding:settings.general"))
focused_tag = page.evaluate("document.activeElement?.tagName") focused_tag = page.evaluate("document.activeElement?.tagName")
self.assertEqual("BODY", focused_tag) self.assertEqual("BODY", focused_tag)
# Bookmark form views should focus the URL input # Bookmark form views should focus the URL input
page.goto(self.live_server_url + reverse("linkding:bookmarks.new")) page.goto(self.live_server_url + reverse("linkding:bookmarks.new"))
page.wait_for_timeout(timeout=1000) page.wait_for_timeout(timeout=1000)
focused_tag = page.evaluate( focused_tag = page.evaluate(
"document.activeElement?.tagName + '|' + document.activeElement?.name" "document.activeElement?.tagName + '|' + document.activeElement?.name"
) )
self.assertEqual("INPUT|url", focused_tag) self.assertEqual("INPUT|url", focused_tag)
def test_page_navigation_focus(self): def test_page_navigation_focus(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.index"))
page = self.open(reverse("linkding:bookmarks.index"), p)
# Subsequent navigation should move focus to main content # Subsequent navigation should move focus to main content
self.reset_focus() self.reset_focus()
self.navigate_menu("Bookmarks", "Active") self.navigate_menu("Bookmarks", "Active")
focused = page.locator("main:focus") focused = page.locator("main:focus")
expect(focused).to_be_visible() expect(focused).to_be_visible()
self.reset_focus() self.reset_focus()
self.navigate_menu("Bookmarks", "Archived") self.navigate_menu("Bookmarks", "Archived")
focused = page.locator("main:focus") focused = page.locator("main:focus")
expect(focused).to_be_visible() expect(focused).to_be_visible()
self.reset_focus() self.reset_focus()
self.navigate_menu("Settings", "General") self.navigate_menu("Settings", "General")
focused = page.locator("main:focus") focused = page.locator("main:focus")
expect(focused).to_be_visible() expect(focused).to_be_visible()
# Bookmark form views should focus the URL input # Bookmark form views should focus the URL input
self.reset_focus() self.reset_focus()
self.navigate_menu("Add bookmark") self.navigate_menu("Add bookmark")
focused = page.locator("input[name='url']:focus") focused = page.locator("input[name='url']:focus")
expect(focused).to_be_visible() expect(focused).to_be_visible()
# Opening details modal should move focus to close button # Opening details modal should move focus to close button
self.navigate_menu("Bookmarks", "Active") self.navigate_menu("Bookmarks", "Active")
self.open_details_modal(bookmark) self.open_details_modal(bookmark)
focused = page.locator(".modal button.close:focus") focused = page.locator(".modal button.close:focus")
expect(focused).to_be_visible() expect(focused).to_be_visible()
# Closing modal should move focus back to the bookmark item # Closing modal should move focus back to the bookmark item
page.keyboard.press("Escape") page.keyboard.press("Escape")
focused = self.locate_bookmark(bookmark.title).locator( focused = self.locate_bookmark(bookmark.title).locator("a.view-action:focus")
"a.view-action:focus" expect(focused).to_be_visible()
)
expect(focused).to_be_visible()
def reset_focus(self): def reset_focus(self):
self.page.evaluate("document.activeElement.blur()") self.page.evaluate("document.activeElement.blur()")

View File

@@ -1,6 +1,6 @@
from django.test import override_settings from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import Bookmark from bookmarks.models import Bookmark
@@ -10,78 +10,74 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
def test_show_details(self): def test_show_details(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
title = details_modal.locator("h2") title = details_modal.locator("h2")
expect(title).to_have_text(bookmark.title) expect(title).to_have_text(bookmark.title)
def test_close_details(self): def test_close_details(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
# close with close button # close with close button
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
details_modal.locator("button.close").click() details_modal.locator("button.close").click()
expect(details_modal).to_be_hidden() expect(details_modal).to_be_hidden()
# close with backdrop # close with backdrop
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
overlay = details_modal.locator(".modal-overlay") overlay = details_modal.locator(".modal-overlay")
overlay.click(position={"x": 0, "y": 0}) overlay.click(position={"x": 0, "y": 0})
expect(details_modal).to_be_hidden() expect(details_modal).to_be_hidden()
# close with escape # close with escape
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
self.page.keyboard.press("Escape") self.page.keyboard.press("Escape")
expect(details_modal).to_be_hidden() expect(details_modal).to_be_hidden()
def test_toggle_archived(self): def test_toggle_archived(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with sync_playwright() as p: # archive
# archive url = reverse("linkding:bookmarks.index")
url = reverse("linkding:bookmarks.index") self.open(url)
self.open(url, p)
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Archived", exact=False).click() details_modal.get_by_text("Archived", exact=False).click()
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible() expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
self.assertReloads(0) self.assertReloads(0)
# unarchive # unarchive
url = reverse("linkding:bookmarks.archived") url = reverse("linkding:bookmarks.archived")
self.page.goto(self.live_server_url + url) self.page.goto(self.live_server_url + url)
self.resetReloads() self.resetReloads()
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Archived", exact=False).click() details_modal.get_by_text("Archived", exact=False).click()
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible() expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
self.assertReloads(0) self.assertReloads(0)
def test_toggle_unread(self): def test_toggle_unread(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with sync_playwright() as p: # mark as unread
# mark as unread url = reverse("linkding:bookmarks.index")
url = reverse("linkding:bookmarks.index") self.open(url)
self.open(url, p)
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Unread").click() details_modal.get_by_text("Unread").click()
bookmark_item = self.locate_bookmark(bookmark.title) bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Unread")).to_be_visible() expect(bookmark_item.get_by_text("Unread")).to_be_visible()
self.assertReloads(0) self.assertReloads(0)
# mark as read # mark as read
details_modal.get_by_text("Unread").click() details_modal.get_by_text("Unread").click()
bookmark_item = self.locate_bookmark(bookmark.title) bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Unread")).not_to_be_visible() expect(bookmark_item.get_by_text("Unread")).not_to_be_visible()
self.assertReloads(0) self.assertReloads(0)
def test_toggle_shared(self): def test_toggle_shared(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@@ -90,61 +86,58 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with sync_playwright() as p: # share bookmark
# share bookmark url = reverse("linkding:bookmarks.index")
url = reverse("linkding:bookmarks.index") self.open(url)
self.open(url, p)
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Shared").click() details_modal.get_by_text("Shared").click()
bookmark_item = self.locate_bookmark(bookmark.title) bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Shared")).to_be_visible() expect(bookmark_item.get_by_text("Shared")).to_be_visible()
self.assertReloads(0) self.assertReloads(0)
# unshare bookmark # unshare bookmark
details_modal.get_by_text("Shared").click() details_modal.get_by_text("Shared").click()
bookmark_item = self.locate_bookmark(bookmark.title) bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Shared")).not_to_be_visible() expect(bookmark_item.get_by_text("Shared")).not_to_be_visible()
self.assertReloads(0) self.assertReloads(0)
def test_edit_return_url(self): def test_edit_return_url(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with sync_playwright() as p: url = reverse("linkding:bookmarks.index") + f"?q={bookmark.title}"
url = reverse("linkding:bookmarks.index") + f"?q={bookmark.title}" self.open(url)
self.open(url, p)
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
# Navigate to edit page # Navigate to edit page
with self.page.expect_navigation(): with self.page.expect_navigation():
details_modal.get_by_text("Edit").click() details_modal.get_by_text("Edit").click()
# Cancel edit, verify return to details url # Cancel edit, verify return to details url
details_url = url + f"&details={bookmark.id}" details_url = url + f"&details={bookmark.id}"
with self.page.expect_navigation(url=self.live_server_url + details_url): with self.page.expect_navigation(url=self.live_server_url + details_url):
self.page.get_by_text("Cancel").click() self.page.get_by_text("Cancel").click()
def test_delete(self): def test_delete(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with sync_playwright() as p: url = reverse("linkding:bookmarks.index") + f"?q={bookmark.title}"
url = reverse("linkding:bookmarks.index") + f"?q={bookmark.title}" self.open(url)
self.open(url, p)
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
# Wait for confirm button to be initialized # Wait for confirm button to be initialized
self.page.wait_for_timeout(1000) self.page.wait_for_timeout(1000)
# Delete bookmark, verify return url # Delete bookmark, verify return url
with self.page.expect_navigation(url=self.live_server_url + url): with self.page.expect_navigation(url=self.live_server_url + url):
details_modal.get_by_text("Delete").click() details_modal.get_by_text("Delete").click()
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# verify bookmark is deleted # verify bookmark is deleted
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible() expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
self.assertEqual(Bookmark.objects.count(), 0) self.assertEqual(Bookmark.objects.count(), 0)
@@ -152,28 +145,27 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
def test_create_snapshot_remove_snapshot(self): def test_create_snapshot_remove_snapshot(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with sync_playwright() as p: url = reverse("linkding:bookmarks.index") + f"?q={bookmark.title}"
url = reverse("linkding:bookmarks.index") + f"?q={bookmark.title}" self.open(url)
self.open(url, p)
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
asset_list = details_modal.locator(".assets") asset_list = details_modal.locator(".assets")
# No snapshots initially # No snapshots initially
snapshot = asset_list.get_by_text("HTML snapshot from", exact=False) snapshot = asset_list.get_by_text("HTML snapshot from", exact=False)
expect(snapshot).not_to_be_visible() expect(snapshot).not_to_be_visible()
# Create snapshot # Create snapshot
details_modal.get_by_text("Create HTML snapshot", exact=False).click() details_modal.get_by_text("Create HTML snapshot", exact=False).click()
self.assertReloads(0) self.assertReloads(0)
# Has new snapshots # Has new snapshots
expect(snapshot).to_be_visible() expect(snapshot).to_be_visible()
# Remove snapshot # Remove snapshot
asset_list.get_by_text("Remove", exact=False).click() asset_list.get_by_text("Remove", exact=False).click()
self.locate_confirm_dialog().get_by_text("Confirm", exact=False).click() self.locate_confirm_dialog().get_by_text("Confirm", exact=False).click()
# Snapshot is removed # Snapshot is removed
expect(snapshot).not_to_be_visible() expect(snapshot).not_to_be_visible()
self.assertReloads(0) self.assertReloads(0)

View File

@@ -1,7 +1,7 @@
from unittest import skip from unittest import skip
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
@@ -11,15 +11,14 @@ class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
def test_toggle_notes_should_show_hide_notes(self): def test_toggle_notes_should_show_hide_notes(self):
bookmark = self.setup_bookmark(notes="Test notes") bookmark = self.setup_bookmark(notes="Test notes")
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.index"))
page = self.open(reverse("linkding:bookmarks.index"), p)
notes = self.locate_bookmark(bookmark.title).locator(".notes") notes = self.locate_bookmark(bookmark.title).locator(".notes")
expect(notes).to_be_hidden() expect(notes).to_be_hidden()
toggle_notes = page.locator("li button.toggle-notes") toggle_notes = page.locator("li button.toggle-notes")
toggle_notes.click() toggle_notes.click()
expect(notes).to_be_visible() expect(notes).to_be_visible()
toggle_notes.click() toggle_notes.click()
expect(notes).to_be_hidden() expect(notes).to_be_hidden()

View File

@@ -1,5 +1,5 @@
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import Bookmark from bookmarks.models import Bookmark
@@ -36,19 +36,18 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
def test_active_bookmarks_bulk_select_across(self): def test_active_bookmarks_bulk_select_across(self):
self.setup_test_data() self.setup_test_data()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
bookmark_list = self.locate_bookmark_list().element_handle() bookmark_list = self.locate_bookmark_list().element_handle()
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click() self.locate_bulk_edit_select_across().click()
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible) # Wait until bookmark list is updated (old reference becomes invisible)
bookmark_list.wait_for_element_state("hidden", timeout=1000) bookmark_list.wait_for_element_state("hidden", timeout=1000)
self.assertEqual( self.assertEqual(
0, 0,
@@ -74,19 +73,18 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
def test_archived_bookmarks_bulk_select_across(self): def test_archived_bookmarks_bulk_select_across(self):
self.setup_test_data() self.setup_test_data()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.archived"))
self.open(reverse("linkding:bookmarks.archived"), p)
bookmark_list = self.locate_bookmark_list().element_handle() bookmark_list = self.locate_bookmark_list().element_handle()
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click() self.locate_bulk_edit_select_across().click()
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible) # Wait until bookmark list is updated (old reference becomes invisible)
bookmark_list.wait_for_element_state("hidden", timeout=1000) bookmark_list.wait_for_element_state("hidden", timeout=1000)
self.assertEqual( self.assertEqual(
50, 50,
@@ -112,19 +110,18 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
def test_active_bookmarks_bulk_select_across_respects_query(self): def test_active_bookmarks_bulk_select_across_respects_query(self):
self.setup_test_data() self.setup_test_data()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index") + "?q=foo")
self.open(reverse("linkding:bookmarks.index") + "?q=foo", p)
bookmark_list = self.locate_bookmark_list().element_handle() bookmark_list = self.locate_bookmark_list().element_handle()
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click() self.locate_bulk_edit_select_across().click()
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible) # Wait until bookmark list is updated (old reference becomes invisible)
bookmark_list.wait_for_element_state("hidden", timeout=1000) bookmark_list.wait_for_element_state("hidden", timeout=1000)
self.assertEqual( self.assertEqual(
50, 50,
@@ -150,19 +147,18 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
def test_archived_bookmarks_bulk_select_across_respects_query(self): def test_archived_bookmarks_bulk_select_across_respects_query(self):
self.setup_test_data() self.setup_test_data()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.archived") + "?q=foo")
self.open(reverse("linkding:bookmarks.archived") + "?q=foo", p)
bookmark_list = self.locate_bookmark_list().element_handle() bookmark_list = self.locate_bookmark_list().element_handle()
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click() self.locate_bulk_edit_select_across().click()
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible) # Wait until bookmark list is updated (old reference becomes invisible)
bookmark_list.wait_for_element_state("hidden", timeout=1000) bookmark_list.wait_for_element_state("hidden", timeout=1000)
self.assertEqual( self.assertEqual(
50, 50,
@@ -188,148 +184,138 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
def test_select_all_toggles_all_checkboxes(self): def test_select_all_toggles_all_checkboxes(self):
self.setup_numbered_bookmarks(5) self.setup_numbered_bookmarks(5)
with sync_playwright() as p: url = reverse("linkding:bookmarks.index")
url = reverse("linkding:bookmarks.index") page = self.open(url)
page = self.open(url, p)
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
checkboxes = page.locator("label.bulk-edit-checkbox input") checkboxes = page.locator("label.bulk-edit-checkbox input")
self.assertEqual(6, checkboxes.count()) self.assertEqual(6, checkboxes.count())
for i in range(checkboxes.count()): for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked() expect(checkboxes.nth(i)).not_to_be_checked()
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
for i in range(checkboxes.count()): for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).to_be_checked() expect(checkboxes.nth(i)).to_be_checked()
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
for i in range(checkboxes.count()): for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked() expect(checkboxes.nth(i)).not_to_be_checked()
def test_select_all_shows_select_across(self): def test_select_all_shows_select_across(self):
self.setup_numbered_bookmarks(5) self.setup_numbered_bookmarks(5)
with sync_playwright() as p: url = reverse("linkding:bookmarks.index")
url = reverse("linkding:bookmarks.index") self.open(url)
self.open(url, p)
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible() expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).to_be_visible() expect(self.locate_bulk_edit_select_across()).to_be_visible()
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible() expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
def test_select_across_is_unchecked_when_toggling_all(self): def test_select_across_is_unchecked_when_toggling_all(self):
self.setup_numbered_bookmarks(5) self.setup_numbered_bookmarks(5)
with sync_playwright() as p: url = reverse("linkding:bookmarks.index")
url = reverse("linkding:bookmarks.index") self.open(url)
self.open(url, p)
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
# Show select across, check it # Show select across, check it
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click() self.locate_bulk_edit_select_across().click()
expect(self.locate_bulk_edit_select_across()).to_be_checked() expect(self.locate_bulk_edit_select_across()).to_be_checked()
# Hide select across by toggling select all # Hide select across by toggling select all
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible() expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
# Show select across again, verify it is unchecked # Show select across again, verify it is unchecked
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked() expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
def test_select_across_is_unchecked_when_toggling_bookmark(self): def test_select_across_is_unchecked_when_toggling_bookmark(self):
self.setup_numbered_bookmarks(5) self.setup_numbered_bookmarks(5)
with sync_playwright() as p: url = reverse("linkding:bookmarks.index")
url = reverse("linkding:bookmarks.index") self.open(url)
self.open(url, p)
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
# Show select across, check it # Show select across, check it
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click() self.locate_bulk_edit_select_across().click()
expect(self.locate_bulk_edit_select_across()).to_be_checked() expect(self.locate_bulk_edit_select_across()).to_be_checked()
# Hide select across by toggling a single bookmark # Hide select across by toggling a single bookmark
self.locate_bookmark("Bookmark 1").locator( self.locate_bookmark("Bookmark 1").locator("label.bulk-edit-checkbox").click()
"label.bulk-edit-checkbox" expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
).click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
# Show select across again, verify it is unchecked # Show select across again, verify it is unchecked
self.locate_bookmark("Bookmark 1").locator( self.locate_bookmark("Bookmark 1").locator("label.bulk-edit-checkbox").click()
"label.bulk-edit-checkbox" expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
).click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
def test_execute_resets_all_checkboxes(self): def test_execute_resets_all_checkboxes(self):
self.setup_numbered_bookmarks(100) self.setup_numbered_bookmarks(100)
with sync_playwright() as p: url = reverse("linkding:bookmarks.index")
url = reverse("linkding:bookmarks.index") page = self.open(url)
page = self.open(url, p)
bookmark_list = self.locate_bookmark_list().element_handle() bookmark_list = self.locate_bookmark_list().element_handle()
# Select all bookmarks, enable select across # Select all bookmarks, enable select across
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click() self.locate_bulk_edit_select_across().click()
# Execute bulk action # Execute bulk action
self.select_bulk_action("Mark as unread") self.select_bulk_action("Mark as unread")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible) # Wait until bookmark list is updated (old reference becomes invisible)
bookmark_list.wait_for_element_state("hidden", timeout=1000) bookmark_list.wait_for_element_state("hidden", timeout=1000)
# Verify bulk edit checkboxes are reset # Verify bulk edit checkboxes are reset
checkboxes = page.locator("label.bulk-edit-checkbox input") checkboxes = page.locator("label.bulk-edit-checkbox input")
self.assertEqual(31, checkboxes.count()) self.assertEqual(31, checkboxes.count())
for i in range(checkboxes.count()): for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked() expect(checkboxes.nth(i)).not_to_be_checked()
# Toggle select all and verify select across is reset # Toggle select all and verify select across is reset
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked() expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
def test_update_select_across_bookmark_count(self): def test_update_select_across_bookmark_count(self):
self.setup_numbered_bookmarks(100) self.setup_numbered_bookmarks(100)
with sync_playwright() as p: url = reverse("linkding:bookmarks.index")
url = reverse("linkding:bookmarks.index") self.open(url)
self.open(url, p)
bookmark_list = self.locate_bookmark_list().element_handle() bookmark_list = self.locate_bookmark_list().element_handle()
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
expect( expect(
self.locate_bulk_edit_bar().get_by_text("All pages (100 bookmarks)") self.locate_bulk_edit_bar().get_by_text("All pages (100 bookmarks)")
).to_be_visible() ).to_be_visible()
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible) # Wait until bookmark list is updated (old reference becomes invisible)
bookmark_list.wait_for_element_state("hidden", timeout=1000) bookmark_list.wait_for_element_state("hidden", timeout=1000)
expect(self.locate_bulk_edit_select_all()).not_to_be_checked() expect(self.locate_bulk_edit_select_all()).not_to_be_checked()
self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_all().click()
expect( expect(
self.locate_bulk_edit_bar().get_by_text("All pages (70 bookmarks)") self.locate_bulk_edit_bar().get_by_text("All pages (70 bookmarks)")
).to_be_visible() ).to_be_visible()

View File

@@ -1,7 +1,7 @@
from typing import List from typing import List
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
@@ -43,91 +43,85 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.setup_numbered_bookmarks(5, prefix="foo") self.setup_numbered_bookmarks(5, prefix="foo")
self.setup_numbered_bookmarks(5, prefix="bar") self.setup_numbered_bookmarks(5, prefix="bar")
with sync_playwright() as p: url = reverse("linkding:bookmarks.index") + "?q=foo"
url = reverse("linkding:bookmarks.index") + "?q=foo" self.open(url)
self.open(url, p)
self.assertVisibleBookmarks(["foo 1", "foo 2", "foo 3", "foo 4", "foo 5"]) self.assertVisibleBookmarks(["foo 1", "foo 2", "foo 3", "foo 4", "foo 5"])
self.locate_bookmark("foo 2").get_by_text("Archive").click() self.locate_bookmark("foo 2").get_by_text("Archive").click()
self.assertVisibleBookmarks(["foo 1", "foo 3", "foo 4", "foo 5"]) self.assertVisibleBookmarks(["foo 1", "foo 3", "foo 4", "foo 5"])
def test_partial_update_respects_sort(self): def test_partial_update_respects_sort(self):
self.setup_numbered_bookmarks(5, prefix="foo") self.setup_numbered_bookmarks(5, prefix="foo")
with sync_playwright() as p: url = reverse("linkding:bookmarks.index") + "?sort=title_asc"
url = reverse("linkding:bookmarks.index") + "?sort=title_asc" page = self.open(url)
page = self.open(url, p)
first_item = page.locator("ul.bookmark-list > li").first first_item = page.locator("ul.bookmark-list > li").first
expect(first_item).to_contain_text("foo 1") expect(first_item).to_contain_text("foo 1")
first_item.get_by_text("Archive").click() first_item.get_by_text("Archive").click()
first_item = page.locator("ul.bookmark-list > li").first first_item = page.locator("ul.bookmark-list > li").first
expect(first_item).to_contain_text("foo 2") expect(first_item).to_contain_text("foo 2")
def test_partial_update_respects_page(self): def test_partial_update_respects_page(self):
# add a suffix, otherwise 'foo 1' also matches 'foo 10' # add a suffix, otherwise 'foo 1' also matches 'foo 10'
self.setup_numbered_bookmarks(50, prefix="foo", suffix="-") self.setup_numbered_bookmarks(50, prefix="foo", suffix="-")
with sync_playwright() as p: url = reverse("linkding:bookmarks.index") + "?q=foo&page=2"
url = reverse("linkding:bookmarks.index") + "?q=foo&page=2" self.open(url)
self.open(url, p)
# with descending sort, page two has 'foo 1' to 'foo 20' # with descending sort, page two has 'foo 1' to 'foo 20'
expected_titles = [f"foo {i}-" for i in range(1, 21)] expected_titles = [f"foo {i}-" for i in range(1, 21)]
self.assertVisibleBookmarks(expected_titles) self.assertVisibleBookmarks(expected_titles)
self.locate_bookmark("foo 20-").get_by_text("Archive").click() self.locate_bookmark("foo 20-").get_by_text("Archive").click()
expected_titles = [f"foo {i}-" for i in range(1, 20)] expected_titles = [f"foo {i}-" for i in range(1, 20)]
self.assertVisibleBookmarks(expected_titles) self.assertVisibleBookmarks(expected_titles)
def test_multiple_partial_updates(self): def test_multiple_partial_updates(self):
self.setup_numbered_bookmarks(5) self.setup_numbered_bookmarks(5)
with sync_playwright() as p: url = reverse("linkding:bookmarks.index")
url = reverse("linkding:bookmarks.index") self.open(url)
self.open(url, p)
self.locate_bookmark("Bookmark 1").get_by_text("Archive").click() self.locate_bookmark("Bookmark 1").get_by_text("Archive").click()
self.assertVisibleBookmarks( self.assertVisibleBookmarks(
["Bookmark 2", "Bookmark 3", "Bookmark 4", "Bookmark 5"] ["Bookmark 2", "Bookmark 3", "Bookmark 4", "Bookmark 5"]
) )
self.locate_bookmark("Bookmark 2").get_by_text("Archive").click() self.locate_bookmark("Bookmark 2").get_by_text("Archive").click()
self.assertVisibleBookmarks(["Bookmark 3", "Bookmark 4", "Bookmark 5"]) self.assertVisibleBookmarks(["Bookmark 3", "Bookmark 4", "Bookmark 5"])
self.locate_bookmark("Bookmark 3").get_by_text("Archive").click() self.locate_bookmark("Bookmark 3").get_by_text("Archive").click()
self.assertVisibleBookmarks(["Bookmark 4", "Bookmark 5"]) self.assertVisibleBookmarks(["Bookmark 4", "Bookmark 5"])
self.assertReloads(0) self.assertReloads(0)
def test_active_bookmarks_partial_update_on_archive(self): def test_active_bookmarks_partial_update_on_archive(self):
self.setup_fixture() self.setup_fixture()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
self.locate_bookmark("Bookmark 2").get_by_text("Archive").click() self.locate_bookmark("Bookmark 2").get_by_text("Archive").click()
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"]) self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
self.assertVisibleTags(["Tag 1", "Tag 3"]) self.assertVisibleTags(["Tag 1", "Tag 3"])
self.assertReloads(0) self.assertReloads(0)
def test_active_bookmarks_partial_update_on_delete(self): def test_active_bookmarks_partial_update_on_delete(self):
self.setup_fixture() self.setup_fixture()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
self.locate_bookmark("Bookmark 2").get_by_text("Remove").click() self.locate_bookmark("Bookmark 2").get_by_text("Remove").click()
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"]) self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
self.assertVisibleTags(["Tag 1", "Tag 3"]) self.assertVisibleTags(["Tag 1", "Tag 3"])
self.assertReloads(0) self.assertReloads(0)
def test_active_bookmarks_partial_update_on_mark_as_read(self): def test_active_bookmarks_partial_update_on_mark_as_read(self):
self.setup_fixture() self.setup_fixture()
@@ -135,15 +129,14 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
bookmark2.unread = True bookmark2.unread = True
bookmark2.save() bookmark2.save()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
expect(self.locate_bookmark("Bookmark 2")).to_have_class("unread") expect(self.locate_bookmark("Bookmark 2")).to_have_class("unread")
self.locate_bookmark("Bookmark 2").get_by_text("Unread").click() self.locate_bookmark("Bookmark 2").get_by_text("Unread").click()
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("unread") expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("unread")
self.assertReloads(0) self.assertReloads(0)
def test_active_bookmarks_partial_update_on_unshare(self): def test_active_bookmarks_partial_update_on_unshare(self):
self.setup_fixture() self.setup_fixture()
@@ -151,112 +144,101 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
bookmark2.shared = True bookmark2.shared = True
bookmark2.save() bookmark2.save()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
expect(self.locate_bookmark("Bookmark 2")).to_have_class("shared") expect(self.locate_bookmark("Bookmark 2")).to_have_class("shared")
self.locate_bookmark("Bookmark 2").get_by_text("Shared").click() self.locate_bookmark("Bookmark 2").get_by_text("Shared").click()
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("shared") expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("shared")
self.assertReloads(0) self.assertReloads(0)
def test_active_bookmarks_partial_update_on_bulk_archive(self): def test_active_bookmarks_partial_update_on_bulk_archive(self):
self.setup_fixture() self.setup_fixture()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Bookmark 2").locator( self.locate_bookmark("Bookmark 2").locator("label.bulk-edit-checkbox").click()
"label.bulk-edit-checkbox" self.select_bulk_action("Archive")
).click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.select_bulk_action("Archive") self.locate_confirm_dialog().get_by_text("Confirm").click()
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"]) self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
self.assertVisibleTags(["Tag 1", "Tag 3"]) self.assertVisibleTags(["Tag 1", "Tag 3"])
self.assertReloads(0) self.assertReloads(0)
def test_active_bookmarks_partial_update_on_bulk_delete(self): def test_active_bookmarks_partial_update_on_bulk_delete(self):
self.setup_fixture() self.setup_fixture()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Bookmark 2").locator( self.locate_bookmark("Bookmark 2").locator("label.bulk-edit-checkbox").click()
"label.bulk-edit-checkbox" self.select_bulk_action("Delete")
).click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.select_bulk_action("Delete") self.locate_confirm_dialog().get_by_text("Confirm").click()
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"]) self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
self.assertVisibleTags(["Tag 1", "Tag 3"]) self.assertVisibleTags(["Tag 1", "Tag 3"])
self.assertReloads(0) self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_unarchive(self): def test_archived_bookmarks_partial_update_on_unarchive(self):
self.setup_fixture() self.setup_fixture()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.archived"))
self.open(reverse("linkding:bookmarks.archived"), p)
self.locate_bookmark("Archived Bookmark 2").get_by_text("Unarchive").click() self.locate_bookmark("Archived Bookmark 2").get_by_text("Unarchive").click()
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"]) self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"]) self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
self.assertReloads(0) self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_delete(self): def test_archived_bookmarks_partial_update_on_delete(self):
self.setup_fixture() self.setup_fixture()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.archived"))
self.open(reverse("linkding:bookmarks.archived"), p)
self.locate_bookmark("Archived Bookmark 2").get_by_text("Remove").click() self.locate_bookmark("Archived Bookmark 2").get_by_text("Remove").click()
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"]) self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"]) self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
self.assertReloads(0) self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_bulk_unarchive(self): def test_archived_bookmarks_partial_update_on_bulk_unarchive(self):
self.setup_fixture() self.setup_fixture()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.archived"))
self.open(reverse("linkding:bookmarks.archived"), p)
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Archived Bookmark 2").locator( self.locate_bookmark("Archived Bookmark 2").locator(
"label.bulk-edit-checkbox" "label.bulk-edit-checkbox"
).click() ).click()
self.select_bulk_action("Unarchive") self.select_bulk_action("Unarchive")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"]) self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"]) self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
self.assertReloads(0) self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_bulk_delete(self): def test_archived_bookmarks_partial_update_on_bulk_delete(self):
self.setup_fixture() self.setup_fixture()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.archived"))
self.open(reverse("linkding:bookmarks.archived"), p)
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Archived Bookmark 2").locator( self.locate_bookmark("Archived Bookmark 2").locator(
"label.bulk-edit-checkbox" "label.bulk-edit-checkbox"
).click() ).click()
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"]) self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"]) self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
self.assertReloads(0) self.assertReloads(0)
def test_shared_bookmarks_partial_update_on_unarchive(self): def test_shared_bookmarks_partial_update_on_unarchive(self):
self.setup_fixture() self.setup_fixture()
@@ -264,24 +246,23 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
3, shared=True, prefix="My Bookmark", with_tags=True 3, shared=True, prefix="My Bookmark", with_tags=True
) )
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.shared"))
self.open(reverse("linkding:bookmarks.shared"), p)
self.locate_bookmark("My Bookmark 2").get_by_text("Archive").click() self.locate_bookmark("My Bookmark 2").get_by_text("Archive").click()
# Shared bookmarks page also shows archived bookmarks, though it probably shouldn't # Shared bookmarks page also shows archived bookmarks, though it probably shouldn't
self.assertVisibleBookmarks( self.assertVisibleBookmarks(
[ [
"My Bookmark 1", "My Bookmark 1",
"My Bookmark 2", "My Bookmark 2",
"My Bookmark 3", "My Bookmark 3",
"Joe's Bookmark 1", "Joe's Bookmark 1",
"Joe's Bookmark 2", "Joe's Bookmark 2",
"Joe's Bookmark 3", "Joe's Bookmark 3",
] ]
) )
self.assertVisibleTags(["Shared Tag 1", "Shared Tag 2", "Shared Tag 3"]) self.assertVisibleTags(["Shared Tag 1", "Shared Tag 2", "Shared Tag 3"])
self.assertReloads(0) self.assertReloads(0)
def test_shared_bookmarks_partial_update_on_delete(self): def test_shared_bookmarks_partial_update_on_delete(self):
self.setup_fixture() self.setup_fixture()
@@ -289,20 +270,19 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
3, shared=True, prefix="My Bookmark", with_tags=True 3, shared=True, prefix="My Bookmark", with_tags=True
) )
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.shared"))
self.open(reverse("linkding:bookmarks.shared"), p)
self.locate_bookmark("My Bookmark 2").get_by_text("Remove").click() self.locate_bookmark("My Bookmark 2").get_by_text("Remove").click()
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks( self.assertVisibleBookmarks(
[ [
"My Bookmark 1", "My Bookmark 1",
"My Bookmark 3", "My Bookmark 3",
"Joe's Bookmark 1", "Joe's Bookmark 1",
"Joe's Bookmark 2", "Joe's Bookmark 2",
"Joe's Bookmark 3", "Joe's Bookmark 3",
] ]
) )
self.assertVisibleTags(["Shared Tag 1", "Shared Tag 3"]) self.assertVisibleTags(["Shared Tag 1", "Shared Tag 3"])
self.assertReloads(0) self.assertReloads(0)

View File

@@ -1,5 +1,5 @@
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
@@ -9,39 +9,38 @@ class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
group1 = self.setup_numbered_bookmarks(3, prefix="foo") group1 = self.setup_numbered_bookmarks(3, prefix="foo")
group2 = self.setup_numbered_bookmarks(3, prefix="bar") group2 = self.setup_numbered_bookmarks(3, prefix="bar")
with sync_playwright() as p: # shows all bookmarks initially
# shows all bookmarks initially page = self.open(reverse("linkding:bundles.new"))
page = self.open(reverse("linkding:bundles.new"), p)
expect( expect(
page.get_by_text(f"Found 6 bookmarks matching this bundle") page.get_by_text(f"Found 6 bookmarks matching this bundle")
).to_be_visible() ).to_be_visible()
self.assertVisibleBookmarks(group1 + group2) self.assertVisibleBookmarks(group1 + group2)
# filter by group1 # filter by group1
search = page.get_by_label("Search") search = page.get_by_label("Search")
search.fill("foo") search.fill("foo")
expect( expect(
page.get_by_text(f"Found 3 bookmarks matching this bundle") page.get_by_text(f"Found 3 bookmarks matching this bundle")
).to_be_visible() ).to_be_visible()
self.assertVisibleBookmarks(group1) self.assertVisibleBookmarks(group1)
# filter by group2 # filter by group2
search.fill("bar") search.fill("bar")
expect( expect(
page.get_by_text(f"Found 3 bookmarks matching this bundle") page.get_by_text(f"Found 3 bookmarks matching this bundle")
).to_be_visible() ).to_be_visible()
self.assertVisibleBookmarks(group2) self.assertVisibleBookmarks(group2)
# filter by invalid group # filter by invalid group
search.fill("invalid") search.fill("invalid")
expect( expect(
page.get_by_text(f"No bookmarks match the current bundle") page.get_by_text(f"No bookmarks match the current bundle")
).to_be_visible() ).to_be_visible()
self.assertVisibleBookmarks([]) self.assertVisibleBookmarks([])
def assertVisibleBookmarks(self, bookmarks): def assertVisibleBookmarks(self, bookmarks):
self.assertEqual(len(bookmarks), self.count_bookmarks()) self.assertEqual(len(bookmarks), self.count_bookmarks())

View File

@@ -1,5 +1,5 @@
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
@@ -22,31 +22,25 @@ class CollapseSidePanelE2ETestCase(LinkdingE2ETestCase):
).to_be_visible() ).to_be_visible()
def test_side_panel_should_be_visible_by_default(self): def test_side_panel_should_be_visible_by_default(self):
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p) self.assertSidePanelIsVisible()
self.assertSidePanelIsVisible()
self.page.goto( self.page.goto(self.live_server_url + reverse("linkding:bookmarks.archived"))
self.live_server_url + reverse("linkding:bookmarks.archived") self.assertSidePanelIsVisible()
)
self.assertSidePanelIsVisible()
self.page.goto(self.live_server_url + reverse("linkding:bookmarks.shared")) self.page.goto(self.live_server_url + reverse("linkding:bookmarks.shared"))
self.assertSidePanelIsVisible() self.assertSidePanelIsVisible()
def test_side_panel_should_be_hidden_when_collapsed(self): def test_side_panel_should_be_hidden_when_collapsed(self):
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
user.profile.collapse_side_panel = True user.profile.collapse_side_panel = True
user.profile.save() user.profile.save()
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p) self.assertSidePanelIsHidden()
self.assertSidePanelIsHidden()
self.page.goto( self.page.goto(self.live_server_url + reverse("linkding:bookmarks.archived"))
self.live_server_url + reverse("linkding:bookmarks.archived") self.assertSidePanelIsHidden()
)
self.assertSidePanelIsHidden()
self.page.goto(self.live_server_url + reverse("linkding:bookmarks.shared")) self.page.goto(self.live_server_url + reverse("linkding:bookmarks.shared"))
self.assertSidePanelIsHidden() self.assertSidePanelIsHidden()

View File

@@ -1,5 +1,5 @@
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
@@ -18,111 +18,105 @@ class DropdownE2ETestCase(LinkdingE2ETestCase):
return self.locate_dropdown().locator(".menu") return self.locate_dropdown().locator(".menu")
def test_click_toggle_opens_and_closes_dropdown(self): def test_click_toggle_opens_and_closes_dropdown(self):
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
toggle = self.locate_dropdown_toggle() toggle = self.locate_dropdown_toggle()
menu = self.locate_dropdown_menu() menu = self.locate_dropdown_menu()
# Open dropdown # Open dropdown
toggle.click() toggle.click()
expect(menu).to_be_visible() expect(menu).to_be_visible()
# Click toggle again to close # Click toggle again to close
toggle.click() toggle.click()
expect(menu).not_to_be_visible() expect(menu).not_to_be_visible()
def test_outside_click_closes_dropdown(self): def test_outside_click_closes_dropdown(self):
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
toggle = self.locate_dropdown_toggle() toggle = self.locate_dropdown_toggle()
menu = self.locate_dropdown_menu() menu = self.locate_dropdown_menu()
# Open dropdown # Open dropdown
toggle.click() toggle.click()
expect(menu).to_be_visible() expect(menu).to_be_visible()
# Click outside the dropdown (on the page body) # Click outside the dropdown (on the page body)
self.page.locator("main").click() self.page.locator("main").click()
expect(menu).not_to_be_visible() expect(menu).not_to_be_visible()
def test_escape_closes_dropdown_and_restores_focus(self): def test_escape_closes_dropdown_and_restores_focus(self):
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
toggle = self.locate_dropdown_toggle() toggle = self.locate_dropdown_toggle()
menu = self.locate_dropdown_menu() menu = self.locate_dropdown_menu()
# Open dropdown # Open dropdown
toggle.click() toggle.click()
expect(menu).to_be_visible() expect(menu).to_be_visible()
# Press Escape # Press Escape
self.page.keyboard.press("Escape") self.page.keyboard.press("Escape")
# Menu should be closed # Menu should be closed
expect(menu).not_to_be_visible() expect(menu).not_to_be_visible()
# Focus should be back on toggle # Focus should be back on toggle
expect(toggle).to_be_focused() expect(toggle).to_be_focused()
def test_focus_out_closes_dropdown(self): def test_focus_out_closes_dropdown(self):
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
toggle = self.locate_dropdown_toggle() toggle = self.locate_dropdown_toggle()
menu = self.locate_dropdown_menu() menu = self.locate_dropdown_menu()
# Open dropdown # Open dropdown
toggle.click() toggle.click()
expect(menu).to_be_visible() expect(menu).to_be_visible()
# Shift+Tab to move focus out of the dropdown # Shift+Tab to move focus out of the dropdown
self.page.keyboard.press("Shift+Tab") self.page.keyboard.press("Shift+Tab")
# Menu should be closed after focus leaves # Menu should be closed after focus leaves
expect(menu).not_to_be_visible() expect(menu).not_to_be_visible()
def test_aria_expanded_attribute(self): def test_aria_expanded_attribute(self):
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
toggle = self.locate_dropdown_toggle() toggle = self.locate_dropdown_toggle()
menu = self.locate_dropdown_menu() menu = self.locate_dropdown_menu()
# Initially aria-expanded should be false # Initially aria-expanded should be false
expect(toggle).to_have_attribute("aria-expanded", "false") expect(toggle).to_have_attribute("aria-expanded", "false")
# Open dropdown # Open dropdown
toggle.click() toggle.click()
expect(menu).to_be_visible() expect(menu).to_be_visible()
# aria-expanded should be true # aria-expanded should be true
expect(toggle).to_have_attribute("aria-expanded", "true") expect(toggle).to_have_attribute("aria-expanded", "true")
# Close dropdown # Close dropdown
toggle.click() toggle.click()
expect(menu).not_to_be_visible() expect(menu).not_to_be_visible()
# aria-expanded should be false again # aria-expanded should be false again
expect(toggle).to_have_attribute("aria-expanded", "false") expect(toggle).to_have_attribute("aria-expanded", "false")
def test_can_click_menu_item(self): def test_can_click_menu_item(self):
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
self.open(reverse("linkding:bookmarks.index"), p)
toggle = self.locate_dropdown_toggle() toggle = self.locate_dropdown_toggle()
menu = self.locate_dropdown_menu() menu = self.locate_dropdown_menu()
# Open dropdown # Open dropdown
toggle.click() toggle.click()
expect(menu).to_be_visible() expect(menu).to_be_visible()
# Click on "Archived" menu item # Click on "Archived" menu item
menu.get_by_text("Archived", exact=True).click() menu.get_by_text("Archived", exact=True).click()
# Should navigate to archived page # Should navigate to archived page
expect(self.page).to_have_url( expect(self.page).to_have_url(
self.live_server_url + reverse("linkding:bookmarks.archived") self.live_server_url + reverse("linkding:bookmarks.archived")
) )

View File

@@ -1,7 +1,7 @@
from unittest.mock import patch from unittest.mock import patch
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
from bookmarks.services import website_loader from bookmarks.services import website_loader
@@ -24,51 +24,47 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
self.website_loader_patch.start() self.website_loader_patch.start()
def tearDown(self) -> None: def tearDown(self) -> None:
super().tearDown()
self.website_loader_patch.stop() self.website_loader_patch.stop()
super().tearDown()
def test_should_not_check_for_existing_bookmark(self): def test_should_not_check_for_existing_bookmark(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]))
page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]), p)
page.wait_for_timeout(timeout=1000) page.wait_for_timeout(timeout=1000)
page.get_by_text("This URL is already bookmarked.").wait_for(state="hidden") page.get_by_text("This URL is already bookmarked.").wait_for(state="hidden")
def test_should_not_prefill_title_and_description(self): def test_should_not_prefill_title_and_description(self):
bookmark = self.setup_bookmark( bookmark = self.setup_bookmark(
title="Initial title", description="Initial description" title="Initial title", description="Initial description"
) )
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]))
page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]), p) page.wait_for_timeout(timeout=1000)
page.wait_for_timeout(timeout=1000)
title = page.get_by_label("Title") title = page.get_by_label("Title")
description = page.get_by_label("Description") description = page.get_by_label("Description")
expect(title).to_have_value(bookmark.title) expect(title).to_have_value(bookmark.title)
expect(description).to_have_value(bookmark.description) expect(description).to_have_value(bookmark.description)
def test_enter_url_should_not_prefill_title_and_description(self): def test_enter_url_should_not_prefill_title_and_description(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]))
page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]), p)
page.get_by_label("URL").fill("https://example.com") page.get_by_label("URL").fill("https://example.com")
page.wait_for_timeout(timeout=1000) page.wait_for_timeout(timeout=1000)
title = page.get_by_label("Title") title = page.get_by_label("Title")
description = page.get_by_label("Description") description = page.get_by_label("Description")
expect(title).to_have_value(bookmark.title) expect(title).to_have_value(bookmark.title)
expect(description).to_have_value(bookmark.description) expect(description).to_have_value(bookmark.description)
def test_refresh_button_should_be_visible_when_editing(self): def test_refresh_button_should_be_visible_when_editing(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]))
page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]), p)
refresh_button = page.get_by_text("Refresh from website") refresh_button = page.get_by_text("Refresh from website")
expect(refresh_button).to_be_visible() expect(refresh_button).to_be_visible()

View File

@@ -1,5 +1,5 @@
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
@@ -9,62 +9,60 @@ class FilterDrawerE2ETestCase(LinkdingE2ETestCase):
self.setup_bookmark(tags=[self.setup_tag(name="cooking")]) self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
self.setup_bookmark(tags=[self.setup_tag(name="hiking")]) self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.index"))
page = self.open(reverse("linkding:bookmarks.index"), p)
# use smaller viewport to make filter button visible # use smaller viewport to make filter button visible
page.set_viewport_size({"width": 375, "height": 812}) page.set_viewport_size({"width": 375, "height": 812})
# open drawer # open drawer
drawer_trigger = page.locator(".main").get_by_role("button", name="Filters") drawer_trigger = page.locator(".main").get_by_role("button", name="Filters")
drawer_trigger.click() drawer_trigger.click()
# verify drawer is visible # verify drawer is visible
drawer = page.locator("ld-filter-drawer") drawer = page.locator("ld-filter-drawer")
expect(drawer).to_be_visible() expect(drawer).to_be_visible()
expect(drawer.locator("h2")).to_have_text("Filters") expect(drawer.locator("h2")).to_have_text("Filters")
# close with close button # close with close button
drawer.locator("button.close").click() drawer.locator("button.close").click()
expect(drawer).to_be_hidden() expect(drawer).to_be_hidden()
# open drawer again # open drawer again
drawer_trigger.click() drawer_trigger.click()
# close with backdrop # close with backdrop
backdrop = drawer.locator(".modal-overlay") backdrop = drawer.locator(".modal-overlay")
backdrop.click(position={"x": 0, "y": 0}) backdrop.click(position={"x": 0, "y": 0})
expect(drawer).to_be_hidden() expect(drawer).to_be_hidden()
def test_select_tag(self): def test_select_tag(self):
self.setup_bookmark(tags=[self.setup_tag(name="cooking")]) self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
self.setup_bookmark(tags=[self.setup_tag(name="hiking")]) self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.index"))
page = self.open(reverse("linkding:bookmarks.index"), p)
# use smaller viewport to make filter button visible # use smaller viewport to make filter button visible
page.set_viewport_size({"width": 375, "height": 812}) page.set_viewport_size({"width": 375, "height": 812})
# open tag cloud modal # open tag cloud modal
drawer_trigger = page.locator(".main").get_by_role("button", name="Filters") drawer_trigger = page.locator(".main").get_by_role("button", name="Filters")
drawer_trigger.click() drawer_trigger.click()
# verify tags are displayed # verify tags are displayed
drawer = page.locator("ld-filter-drawer") drawer = page.locator("ld-filter-drawer")
unselected_tags = drawer.locator(".unselected-tags") unselected_tags = drawer.locator(".unselected-tags")
expect(unselected_tags.get_by_text("cooking")).to_be_visible() expect(unselected_tags.get_by_text("cooking")).to_be_visible()
expect(unselected_tags.get_by_text("hiking")).to_be_visible() expect(unselected_tags.get_by_text("hiking")).to_be_visible()
# select tag # select tag
unselected_tags.get_by_text("cooking").click() unselected_tags.get_by_text("cooking").click()
# open drawer again # open drawer again
drawer_trigger.click() drawer_trigger.click()
# verify tag is selected, other tag is not visible anymore # verify tag is selected, other tag is not visible anymore
selected_tags = drawer.locator(".selected-tags") selected_tags = drawer.locator(".selected-tags")
expect(selected_tags.get_by_text("cooking")).to_be_visible() expect(selected_tags.get_by_text("cooking")).to_be_visible()
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible() expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()
expect(unselected_tags.get_by_text("hiking")).not_to_be_visible() expect(unselected_tags.get_by_text("hiking")).not_to_be_visible()

View File

@@ -1,32 +1,24 @@
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
class GlobalShortcutsE2ETestCase(LinkdingE2ETestCase): class GlobalShortcutsE2ETestCase(LinkdingE2ETestCase):
def test_focus_search(self): def test_focus_search(self):
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("linkding:bookmarks.index"))
page.press("body", "s") self.page.press("body", "s")
expect(page.get_by_placeholder("Search for words or #tags")).to_be_focused() expect(
self.page.get_by_placeholder("Search for words or #tags")
browser.close() ).to_be_focused()
def test_add_bookmark(self): def test_add_bookmark(self):
with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"))
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("linkding:bookmarks.index"))
page.press("body", "n") self.page.press("body", "n")
expect(page).to_have_url( expect(self.page).to_have_url(
self.live_server_url + reverse("linkding:bookmarks.new") self.live_server_url + reverse("linkding:bookmarks.new")
) )
browser.close()

View File

@@ -2,7 +2,7 @@ from unittest.mock import patch
from urllib.parse import quote from urllib.parse import quote
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.models import Bookmark from bookmarks.models import Bookmark
from bookmarks.services import website_loader from bookmarks.services import website_loader
@@ -26,74 +26,69 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
self.website_loader_mock = self.website_loader_patch.start() self.website_loader_mock = self.website_loader_patch.start()
def tearDown(self) -> None: def tearDown(self) -> None:
super().tearDown()
self.website_loader_patch.stop() self.website_loader_patch.stop()
super().tearDown()
def test_enter_url_prefills_title_and_description(self): def test_enter_url_prefills_title_and_description(self):
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.new"))
page = self.open(reverse("linkding:bookmarks.new"), p) url = page.get_by_label("URL")
url = page.get_by_label("URL") title = page.get_by_label("Title")
title = page.get_by_label("Title") description = page.get_by_label("Description")
description = page.get_by_label("Description")
url.fill("https://example.com") url.fill("https://example.com")
expect(title).to_have_value("Example Domain") expect(title).to_have_value("Example Domain")
expect(description).to_have_value( expect(description).to_have_value(
"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission." "This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission."
) )
def test_enter_url_does_not_overwrite_modified_title_and_description(self): def test_enter_url_does_not_overwrite_modified_title_and_description(self):
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.new"))
page = self.open(reverse("linkding:bookmarks.new"), p) url = page.get_by_label("URL")
url = page.get_by_label("URL") title = page.get_by_label("Title")
title = page.get_by_label("Title") description = page.get_by_label("Description")
description = page.get_by_label("Description")
title.fill("Modified title") title.fill("Modified title")
description.fill("Modified description") description.fill("Modified description")
url.fill("https://example.com") url.fill("https://example.com")
page.wait_for_timeout(timeout=1000) page.wait_for_timeout(timeout=1000)
expect(title).to_have_value("Modified title") expect(title).to_have_value("Modified title")
expect(description).to_have_value("Modified description") expect(description).to_have_value("Modified description")
def test_with_initial_url_prefills_title_and_description(self): def test_with_initial_url_prefills_title_and_description(self):
with sync_playwright() as p: page_url = (
page_url = ( reverse("linkding:bookmarks.new") + f"?url={quote('https://example.com')}"
reverse("linkding:bookmarks.new") )
+ f"?url={quote('https://example.com')}" page = self.open(page_url)
) url = page.get_by_label("URL")
page = self.open(page_url, p) title = page.get_by_label("Title")
url = page.get_by_label("URL") description = page.get_by_label("Description")
title = page.get_by_label("Title")
description = page.get_by_label("Description")
page.wait_for_timeout(timeout=1000) page.wait_for_timeout(timeout=1000)
expect(url).to_have_value("https://example.com") expect(url).to_have_value("https://example.com")
expect(title).to_have_value("Example Domain") expect(title).to_have_value("Example Domain")
expect(description).to_have_value( expect(description).to_have_value(
"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission." "This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission."
) )
def test_with_initial_url_title_description_does_not_overwrite_title_and_description( def test_with_initial_url_title_description_does_not_overwrite_title_and_description(
self, self,
): ):
with sync_playwright() as p: page_url = (
page_url = ( reverse("linkding:bookmarks.new")
reverse("linkding:bookmarks.new") + f"?url={quote('https://example.com')}&title=Initial+title&description=Initial+description"
+ f"?url={quote('https://example.com')}&title=Initial+title&description=Initial+description" )
) page = self.open(page_url)
page = self.open(page_url, p) url = page.get_by_label("URL")
url = page.get_by_label("URL") title = page.get_by_label("Title")
title = page.get_by_label("Title") description = page.get_by_label("Description")
description = page.get_by_label("Description")
page.wait_for_timeout(timeout=1000) page.wait_for_timeout(timeout=1000)
expect(url).to_have_value("https://example.com") expect(url).to_have_value("https://example.com")
expect(title).to_have_value("Initial title") expect(title).to_have_value("Initial title")
expect(description).to_have_value("Initial description") expect(description).to_have_value("Initial description")
def test_create_should_check_for_existing_bookmark(self): def test_create_should_check_for_existing_bookmark(self):
existing_bookmark = self.setup_bookmark( existing_bookmark = self.setup_bookmark(
@@ -105,170 +100,164 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
) )
tag_names = " ".join(existing_bookmark.tag_names) tag_names = " ".join(existing_bookmark.tag_names)
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.new"))
page = self.open(reverse("linkding:bookmarks.new"), p)
# Enter bookmarked URL # Enter bookmarked URL
page.get_by_label("URL").fill(existing_bookmark.url) page.get_by_label("URL").fill(existing_bookmark.url)
# Already bookmarked hint should be visible # Already bookmarked hint should be visible
page.get_by_text("This URL is already bookmarked.").wait_for(timeout=2000) page.get_by_text("This URL is already bookmarked.").wait_for(timeout=2000)
# Form should be pre-filled with data from existing bookmark # Form should be pre-filled with data from existing bookmark
self.assertEqual( self.assertEqual(
existing_bookmark.title, page.get_by_label("Title").input_value() existing_bookmark.title, page.get_by_label("Title").input_value()
) )
self.assertEqual( self.assertEqual(
existing_bookmark.description, existing_bookmark.description,
page.get_by_label("Description").input_value(), page.get_by_label("Description").input_value(),
) )
self.assertEqual( self.assertEqual(
existing_bookmark.notes, page.get_by_label("Notes").input_value() existing_bookmark.notes, page.get_by_label("Notes").input_value()
) )
self.assertEqual(tag_names, page.get_by_label("Tags").input_value()) self.assertEqual(tag_names, page.get_by_label("Tags").input_value())
self.assertTrue(tag_names, page.get_by_label("Mark as unread").is_checked()) self.assertTrue(tag_names, page.get_by_label("Mark as unread").is_checked())
# Enter non-bookmarked URL # Enter non-bookmarked URL
page.get_by_label("URL").fill("https://example.com/unknown") page.get_by_label("URL").fill("https://example.com/unknown")
# Already bookmarked hint should be hidden # Already bookmarked hint should be hidden
page.get_by_text("This URL is already bookmarked.").wait_for( page.get_by_text("This URL is already bookmarked.").wait_for(
state="hidden", timeout=2000 state="hidden", timeout=2000
) )
def test_enter_url_of_existing_bookmark_should_show_notes(self): def test_enter_url_of_existing_bookmark_should_show_notes(self):
bookmark = self.setup_bookmark( bookmark = self.setup_bookmark(
notes="Existing notes", description="Existing description" notes="Existing notes", description="Existing description"
) )
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.new"))
page = self.open(reverse("linkding:bookmarks.new"), p)
details = page.locator("details.notes") details = page.locator("details.notes")
expect(details).not_to_have_attribute("open", value="") expect(details).not_to_have_attribute("open", value="")
page.get_by_label("URL").fill(bookmark.url) page.get_by_label("URL").fill(bookmark.url)
expect(details).to_have_attribute("open", value="") expect(details).to_have_attribute("open", value="")
def test_create_should_preview_auto_tags(self): def test_create_should_preview_auto_tags(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
profile.auto_tagging_rules = "github.com dev github" profile.auto_tagging_rules = "github.com dev github"
profile.save() profile.save()
with sync_playwright() as p: # Open page with URL that should have auto tags
# Open page with URL that should have auto tags url = (
url = ( reverse("linkding:bookmarks.new")
reverse("linkding:bookmarks.new") + "?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
+ "?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding" )
) page = self.open(url)
page = self.open(url, p)
auto_tags_hint = page.locator(".form-input-hint.auto-tags") auto_tags_hint = page.locator(".form-input-hint.auto-tags")
expect(auto_tags_hint).to_be_visible() expect(auto_tags_hint).to_be_visible()
expect(auto_tags_hint).to_have_text("Auto tags: dev github") expect(auto_tags_hint).to_have_text("Auto tags: dev github")
# Change to URL without auto tags # Change to URL without auto tags
page.get_by_label("URL").fill("https://example.com") page.get_by_label("URL").fill("https://example.com")
expect(auto_tags_hint).to_be_hidden() expect(auto_tags_hint).to_be_hidden()
def test_clear_buttons_only_shown_when_fields_have_content(self): def test_clear_buttons_only_shown_when_fields_have_content(self):
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.new"))
page = self.open(reverse("linkding:bookmarks.new"), p)
title_field = page.get_by_label("Title") title_field = page.get_by_label("Title")
title_clear_button = page.locator("ld-clear-button[data-for='id_title']") title_clear_button = page.locator("ld-clear-button[data-for='id_title']")
description_field = page.get_by_label("Description") description_field = page.get_by_label("Description")
description_clear_button = page.locator( description_clear_button = page.locator(
"ld-clear-button[data-for='id_description']" "ld-clear-button[data-for='id_description']"
) )
# Initially, clear buttons should be hidden because fields are empty # Initially, clear buttons should be hidden because fields are empty
expect(title_clear_button).to_be_hidden() expect(title_clear_button).to_be_hidden()
expect(description_clear_button).to_be_hidden() expect(description_clear_button).to_be_hidden()
# Add content to title field, its clear button should become visible # Add content to title field, its clear button should become visible
title_field.fill("Test title") title_field.fill("Test title")
expect(title_clear_button).to_be_visible() expect(title_clear_button).to_be_visible()
# Add content to description field, its clear button should become visible # Add content to description field, its clear button should become visible
description_field.fill("Test description") description_field.fill("Test description")
expect(description_clear_button).to_be_visible() expect(description_clear_button).to_be_visible()
# Clear title field, its clear button should be hidden again # Clear title field, its clear button should be hidden again
title_field.fill("") title_field.fill("")
expect(title_clear_button).to_be_hidden() expect(title_clear_button).to_be_hidden()
# Clear description field, its clear button should be hidden again # Clear description field, its clear button should be hidden again
description_field.fill("") description_field.fill("")
expect(description_clear_button).to_be_hidden() expect(description_clear_button).to_be_hidden()
def test_refresh_button_only_shown_for_existing_bookmarks(self): def test_refresh_button_only_shown_for_existing_bookmarks(self):
existing_bookmark = self.setup_bookmark( existing_bookmark = self.setup_bookmark(
title="Existing title", description="Existing description" title="Existing title", description="Existing description"
) )
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.new"))
page = self.open(reverse("linkding:bookmarks.new"), p) refresh_button = page.locator("#refresh-button")
refresh_button = page.locator("#refresh-button")
# Initially, refresh button should be hidden # Initially, refresh button should be hidden
expect(refresh_button).to_be_hidden() expect(refresh_button).to_be_hidden()
# Enter a URL that is not bookmarked yet # Enter a URL that is not bookmarked yet
page.get_by_label("URL").fill("https://example.com/not-bookmarked") page.get_by_label("URL").fill("https://example.com/not-bookmarked")
page.wait_for_timeout(timeout=1000) page.wait_for_timeout(timeout=1000)
expect(refresh_button).to_be_hidden() expect(refresh_button).to_be_hidden()
# Enter a URL that is already bookmarked # Enter a URL that is already bookmarked
page.get_by_label("URL").fill(existing_bookmark.url) page.get_by_label("URL").fill(existing_bookmark.url)
expect(refresh_button).to_be_visible() expect(refresh_button).to_be_visible()
# Change back to non-bookmarked URL # Change back to non-bookmarked URL
page.get_by_label("URL").fill("https://example.com/another-not-bookmarked") page.get_by_label("URL").fill("https://example.com/another-not-bookmarked")
expect(refresh_button).to_be_hidden() expect(refresh_button).to_be_hidden()
def test_refresh_from_website_button_updates_title_and_description(self): def test_refresh_from_website_button_updates_title_and_description(self):
existing_bookmark = self.setup_bookmark( existing_bookmark = self.setup_bookmark(
title="Existing title", description="Existing description" title="Existing title", description="Existing description"
) )
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.new"))
page = self.open(reverse("linkding:bookmarks.new"), p) url_field = page.get_by_label("URL")
url_field = page.get_by_label("URL") title_field = page.get_by_label("Title")
title_field = page.get_by_label("Title") description_field = page.get_by_label("Description")
description_field = page.get_by_label("Description") refresh_button = page.locator("#refresh-button")
refresh_button = page.locator("#refresh-button")
# Enter the URL of the existing bookmark to make refresh button visible # Enter the URL of the existing bookmark to make refresh button visible
url_field.fill(existing_bookmark.url) url_field.fill(existing_bookmark.url)
# Wait for metadata to be loaded and for refresh button to be visible # Wait for metadata to be loaded and for refresh button to be visible
expect(refresh_button).to_be_visible() expect(refresh_button).to_be_visible()
expect(title_field).to_have_value("Existing title") expect(title_field).to_have_value("Existing title")
expect(description_field).to_have_value("Existing description") expect(description_field).to_have_value("Existing description")
# Update the mock to return different metadata when refresh is requested # Update the mock to return different metadata when refresh is requested
self.website_loader_mock.reset_mock() self.website_loader_mock.reset_mock()
self.website_loader_mock.return_value = website_loader.WebsiteMetadata( self.website_loader_mock.return_value = website_loader.WebsiteMetadata(
url="https://example.com", url="https://example.com",
title="Updated Example Domain", title="Updated Example Domain",
description="This is a refreshed description for the example domain.", description="This is a refreshed description for the example domain.",
preview_image=None, preview_image=None,
) )
# Click the refresh button # Click the refresh button
refresh_button.click() refresh_button.click()
# Verify that title and description have been updated with new values # Verify that title and description have been updated with new values
expect(title_field).to_have_value("Updated Example Domain") expect(title_field).to_have_value("Updated Example Domain")
expect(description_field).to_have_value( expect(description_field).to_have_value(
"This is a refreshed description for the example domain." "This is a refreshed description for the example domain."
) )
# Verify that the fields are visually marked as modified # Verify that the fields are visually marked as modified
expect(title_field).to_have_class("form-input modified") expect(title_field).to_have_class("form-input modified")
expect(description_field).to_have_class("form-input modified") expect(description_field).to_have_class("form-input modified")
def test_refresh_from_website_button_does_not_modify_fields_if_metadata_is_same( def test_refresh_from_website_button_does_not_modify_fields_if_metadata_is_same(
self, self,
@@ -277,59 +266,57 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
title="Existing title", description="Existing description" title="Existing title", description="Existing description"
) )
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.new"))
page = self.open(reverse("linkding:bookmarks.new"), p) url_field = page.get_by_label("URL")
url_field = page.get_by_label("URL") title_field = page.get_by_label("Title")
title_field = page.get_by_label("Title") description_field = page.get_by_label("Description")
description_field = page.get_by_label("Description") refresh_button = page.locator("#refresh-button")
refresh_button = page.locator("#refresh-button")
# Enter the URL of the existing bookmark to make refresh button visible # Enter the URL of the existing bookmark to make refresh button visible
url_field.fill(existing_bookmark.url) url_field.fill(existing_bookmark.url)
# Wait for metadata to be loaded and for refresh button to be visible # Wait for metadata to be loaded and for refresh button to be visible
expect(refresh_button).to_be_visible() expect(refresh_button).to_be_visible()
expect(title_field).to_have_value("Existing title") expect(title_field).to_have_value("Existing title")
expect(description_field).to_have_value("Existing description") expect(description_field).to_have_value("Existing description")
# Update the mock to return same metadata when refresh is requested # Update the mock to return same metadata when refresh is requested
self.website_loader_mock.reset_mock() self.website_loader_mock.reset_mock()
self.website_loader_mock.return_value = website_loader.WebsiteMetadata( self.website_loader_mock.return_value = website_loader.WebsiteMetadata(
url="https://example.com", url="https://example.com",
title="Existing title", title="Existing title",
description="Existing description", description="Existing description",
preview_image=None, preview_image=None,
) )
# Click the refresh button # Click the refresh button
refresh_button.click() refresh_button.click()
page.wait_for_timeout(timeout=1000) page.wait_for_timeout(timeout=1000)
# Verify that title and description values are still the same # Verify that title and description values are still the same
expect(title_field).to_have_value("Existing title") expect(title_field).to_have_value("Existing title")
expect(description_field).to_have_value("Existing description") expect(description_field).to_have_value("Existing description")
# Verify that the fields are NOT visually marked as modified # Verify that the fields are NOT visually marked as modified
expect(title_field).to_have_class("form-input") expect(title_field).to_have_class("form-input")
expect(description_field).to_have_class("form-input") expect(description_field).to_have_class("form-input")
def test_ctrl_enter_submits_form_from_description(self): def test_ctrl_enter_submits_form_from_description(self):
with sync_playwright() as p: page = self.open(reverse("linkding:bookmarks.new"))
page = self.open(reverse("linkding:bookmarks.new"), p) url_field = page.get_by_label("URL")
url_field = page.get_by_label("URL") description_field = page.get_by_label("Description")
description_field = page.get_by_label("Description")
url_field.fill("https://example.com") url_field.fill("https://example.com")
description_field.fill("Test description") description_field.fill("Test description")
description_field.focus() description_field.focus()
# Press Ctrl+Enter to submit form # Press Ctrl+Enter to submit form
description_field.press("Control+Enter") description_field.press("Control+Enter")
# Should navigate away from new bookmark page after successful submission # Should navigate away from new bookmark page after successful submission
expect(page).not_to_have_url( expect(page).not_to_have_url(
self.live_server_url + reverse("linkding:bookmarks.new") self.live_server_url + reverse("linkding:bookmarks.new")
) )
self.assertEqual(1, Bookmark.objects.count()) self.assertEqual(1, Bookmark.objects.count())
bookmark = Bookmark.objects.first() bookmark = Bookmark.objects.first()

View File

@@ -1,5 +1,5 @@
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import UserProfile from bookmarks.models import UserProfile
@@ -7,54 +7,51 @@ from bookmarks.models import UserProfile
class SettingsGeneralE2ETestCase(LinkdingE2ETestCase): class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
def test_should_only_enable_public_sharing_if_sharing_is_enabled(self): def test_should_only_enable_public_sharing_if_sharing_is_enabled(self):
with sync_playwright() as p: self.open(reverse("linkding:settings.general"))
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("linkding:settings.general"))
enable_sharing = page.get_by_label("Enable bookmark sharing") enable_sharing = self.page.get_by_label("Enable bookmark sharing")
enable_sharing_label = page.get_by_text("Enable bookmark sharing") enable_sharing_label = self.page.get_by_text("Enable bookmark sharing")
enable_public_sharing = page.get_by_label("Enable public bookmark sharing") enable_public_sharing = self.page.get_by_label("Enable public bookmark sharing")
enable_public_sharing_label = page.get_by_text( enable_public_sharing_label = self.page.get_by_text(
"Enable public bookmark sharing" "Enable public bookmark sharing"
) )
default_mark_shared = page.get_by_label( default_mark_shared = self.page.get_by_label(
"Create bookmarks as shared by default" "Create bookmarks as shared by default"
) )
default_mark_shared_label = page.get_by_text( default_mark_shared_label = self.page.get_by_text(
"Create bookmarks as shared by default" "Create bookmarks as shared by default"
) )
# Public sharing and default shared are disabled by default # Public sharing and default shared are disabled by default
expect(enable_sharing).not_to_be_checked() expect(enable_sharing).not_to_be_checked()
expect(enable_public_sharing).not_to_be_checked() expect(enable_public_sharing).not_to_be_checked()
expect(enable_public_sharing).to_be_disabled() expect(enable_public_sharing).to_be_disabled()
expect(default_mark_shared).not_to_be_checked() expect(default_mark_shared).not_to_be_checked()
expect(default_mark_shared).to_be_disabled() expect(default_mark_shared).to_be_disabled()
# Enable sharing # Enable sharing
enable_sharing_label.click() enable_sharing_label.click()
expect(enable_sharing).to_be_checked() expect(enable_sharing).to_be_checked()
expect(enable_public_sharing).not_to_be_checked() expect(enable_public_sharing).not_to_be_checked()
expect(enable_public_sharing).to_be_enabled() expect(enable_public_sharing).to_be_enabled()
expect(default_mark_shared).not_to_be_checked() expect(default_mark_shared).not_to_be_checked()
expect(default_mark_shared).to_be_enabled() expect(default_mark_shared).to_be_enabled()
# Enable public sharing and default shared # Enable public sharing and default shared
enable_public_sharing_label.click() enable_public_sharing_label.click()
default_mark_shared_label.click() default_mark_shared_label.click()
expect(enable_public_sharing).to_be_checked() expect(enable_public_sharing).to_be_checked()
expect(enable_public_sharing).to_be_enabled() expect(enable_public_sharing).to_be_enabled()
expect(default_mark_shared).to_be_checked() expect(default_mark_shared).to_be_checked()
expect(default_mark_shared).to_be_enabled() expect(default_mark_shared).to_be_enabled()
# Disable sharing # Disable sharing
enable_sharing_label.click() enable_sharing_label.click()
expect(enable_sharing).not_to_be_checked() expect(enable_sharing).not_to_be_checked()
expect(enable_public_sharing).not_to_be_checked() expect(enable_public_sharing).not_to_be_checked()
expect(enable_public_sharing).to_be_disabled() expect(enable_public_sharing).to_be_disabled()
expect(default_mark_shared).not_to_be_checked() expect(default_mark_shared).not_to_be_checked()
expect(default_mark_shared).to_be_disabled() expect(default_mark_shared).to_be_disabled()
def test_should_not_show_bookmark_description_max_lines_when_display_inline(self): def test_should_not_show_bookmark_description_max_lines_when_display_inline(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@@ -63,13 +60,10 @@ class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
) )
profile.save() profile.save()
with sync_playwright() as p: self.open(reverse("linkding:settings.general"))
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("linkding:settings.general"))
max_lines = page.get_by_label("Bookmark description max lines") max_lines = self.page.get_by_label("Bookmark description max lines")
expect(max_lines).to_be_hidden() expect(max_lines).to_be_hidden()
def test_should_show_bookmark_description_max_lines_when_display_separate(self): def test_should_show_bookmark_description_max_lines_when_display_separate(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@@ -78,26 +72,20 @@ class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
) )
profile.save() profile.save()
with sync_playwright() as p: self.open(reverse("linkding:settings.general"))
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("linkding:settings.general"))
max_lines = page.get_by_label("Bookmark description max lines") max_lines = self.page.get_by_label("Bookmark description max lines")
expect(max_lines).to_be_visible() expect(max_lines).to_be_visible()
def test_should_update_bookmark_description_max_lines_when_changing_display(self): def test_should_update_bookmark_description_max_lines_when_changing_display(self):
with sync_playwright() as p: self.open(reverse("linkding:settings.general"))
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("linkding:settings.general"))
max_lines = page.get_by_label("Bookmark description max lines") max_lines = self.page.get_by_label("Bookmark description max lines")
expect(max_lines).to_be_hidden() expect(max_lines).to_be_hidden()
display = page.get_by_label("Bookmark description", exact=True) display = self.page.get_by_label("Bookmark description", exact=True)
display.select_option("separate") display.select_option("separate")
expect(max_lines).to_be_visible() expect(max_lines).to_be_visible()
display.select_option("inline") display.select_option("inline")
expect(max_lines).to_be_hidden() expect(max_lines).to_be_hidden()

View File

@@ -1,67 +1,65 @@
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
class SettingsIntegrationsE2ETestCase(LinkdingE2ETestCase): class SettingsIntegrationsE2ETestCase(LinkdingE2ETestCase):
def test_create_api_token(self): def test_create_api_token(self):
with sync_playwright() as p: self.open(reverse("linkding:settings.integrations"))
self.open(reverse("linkding:settings.integrations"), p)
# Click create API token button # Click create API token button
self.page.get_by_text("Create API token").click() self.page.get_by_text("Create API token").click()
# Wait for modal to appear # Wait for modal to appear
modal = self.page.locator(".modal") modal = self.page.locator(".modal")
expect(modal).to_be_visible() expect(modal).to_be_visible()
# Enter custom token name # Enter custom token name
token_name_input = modal.locator("#token-name") token_name_input = modal.locator("#token-name")
token_name_input.fill("") token_name_input.fill("")
token_name_input.fill("My Test Token") token_name_input.fill("My Test Token")
# Confirm the dialog # Confirm the dialog
modal.page.get_by_role("button", name="Create Token").click() modal.page.get_by_role("button", name="Create Token").click()
# Verify the API token key is shown in the input # Verify the API token key is shown in the input
new_token_input = self.page.locator("#new-token-key") new_token_input = self.page.locator("#new-token-key")
expect(new_token_input).to_be_visible() expect(new_token_input).to_be_visible()
token_value = new_token_input.input_value() token_value = new_token_input.input_value()
self.assertTrue(len(token_value) > 0) self.assertTrue(len(token_value) > 0)
# Verify the API token is now listed in the table # Verify the API token is now listed in the table
token_table = self.page.locator("table.crud-table") token_table = self.page.locator("table.crud-table")
expect(token_table).to_be_visible() expect(token_table).to_be_visible()
expect(token_table.get_by_text("My Test Token")).to_be_visible() expect(token_table.get_by_text("My Test Token")).to_be_visible()
# Verify the dialog is gone # Verify the dialog is gone
expect(modal).to_be_hidden() expect(modal).to_be_hidden()
# Reload the page to verify the API token key is only shown once # Reload the page to verify the API token key is only shown once
self.page.reload() self.page.reload()
# Token key input should no longer be visible # Token key input should no longer be visible
expect(new_token_input).not_to_be_visible() expect(new_token_input).not_to_be_visible()
# But the token should still be listed in the table # But the token should still be listed in the table
expect(token_table.get_by_text("My Test Token")).to_be_visible() expect(token_table.get_by_text("My Test Token")).to_be_visible()
def test_delete_api_token(self): def test_delete_api_token(self):
self.setup_api_token(name="Token To Delete") self.setup_api_token(name="Token To Delete")
with sync_playwright() as p: self.open(reverse("linkding:settings.integrations"))
self.open(reverse("linkding:settings.integrations"), p)
token_table = self.page.locator("table.crud-table") token_table = self.page.locator("table.crud-table")
expect(token_table.get_by_text("Token To Delete")).to_be_visible() expect(token_table.get_by_text("Token To Delete")).to_be_visible()
# Click delete button for the token # Click delete button for the token
token_row = token_table.locator("tr").filter(has_text="Token To Delete") token_row = token_table.locator("tr").filter(has_text="Token To Delete")
token_row.get_by_role("button", name="Delete").click() token_row.get_by_role("button", name="Delete").click()
# Confirm deletion # Confirm deletion
self.locate_confirm_dialog().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# Verify the token is removed from the table # Verify the token is removed from the table
expect(token_table.get_by_text("Token To Delete")).not_to_be_visible() expect(token_table.get_by_text("Token To Delete")).not_to_be_visible()

View File

@@ -1,5 +1,5 @@
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import expect
from bookmarks.models import Tag from bookmarks.models import Tag
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
@@ -25,30 +25,29 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
expect(success_message).to_contain_text(text) expect(success_message).to_contain_text(text)
def test_create_tag(self): def test_create_tag(self):
with sync_playwright() as p: self.open(reverse("linkding:tags.index"))
self.open(reverse("linkding:tags.index"), p)
# Click the Create Tag button to open the modal # Click the Create Tag button to open the modal
self.page.get_by_text("Create Tag").click() self.page.get_by_text("Create Tag").click()
modal = self.locate_tag_modal() modal = self.locate_tag_modal()
# Fill in a tag name # Fill in a tag name
name_input = modal.get_by_label("Name") name_input = modal.get_by_label("Name")
name_input.fill("test-tag") name_input.fill("test-tag")
# Submit the form # Submit the form
modal.get_by_text("Save").click() modal.get_by_text("Save").click()
# Verify modal is closed and we're back on the tags page # Verify modal is closed and we're back on the tags page
expect(modal).not_to_be_visible() expect(modal).not_to_be_visible()
# Verify the success message is shown # Verify the success message is shown
self.verify_success_message('Tag "test-tag" created successfully.') self.verify_success_message('Tag "test-tag" created successfully.')
# Verify the new tag is shown in the list # Verify the new tag is shown in the list
tag_row = self.locate_tag_row("test-tag") tag_row = self.locate_tag_row("test-tag")
expect(tag_row).to_be_visible() expect(tag_row).to_be_visible()
# Verify the tag was actually created in the database # Verify the tag was actually created in the database
self.assertEqual( self.assertEqual(
@@ -60,31 +59,30 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
def test_create_tag_validation_error(self): def test_create_tag_validation_error(self):
existing_tag = self.setup_tag(name="existing-tag") existing_tag = self.setup_tag(name="existing-tag")
with sync_playwright() as p: self.open(reverse("linkding:tags.index"))
self.open(reverse("linkding:tags.index"), p)
# Click the Create Tag button to open the modal # Click the Create Tag button to open the modal
self.page.get_by_text("Create Tag").click() self.page.get_by_text("Create Tag").click()
modal = self.locate_tag_modal() modal = self.locate_tag_modal()
# Submit with empty value # Submit with empty value
modal.get_by_text("Save").click() modal.get_by_text("Save").click()
# Verify the error is shown (field is required) # Verify the error is shown (field is required)
error_hint = modal.get_by_text("This field is required") error_hint = modal.get_by_text("This field is required")
expect(error_hint).to_be_visible() expect(error_hint).to_be_visible()
# Fill in the name of an existing tag # Fill in the name of an existing tag
name_input = modal.get_by_label("Name") name_input = modal.get_by_label("Name")
name_input.fill(existing_tag.name) name_input.fill(existing_tag.name)
# Submit the form # Submit the form
modal.get_by_text("Save").click() modal.get_by_text("Save").click()
# Verify the error is shown (tag already exists) # Verify the error is shown (tag already exists)
error_hint = modal.get_by_text('Tag "existing-tag" already exists') error_hint = modal.get_by_text('Tag "existing-tag" already exists')
expect(error_hint).to_be_visible() expect(error_hint).to_be_visible()
# Verify no additional tag was created # Verify no additional tag was created
self.assertEqual( self.assertEqual(
@@ -94,34 +92,33 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
def test_edit_tag(self): def test_edit_tag(self):
tag = self.setup_tag(name="old-name") tag = self.setup_tag(name="old-name")
with sync_playwright() as p: self.open(reverse("linkding:tags.index"))
self.open(reverse("linkding:tags.index"), p)
# Click the Edit button for the tag # Click the Edit button for the tag
tag_row = self.locate_tag_row(tag.name) tag_row = self.locate_tag_row(tag.name)
tag_row.get_by_role("link", name="Edit").click() tag_row.get_by_role("link", name="Edit").click()
modal = self.locate_tag_modal() modal = self.locate_tag_modal()
# Verify the form is pre-filled with the tag name # Verify the form is pre-filled with the tag name
name_input = modal.get_by_label("Name") name_input = modal.get_by_label("Name")
expect(name_input).to_have_value(tag.name) expect(name_input).to_have_value(tag.name)
# Change the tag name # Change the tag name
name_input.fill("new-name") name_input.fill("new-name")
# Submit the form # Submit the form
modal.get_by_text("Save").click() modal.get_by_text("Save").click()
# Verify modal is closed # Verify modal is closed
expect(modal).not_to_be_visible() expect(modal).not_to_be_visible()
# Verify the success message is shown # Verify the success message is shown
self.verify_success_message('Tag "new-name" updated successfully.') self.verify_success_message('Tag "new-name" updated successfully.')
# Verify the updated tag is shown in the list # Verify the updated tag is shown in the list
expect(self.locate_tag_row("new-name")).to_be_visible() expect(self.locate_tag_row("new-name")).to_be_visible()
expect(self.locate_tag_row("old-name")).not_to_be_visible() expect(self.locate_tag_row("old-name")).not_to_be_visible()
# Verify the tag was updated in the database # Verify the tag was updated in the database
tag.refresh_from_db() tag.refresh_from_db()
@@ -131,31 +128,30 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
tag = self.setup_tag(name="tag-to-edit") tag = self.setup_tag(name="tag-to-edit")
other_tag = self.setup_tag(name="other-tag") other_tag = self.setup_tag(name="other-tag")
with sync_playwright() as p: self.open(reverse("linkding:tags.index"))
self.open(reverse("linkding:tags.index"), p)
# Click the Edit button for the tag # Click the Edit button for the tag
tag_row = self.locate_tag_row(tag.name) tag_row = self.locate_tag_row(tag.name)
tag_row.get_by_role("link", name="Edit").click() tag_row.get_by_role("link", name="Edit").click()
modal = self.locate_tag_modal() modal = self.locate_tag_modal()
# Clear the name and submit # Clear the name and submit
name_input = modal.get_by_label("Name") name_input = modal.get_by_label("Name")
name_input.fill("") name_input.fill("")
modal.get_by_text("Save").click() modal.get_by_text("Save").click()
# Verify the error is shown (field is required) # Verify the error is shown (field is required)
error_hint = modal.get_by_text("This field is required") error_hint = modal.get_by_text("This field is required")
expect(error_hint).to_be_visible() expect(error_hint).to_be_visible()
# Fill in the name of another existing tag # Fill in the name of another existing tag
name_input.fill(other_tag.name) name_input.fill(other_tag.name)
modal.get_by_text("Save").click() modal.get_by_text("Save").click()
# Verify the error is shown (tag already exists) # Verify the error is shown (tag already exists)
error_hint = modal.get_by_text('Tag "other-tag" already exists') error_hint = modal.get_by_text('Tag "other-tag" already exists')
expect(error_hint).to_be_visible() expect(error_hint).to_be_visible()
# Verify the tag was not modified # Verify the tag was not modified
tag.refresh_from_db() tag.refresh_from_db()
@@ -170,37 +166,36 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
bookmark1 = self.setup_bookmark(tags=[merge_tag1]) bookmark1 = self.setup_bookmark(tags=[merge_tag1])
bookmark2 = self.setup_bookmark(tags=[merge_tag2]) bookmark2 = self.setup_bookmark(tags=[merge_tag2])
with sync_playwright() as p: self.open(reverse("linkding:tags.index"))
self.open(reverse("linkding:tags.index"), p)
# Click the Merge Tags button to open the modal # Click the Merge Tags button to open the modal
self.page.get_by_text("Merge Tags", exact=True).click() self.page.get_by_text("Merge Tags", exact=True).click()
modal = self.locate_merge_modal() modal = self.locate_merge_modal()
# Fill in the target tag # Fill in the target tag
target_input = modal.get_by_label("Target tag") target_input = modal.get_by_label("Target tag")
target_input.fill(target_tag.name) target_input.fill(target_tag.name)
# Fill in the tags to merge # Fill in the tags to merge
merge_input = modal.get_by_label("Tags to merge") merge_input = modal.get_by_label("Tags to merge")
merge_input.fill(f"{merge_tag1.name} {merge_tag2.name}") merge_input.fill(f"{merge_tag1.name} {merge_tag2.name}")
# Submit the form # Submit the form
modal.get_by_role("button", name="Merge Tags").click() modal.get_by_role("button", name="Merge Tags").click()
# Verify modal is closed # Verify modal is closed
expect(modal).not_to_be_visible() expect(modal).not_to_be_visible()
# Verify the success message is shown # Verify the success message is shown
self.verify_success_message( self.verify_success_message(
'Successfully merged 2 tags (merge-tag1, merge-tag2) into "target-tag".' 'Successfully merged 2 tags (merge-tag1, merge-tag2) into "target-tag".'
) )
# Verify the merged tags are no longer in the list # Verify the merged tags are no longer in the list
expect(self.locate_tag_row("target-tag")).to_be_visible() expect(self.locate_tag_row("target-tag")).to_be_visible()
expect(self.locate_tag_row("merge-tag1")).not_to_be_visible() expect(self.locate_tag_row("merge-tag1")).not_to_be_visible()
expect(self.locate_tag_row("merge-tag2")).not_to_be_visible() expect(self.locate_tag_row("merge-tag2")).not_to_be_visible()
# Verify the merge tags were deleted # Verify the merge tags were deleted
self.assertEqual( self.assertEqual(
@@ -217,44 +212,43 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
target_tag = self.setup_tag(name="target-tag") target_tag = self.setup_tag(name="target-tag")
merge_tag = self.setup_tag(name="merge-tag") merge_tag = self.setup_tag(name="merge-tag")
with sync_playwright() as p: self.open(reverse("linkding:tags.index"))
self.open(reverse("linkding:tags.index"), p)
# Click the Merge Tags button to open the modal # Click the Merge Tags button to open the modal
self.page.get_by_text("Merge Tags", exact=True).click() self.page.get_by_text("Merge Tags", exact=True).click()
modal = self.locate_merge_modal() modal = self.locate_merge_modal()
# Submit with empty values # Submit with empty values
modal.get_by_role("button", name="Merge Tags").click() modal.get_by_role("button", name="Merge Tags").click()
# Verify the errors are shown # Verify the errors are shown
expect(modal.get_by_text("This field is required").first).to_be_visible() expect(modal.get_by_text("This field is required").first).to_be_visible()
# Fill in non-existent target tag # Fill in non-existent target tag
target_input = modal.get_by_label("Target tag") target_input = modal.get_by_label("Target tag")
target_input.fill("nonexistent-tag") target_input.fill("nonexistent-tag")
merge_input = modal.get_by_label("Tags to merge") merge_input = modal.get_by_label("Tags to merge")
merge_input.fill(merge_tag.name) merge_input.fill(merge_tag.name)
modal.get_by_role("button", name="Merge Tags").click() modal.get_by_role("button", name="Merge Tags").click()
# Verify error for non-existent target tag # Verify error for non-existent target tag
expect( expect(
modal.get_by_text('Tag "nonexistent-tag" does not exist') modal.get_by_text('Tag "nonexistent-tag" does not exist')
).to_be_visible() ).to_be_visible()
# Fill in valid target but target tag in merge tags # Fill in valid target but target tag in merge tags
target_input.fill(target_tag.name) target_input.fill(target_tag.name)
merge_input.fill(target_tag.name) merge_input.fill(target_tag.name)
modal.get_by_role("button", name="Merge Tags").click() modal.get_by_role("button", name="Merge Tags").click()
# Verify error for target tag in merge tags # Verify error for target tag in merge tags
expect( expect(
modal.get_by_text("The target tag cannot be selected for merging") modal.get_by_text("The target tag cannot be selected for merging")
).to_be_visible() ).to_be_visible()
# Verify no tags were deleted # Verify no tags were deleted
self.assertEqual( self.assertEqual(

View File

@@ -1,18 +1,66 @@
import os
from django.contrib.staticfiles.testing import LiveServerTestCase from django.contrib.staticfiles.testing import LiveServerTestCase
from playwright.sync_api import BrowserContext, Playwright, Page from playwright.sync_api import BrowserContext, Page, sync_playwright
from playwright.sync_api import expect from playwright.sync_api import expect
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
SCREENSHOT_DIR = "test-results/screenshots"
# Allow Django ORM operations within Playwright's async context
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin): class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
def setUp(self) -> None: def setUp(self) -> None:
self.client.force_login(self.get_or_create_test_user()) self.client.force_login(self.get_or_create_test_user())
self.cookie = self.client.cookies["sessionid"] self.cookie = self.client.cookies["sessionid"]
self.playwright = None
self.browser = None
self.context = None
self.page = None
def setup_browser(self, playwright) -> BrowserContext: def tearDown(self) -> None:
browser = playwright.chromium.launch(headless=True) if self.page and self._test_has_failed():
context = browser.new_context() self._capture_screenshot()
if self.browser:
self.browser.close()
if self.playwright:
self.playwright.stop()
super().tearDown()
def _test_has_failed(self) -> bool:
"""Detect if the current test has failed. Works with both Django/unittest and pytest."""
# Check _outcome for failure info
if self._outcome is not None:
result = self._outcome.result
if result:
# pytest stores exception info in _excinfo
if hasattr(result, "_excinfo") and result._excinfo:
return True
# Django/unittest stores failures and errors in the result
# Check if THIS test is in failures/errors (not just any test)
if hasattr(result, "failures"):
for failed_test, _ in result.failures:
if failed_test is self:
return True
return False
def _ensure_playwright(self):
if not self.playwright:
self.playwright = sync_playwright().start()
self.browser = self.playwright.chromium.launch(headless=True)
def _capture_screenshot(self):
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
filename = f"{self.__class__.__name__}_{self._testMethodName}.png"
filepath = os.path.join(SCREENSHOT_DIR, filename)
self.page.screenshot(path=filepath, full_page=True)
def setup_browser(self) -> BrowserContext:
self._ensure_playwright()
context = self.browser.new_context()
context.add_cookies( context.add_cookies(
[ [
{ {
@@ -25,14 +73,20 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
) )
return context return context
def open(self, url: str, playwright: Playwright) -> Page: def open(self, url: str) -> Page:
browser = self.setup_browser(playwright) self.context = self.setup_browser()
self.page = browser.new_page() self.page = self.context.new_page()
self.page.on("pageerror", self.on_page_error)
self.page.goto(self.live_server_url + url) self.page.goto(self.live_server_url + url)
self.page.on("load", self.on_load) self.page.on("load", self.on_load)
self.num_loads = 0 self.num_loads = 0
return self.page return self.page
def on_page_error(self, error):
print(f"[JS ERROR] {error}")
if hasattr(error, "stack"):
print(f"[JS STACK] {error.stack}")
def on_load(self): def on_load(self):
self.num_loads += 1 self.num_loads += 1

View File

@@ -1,8 +0,0 @@
# Example setup for OIDC with Zitadel
export LD_ENABLE_OIDC=True
export OIDC_USE_PKCE=True
export OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8080/oauth/v2/authorize
export OIDC_OP_TOKEN_ENDPOINT=http://localhost:8080/oauth/v2/token
export OIDC_OP_USER_ENDPOINT=http://localhost:8080/oidc/v1/userinfo
export OIDC_OP_JWKS_ENDPOINT=http://localhost:8080/oauth/v2/keys
export OIDC_RP_CLIENT_ID=<client-id>

View File

@@ -1,12 +0,0 @@
#!/usr/bin/env bash
# Make sure Chromium is installed
uv run playwright install chromium
# Test server loads assets from static folder, so make sure files there are up-to-date
rm -rf static
npm run build
uv run manage.py collectstatic
# Run E2E tests
uv run manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py"

View File

@@ -23,7 +23,8 @@ export LD_DB_ENGINE=postgres
export LD_DB_USER=linkding export LD_DB_USER=linkding
export LD_DB_PASSWORD=linkding export LD_DB_PASSWORD=linkding
./scripts/test.sh make test
make e2e
# Remove postgres container # Remove postgres container
docker rm -f linkding-postgres-test || true docker rm -f linkding-postgres-test || true

View File

@@ -1 +0,0 @@
uv run manage.py test bookmarks.tests

View File

@@ -1,2 +0,0 @@
./scripts/test-unit.sh
./scripts/test-e2e.sh