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
mkdir data
- name: Run tests
run: uv run manage.py test bookmarks.tests
run: uv run pytest -n auto
e2e_tests:
name: E2E Tests
runs-on: ubuntu-latest
@@ -59,4 +59,10 @@ jobs:
npm run build
uv run manage.py collectstatic
- 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
.hypothesis/
.pytest_cache/
test-results/
# Translations
*.mo

View File

@@ -1,14 +1,13 @@
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
class A11yNavigationFocusTest(LinkdingE2ETestCase):
def test_initial_page_load_focus(self):
with sync_playwright() as p:
# First page load should keep focus on the body
page = self.open(reverse("linkding:bookmarks.index"), p)
page = self.open(reverse("linkding:bookmarks.index"))
focused_tag = page.evaluate("document.activeElement?.tagName")
self.assertEqual("BODY", focused_tag)
@@ -31,8 +30,7 @@ class A11yNavigationFocusTest(LinkdingE2ETestCase):
def test_page_navigation_focus(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.index"), p)
page = self.open(reverse("linkding:bookmarks.index"))
# Subsequent navigation should move focus to main content
self.reset_focus()
@@ -64,9 +62,7 @@ class A11yNavigationFocusTest(LinkdingE2ETestCase):
# Closing modal should move focus back to the bookmark item
page.keyboard.press("Escape")
focused = self.locate_bookmark(bookmark.title).locator(
"a.view-action:focus"
)
focused = self.locate_bookmark(bookmark.title).locator("a.view-action:focus")
expect(focused).to_be_visible()
def reset_focus(self):

View File

@@ -1,6 +1,6 @@
from django.test import override_settings
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.models import Bookmark
@@ -10,8 +10,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
def test_show_details(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
details_modal = self.open_details_modal(bookmark)
title = details_modal.locator("h2")
@@ -20,8 +19,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
def test_close_details(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
# close with close button
details_modal = self.open_details_modal(bookmark)
@@ -42,10 +40,9 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
def test_toggle_archived(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
# archive
url = reverse("linkding:bookmarks.index")
self.open(url, p)
self.open(url)
details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Archived", exact=False).click()
@@ -65,10 +62,9 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
def test_toggle_unread(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
# mark as unread
url = reverse("linkding:bookmarks.index")
self.open(url, p)
self.open(url)
details_modal = self.open_details_modal(bookmark)
@@ -90,10 +86,9 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
# share bookmark
url = reverse("linkding:bookmarks.index")
self.open(url, p)
self.open(url)
details_modal = self.open_details_modal(bookmark)
@@ -111,9 +106,8 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
def test_edit_return_url(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
url = reverse("linkding:bookmarks.index") + f"?q={bookmark.title}"
self.open(url, p)
self.open(url)
details_modal = self.open_details_modal(bookmark)
@@ -129,9 +123,8 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
def test_delete(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
url = reverse("linkding:bookmarks.index") + f"?q={bookmark.title}"
self.open(url, p)
self.open(url)
details_modal = self.open_details_modal(bookmark)
@@ -152,9 +145,8 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
def test_create_snapshot_remove_snapshot(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
url = reverse("linkding:bookmarks.index") + f"?q={bookmark.title}"
self.open(url, p)
self.open(url)
details_modal = self.open_details_modal(bookmark)
asset_list = details_modal.locator(".assets")

View File

@@ -1,7 +1,7 @@
from unittest import skip
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
@@ -11,8 +11,7 @@ class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
def test_toggle_notes_should_show_hide_notes(self):
bookmark = self.setup_bookmark(notes="Test notes")
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.index"), p)
page = self.open(reverse("linkding:bookmarks.index"))
notes = self.locate_bookmark(bookmark.title).locator(".notes")
expect(notes).to_be_hidden()

View File

@@ -1,5 +1,5 @@
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.models import Bookmark
@@ -36,8 +36,7 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
def test_active_bookmarks_bulk_select_across(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
bookmark_list = self.locate_bookmark_list().element_handle()
self.locate_bulk_edit_toggle().click()
@@ -74,8 +73,7 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
def test_archived_bookmarks_bulk_select_across(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.archived"), p)
self.open(reverse("linkding:bookmarks.archived"))
bookmark_list = self.locate_bookmark_list().element_handle()
self.locate_bulk_edit_toggle().click()
@@ -112,8 +110,7 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
def test_active_bookmarks_bulk_select_across_respects_query(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index") + "?q=foo", p)
self.open(reverse("linkding:bookmarks.index") + "?q=foo")
bookmark_list = self.locate_bookmark_list().element_handle()
self.locate_bulk_edit_toggle().click()
@@ -150,8 +147,7 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
def test_archived_bookmarks_bulk_select_across_respects_query(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.archived") + "?q=foo", p)
self.open(reverse("linkding:bookmarks.archived") + "?q=foo")
bookmark_list = self.locate_bookmark_list().element_handle()
self.locate_bulk_edit_toggle().click()
@@ -188,9 +184,8 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
def test_select_all_toggles_all_checkboxes(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse("linkding:bookmarks.index")
page = self.open(url, p)
page = self.open(url)
self.locate_bulk_edit_toggle().click()
@@ -212,9 +207,8 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
def test_select_all_shows_select_across(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse("linkding:bookmarks.index")
self.open(url, p)
self.open(url)
self.locate_bulk_edit_toggle().click()
@@ -229,9 +223,8 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
def test_select_across_is_unchecked_when_toggling_all(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse("linkding:bookmarks.index")
self.open(url, p)
self.open(url)
self.locate_bulk_edit_toggle().click()
@@ -251,9 +244,8 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
def test_select_across_is_unchecked_when_toggling_bookmark(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse("linkding:bookmarks.index")
self.open(url, p)
self.open(url)
self.locate_bulk_edit_toggle().click()
@@ -263,23 +255,18 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
expect(self.locate_bulk_edit_select_across()).to_be_checked()
# Hide select across by toggling a single bookmark
self.locate_bookmark("Bookmark 1").locator(
"label.bulk-edit-checkbox"
).click()
self.locate_bookmark("Bookmark 1").locator("label.bulk-edit-checkbox").click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
# Show select across again, verify it is unchecked
self.locate_bookmark("Bookmark 1").locator(
"label.bulk-edit-checkbox"
).click()
self.locate_bookmark("Bookmark 1").locator("label.bulk-edit-checkbox").click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
def test_execute_resets_all_checkboxes(self):
self.setup_numbered_bookmarks(100)
with sync_playwright() as p:
url = reverse("linkding:bookmarks.index")
page = self.open(url, p)
page = self.open(url)
bookmark_list = self.locate_bookmark_list().element_handle()
@@ -309,9 +296,8 @@ class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
def test_update_select_across_bookmark_count(self):
self.setup_numbered_bookmarks(100)
with sync_playwright() as p:
url = reverse("linkding:bookmarks.index")
self.open(url, p)
self.open(url)
bookmark_list = self.locate_bookmark_list().element_handle()
self.locate_bulk_edit_toggle().click()

View File

@@ -1,7 +1,7 @@
from typing import List
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
@@ -43,9 +43,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.setup_numbered_bookmarks(5, prefix="foo")
self.setup_numbered_bookmarks(5, prefix="bar")
with sync_playwright() as p:
url = reverse("linkding:bookmarks.index") + "?q=foo"
self.open(url, p)
self.open(url)
self.assertVisibleBookmarks(["foo 1", "foo 2", "foo 3", "foo 4", "foo 5"])
@@ -55,9 +54,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def test_partial_update_respects_sort(self):
self.setup_numbered_bookmarks(5, prefix="foo")
with sync_playwright() as p:
url = reverse("linkding:bookmarks.index") + "?sort=title_asc"
page = self.open(url, p)
page = self.open(url)
first_item = page.locator("ul.bookmark-list > li").first
expect(first_item).to_contain_text("foo 1")
@@ -71,9 +69,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
# add a suffix, otherwise 'foo 1' also matches 'foo 10'
self.setup_numbered_bookmarks(50, prefix="foo", suffix="-")
with sync_playwright() as p:
url = reverse("linkding:bookmarks.index") + "?q=foo&page=2"
self.open(url, p)
self.open(url)
# with descending sort, page two has 'foo 1' to 'foo 20'
expected_titles = [f"foo {i}-" for i in range(1, 21)]
@@ -87,9 +84,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def test_multiple_partial_updates(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse("linkding:bookmarks.index")
self.open(url, p)
self.open(url)
self.locate_bookmark("Bookmark 1").get_by_text("Archive").click()
self.assertVisibleBookmarks(
@@ -107,8 +103,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def test_active_bookmarks_partial_update_on_archive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
self.locate_bookmark("Bookmark 2").get_by_text("Archive").click()
@@ -119,8 +114,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def test_active_bookmarks_partial_update_on_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
self.locate_bookmark("Bookmark 2").get_by_text("Remove").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
@@ -135,8 +129,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
bookmark2.unread = True
bookmark2.save()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
expect(self.locate_bookmark("Bookmark 2")).to_have_class("unread")
self.locate_bookmark("Bookmark 2").get_by_text("Unread").click()
@@ -151,8 +144,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
bookmark2.shared = True
bookmark2.save()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
expect(self.locate_bookmark("Bookmark 2")).to_have_class("shared")
self.locate_bookmark("Bookmark 2").get_by_text("Shared").click()
@@ -164,13 +156,10 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def test_active_bookmarks_partial_update_on_bulk_archive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Bookmark 2").locator(
"label.bulk-edit-checkbox"
).click()
self.locate_bookmark("Bookmark 2").locator("label.bulk-edit-checkbox").click()
self.select_bulk_action("Archive")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
@@ -182,13 +171,10 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def test_active_bookmarks_partial_update_on_bulk_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Bookmark 2").locator(
"label.bulk-edit-checkbox"
).click()
self.locate_bookmark("Bookmark 2").locator("label.bulk-edit-checkbox").click()
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
@@ -200,8 +186,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def test_archived_bookmarks_partial_update_on_unarchive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.archived"), p)
self.open(reverse("linkding:bookmarks.archived"))
self.locate_bookmark("Archived Bookmark 2").get_by_text("Unarchive").click()
@@ -212,8 +197,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def test_archived_bookmarks_partial_update_on_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.archived"), p)
self.open(reverse("linkding:bookmarks.archived"))
self.locate_bookmark("Archived Bookmark 2").get_by_text("Remove").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
@@ -225,8 +209,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def test_archived_bookmarks_partial_update_on_bulk_unarchive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.archived"), p)
self.open(reverse("linkding:bookmarks.archived"))
self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Archived Bookmark 2").locator(
@@ -243,8 +226,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def test_archived_bookmarks_partial_update_on_bulk_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.archived"), p)
self.open(reverse("linkding:bookmarks.archived"))
self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Archived Bookmark 2").locator(
@@ -264,8 +246,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
3, shared=True, prefix="My Bookmark", with_tags=True
)
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.shared"), p)
self.open(reverse("linkding:bookmarks.shared"))
self.locate_bookmark("My Bookmark 2").get_by_text("Archive").click()
@@ -289,8 +270,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
3, shared=True, prefix="My Bookmark", with_tags=True
)
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.shared"), p)
self.open(reverse("linkding:bookmarks.shared"))
self.locate_bookmark("My Bookmark 2").get_by_text("Remove").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()

View File

@@ -1,5 +1,5 @@
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
@@ -9,9 +9,8 @@ class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
group1 = self.setup_numbered_bookmarks(3, prefix="foo")
group2 = self.setup_numbered_bookmarks(3, prefix="bar")
with sync_playwright() as p:
# shows all bookmarks initially
page = self.open(reverse("linkding:bundles.new"), p)
page = self.open(reverse("linkding:bundles.new"))
expect(
page.get_by_text(f"Found 6 bookmarks matching this bundle")

View File

@@ -1,5 +1,5 @@
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
@@ -22,13 +22,10 @@ class CollapseSidePanelE2ETestCase(LinkdingE2ETestCase):
).to_be_visible()
def test_side_panel_should_be_visible_by_default(self):
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
self.assertSidePanelIsVisible()
self.page.goto(
self.live_server_url + reverse("linkding:bookmarks.archived")
)
self.page.goto(self.live_server_url + reverse("linkding:bookmarks.archived"))
self.assertSidePanelIsVisible()
self.page.goto(self.live_server_url + reverse("linkding:bookmarks.shared"))
@@ -39,13 +36,10 @@ class CollapseSidePanelE2ETestCase(LinkdingE2ETestCase):
user.profile.collapse_side_panel = True
user.profile.save()
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
self.assertSidePanelIsHidden()
self.page.goto(
self.live_server_url + reverse("linkding:bookmarks.archived")
)
self.page.goto(self.live_server_url + reverse("linkding:bookmarks.archived"))
self.assertSidePanelIsHidden()
self.page.goto(self.live_server_url + reverse("linkding:bookmarks.shared"))

View File

@@ -1,5 +1,5 @@
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
@@ -18,8 +18,7 @@ class DropdownE2ETestCase(LinkdingE2ETestCase):
return self.locate_dropdown().locator(".menu")
def test_click_toggle_opens_and_closes_dropdown(self):
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
toggle = self.locate_dropdown_toggle()
menu = self.locate_dropdown_menu()
@@ -33,8 +32,7 @@ class DropdownE2ETestCase(LinkdingE2ETestCase):
expect(menu).not_to_be_visible()
def test_outside_click_closes_dropdown(self):
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
toggle = self.locate_dropdown_toggle()
menu = self.locate_dropdown_menu()
@@ -48,8 +46,7 @@ class DropdownE2ETestCase(LinkdingE2ETestCase):
expect(menu).not_to_be_visible()
def test_escape_closes_dropdown_and_restores_focus(self):
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
toggle = self.locate_dropdown_toggle()
menu = self.locate_dropdown_menu()
@@ -68,8 +65,7 @@ class DropdownE2ETestCase(LinkdingE2ETestCase):
expect(toggle).to_be_focused()
def test_focus_out_closes_dropdown(self):
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
toggle = self.locate_dropdown_toggle()
menu = self.locate_dropdown_menu()
@@ -85,8 +81,7 @@ class DropdownE2ETestCase(LinkdingE2ETestCase):
expect(menu).not_to_be_visible()
def test_aria_expanded_attribute(self):
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
toggle = self.locate_dropdown_toggle()
menu = self.locate_dropdown_menu()
@@ -109,8 +104,7 @@ class DropdownE2ETestCase(LinkdingE2ETestCase):
expect(toggle).to_have_attribute("aria-expanded", "false")
def test_can_click_menu_item(self):
with sync_playwright() as p:
self.open(reverse("linkding:bookmarks.index"), p)
self.open(reverse("linkding:bookmarks.index"))
toggle = self.locate_dropdown_toggle()
menu = self.locate_dropdown_menu()

View File

@@ -1,7 +1,7 @@
from unittest.mock import patch
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.services import website_loader
@@ -24,14 +24,13 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
self.website_loader_patch.start()
def tearDown(self) -> None:
super().tearDown()
self.website_loader_patch.stop()
super().tearDown()
def test_should_not_check_for_existing_bookmark(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]), p)
page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]))
page.wait_for_timeout(timeout=1000)
page.get_by_text("This URL is already bookmarked.").wait_for(state="hidden")
@@ -41,8 +40,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
title="Initial title", description="Initial description"
)
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]), p)
page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]))
page.wait_for_timeout(timeout=1000)
title = page.get_by_label("Title")
@@ -53,8 +51,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
def test_enter_url_should_not_prefill_title_and_description(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]), p)
page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]))
page.get_by_label("URL").fill("https://example.com")
page.wait_for_timeout(timeout=1000)
@@ -67,8 +64,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
def test_refresh_button_should_be_visible_when_editing(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]), p)
page = self.open(reverse("linkding:bookmarks.edit", args=[bookmark.id]))
refresh_button = page.get_by_text("Refresh from website")
expect(refresh_button).to_be_visible()

View File

@@ -1,5 +1,5 @@
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
@@ -9,8 +9,7 @@ class FilterDrawerE2ETestCase(LinkdingE2ETestCase):
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.index"), p)
page = self.open(reverse("linkding:bookmarks.index"))
# use smaller viewport to make filter button visible
page.set_viewport_size({"width": 375, "height": 812})
@@ -40,8 +39,7 @@ class FilterDrawerE2ETestCase(LinkdingE2ETestCase):
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.index"), p)
page = self.open(reverse("linkding:bookmarks.index"))
# use smaller viewport to make filter button visible
page.set_viewport_size({"width": 375, "height": 812})

View File

@@ -1,32 +1,24 @@
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
class GlobalShortcutsE2ETestCase(LinkdingE2ETestCase):
def test_focus_search(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("linkding:bookmarks.index"))
self.open(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()
browser.close()
expect(
self.page.get_by_placeholder("Search for words or #tags")
).to_be_focused()
def test_add_bookmark(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("linkding:bookmarks.index"))
self.open(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")
)
browser.close()

View File

@@ -2,7 +2,7 @@ from unittest.mock import patch
from urllib.parse import quote
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.services import website_loader
@@ -26,12 +26,11 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
self.website_loader_mock = self.website_loader_patch.start()
def tearDown(self) -> None:
super().tearDown()
self.website_loader_patch.stop()
super().tearDown()
def test_enter_url_prefills_title_and_description(self):
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.new"), p)
page = self.open(reverse("linkding:bookmarks.new"))
url = page.get_by_label("URL")
title = page.get_by_label("Title")
description = page.get_by_label("Description")
@@ -43,8 +42,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
)
def test_enter_url_does_not_overwrite_modified_title_and_description(self):
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.new"), p)
page = self.open(reverse("linkding:bookmarks.new"))
url = page.get_by_label("URL")
title = page.get_by_label("Title")
description = page.get_by_label("Description")
@@ -58,12 +56,10 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
expect(description).to_have_value("Modified description")
def test_with_initial_url_prefills_title_and_description(self):
with sync_playwright() as p:
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, p)
page = self.open(page_url)
url = page.get_by_label("URL")
title = page.get_by_label("Title")
description = page.get_by_label("Description")
@@ -79,12 +75,11 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
def test_with_initial_url_title_description_does_not_overwrite_title_and_description(
self,
):
with sync_playwright() as p:
page_url = (
reverse("linkding:bookmarks.new")
+ f"?url={quote('https://example.com')}&title=Initial+title&description=Initial+description"
)
page = self.open(page_url, p)
page = self.open(page_url)
url = page.get_by_label("URL")
title = page.get_by_label("Title")
description = page.get_by_label("Description")
@@ -105,8 +100,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
)
tag_names = " ".join(existing_bookmark.tag_names)
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.new"), p)
page = self.open(reverse("linkding:bookmarks.new"))
# Enter bookmarked URL
page.get_by_label("URL").fill(existing_bookmark.url)
@@ -138,8 +132,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
notes="Existing notes", description="Existing description"
)
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.new"), p)
page = self.open(reverse("linkding:bookmarks.new"))
details = page.locator("details.notes")
expect(details).not_to_have_attribute("open", value="")
@@ -152,13 +145,12 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
profile.auto_tagging_rules = "github.com dev github"
profile.save()
with sync_playwright() as p:
# Open page with URL that should have auto tags
url = (
reverse("linkding:bookmarks.new")
+ "?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
)
page = self.open(url, p)
page = self.open(url)
auto_tags_hint = page.locator(".form-input-hint.auto-tags")
expect(auto_tags_hint).to_be_visible()
@@ -170,8 +162,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
expect(auto_tags_hint).to_be_hidden()
def test_clear_buttons_only_shown_when_fields_have_content(self):
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.new"), p)
page = self.open(reverse("linkding:bookmarks.new"))
title_field = page.get_by_label("Title")
title_clear_button = page.locator("ld-clear-button[data-for='id_title']")
@@ -205,8 +196,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
title="Existing title", description="Existing description"
)
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.new"), p)
page = self.open(reverse("linkding:bookmarks.new"))
refresh_button = page.locator("#refresh-button")
# Initially, refresh button should be hidden
@@ -233,8 +223,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
title="Existing title", description="Existing description"
)
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.new"), p)
page = self.open(reverse("linkding:bookmarks.new"))
url_field = page.get_by_label("URL")
title_field = page.get_by_label("Title")
description_field = page.get_by_label("Description")
@@ -277,8 +266,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
title="Existing title", description="Existing description"
)
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.new"), p)
page = self.open(reverse("linkding:bookmarks.new"))
url_field = page.get_by_label("URL")
title_field = page.get_by_label("Title")
description_field = page.get_by_label("Description")
@@ -314,8 +302,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
expect(description_field).to_have_class("form-input")
def test_ctrl_enter_submits_form_from_description(self):
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.new"), p)
page = self.open(reverse("linkding:bookmarks.new"))
url_field = page.get_by_label("URL")
description_field = page.get_by_label("Description")

View File

@@ -1,5 +1,5 @@
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.models import UserProfile
@@ -7,21 +7,18 @@ from bookmarks.models import UserProfile
class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
def test_should_only_enable_public_sharing_if_sharing_is_enabled(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("linkding:settings.general"))
self.open(reverse("linkding:settings.general"))
enable_sharing = page.get_by_label("Enable bookmark sharing")
enable_sharing_label = page.get_by_text("Enable bookmark sharing")
enable_public_sharing = page.get_by_label("Enable public bookmark sharing")
enable_public_sharing_label = page.get_by_text(
enable_sharing = self.page.get_by_label("Enable bookmark sharing")
enable_sharing_label = self.page.get_by_text("Enable bookmark sharing")
enable_public_sharing = self.page.get_by_label("Enable public bookmark sharing")
enable_public_sharing_label = self.page.get_by_text(
"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"
)
default_mark_shared_label = page.get_by_text(
default_mark_shared_label = self.page.get_by_text(
"Create bookmarks as shared by default"
)
@@ -63,12 +60,9 @@ class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
)
profile.save()
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("linkding:settings.general"))
self.open(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()
def test_should_show_bookmark_description_max_lines_when_display_separate(self):
@@ -78,24 +72,18 @@ class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
)
profile.save()
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("linkding:settings.general"))
self.open(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()
def test_should_update_bookmark_description_max_lines_when_changing_display(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("linkding:settings.general"))
self.open(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()
display = page.get_by_label("Bookmark description", exact=True)
display = self.page.get_by_label("Bookmark description", exact=True)
display.select_option("separate")
expect(max_lines).to_be_visible()

View File

@@ -1,13 +1,12 @@
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
class SettingsIntegrationsE2ETestCase(LinkdingE2ETestCase):
def test_create_api_token(self):
with sync_playwright() as p:
self.open(reverse("linkding:settings.integrations"), p)
self.open(reverse("linkding:settings.integrations"))
# Click create API token button
self.page.get_by_text("Create API token").click()
@@ -50,8 +49,7 @@ class SettingsIntegrationsE2ETestCase(LinkdingE2ETestCase):
def test_delete_api_token(self):
self.setup_api_token(name="Token To Delete")
with sync_playwright() as p:
self.open(reverse("linkding:settings.integrations"), p)
self.open(reverse("linkding:settings.integrations"))
token_table = self.page.locator("table.crud-table")
expect(token_table.get_by_text("Token To Delete")).to_be_visible()

View File

@@ -1,5 +1,5 @@
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.tests_e2e.helpers import LinkdingE2ETestCase
@@ -25,8 +25,7 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
expect(success_message).to_contain_text(text)
def test_create_tag(self):
with sync_playwright() as p:
self.open(reverse("linkding:tags.index"), p)
self.open(reverse("linkding:tags.index"))
# Click the Create Tag button to open the modal
self.page.get_by_text("Create Tag").click()
@@ -60,8 +59,7 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
def test_create_tag_validation_error(self):
existing_tag = self.setup_tag(name="existing-tag")
with sync_playwright() as p:
self.open(reverse("linkding:tags.index"), p)
self.open(reverse("linkding:tags.index"))
# Click the Create Tag button to open the modal
self.page.get_by_text("Create Tag").click()
@@ -94,8 +92,7 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
def test_edit_tag(self):
tag = self.setup_tag(name="old-name")
with sync_playwright() as p:
self.open(reverse("linkding:tags.index"), p)
self.open(reverse("linkding:tags.index"))
# Click the Edit button for the tag
tag_row = self.locate_tag_row(tag.name)
@@ -131,8 +128,7 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
tag = self.setup_tag(name="tag-to-edit")
other_tag = self.setup_tag(name="other-tag")
with sync_playwright() as p:
self.open(reverse("linkding:tags.index"), p)
self.open(reverse("linkding:tags.index"))
# Click the Edit button for the tag
tag_row = self.locate_tag_row(tag.name)
@@ -170,8 +166,7 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
bookmark1 = self.setup_bookmark(tags=[merge_tag1])
bookmark2 = self.setup_bookmark(tags=[merge_tag2])
with sync_playwright() as p:
self.open(reverse("linkding:tags.index"), p)
self.open(reverse("linkding:tags.index"))
# Click the Merge Tags button to open the modal
self.page.get_by_text("Merge Tags", exact=True).click()
@@ -217,8 +212,7 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
target_tag = self.setup_tag(name="target-tag")
merge_tag = self.setup_tag(name="merge-tag")
with sync_playwright() as p:
self.open(reverse("linkding:tags.index"), p)
self.open(reverse("linkding:tags.index"))
# Click the Merge Tags button to open the modal
self.page.get_by_text("Merge Tags", exact=True).click()

View File

@@ -1,18 +1,66 @@
import os
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 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):
def setUp(self) -> None:
self.client.force_login(self.get_or_create_test_user())
self.cookie = self.client.cookies["sessionid"]
self.playwright = None
self.browser = None
self.context = None
self.page = None
def setup_browser(self, playwright) -> BrowserContext:
browser = playwright.chromium.launch(headless=True)
context = browser.new_context()
def tearDown(self) -> None:
if self.page and self._test_has_failed():
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(
[
{
@@ -25,14 +73,20 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
)
return context
def open(self, url: str, playwright: Playwright) -> Page:
browser = self.setup_browser(playwright)
self.page = browser.new_page()
def open(self, url: str) -> Page:
self.context = self.setup_browser()
self.page = self.context.new_page()
self.page.on("pageerror", self.on_page_error)
self.page.goto(self.live_server_url + url)
self.page.on("load", self.on_load)
self.num_loads = 0
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):
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_PASSWORD=linkding
./scripts/test.sh
make test
make e2e
# Remove postgres container
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