diff --git a/Makefile b/Makefile
index 283caf9db..04c8089ae 100644
--- a/Makefile
+++ b/Makefile
@@ -62,6 +62,12 @@ test-api:
DEBUG=true \
uv run pytest tests/api
+test-wasm-e2e:
+ LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
+ PYTHONUNBUFFERED=1 \
+ DEBUG=true \
+ uv run pytest tests/wasm_ext --browser chromium
+
test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
diff --git a/pyproject.toml b/pyproject.toml
index f10a95929..0d0d0c941 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -86,6 +86,7 @@ dev = [
"types-mock~=5.2.0.20250924",
"mock~=5.2.0",
"grpcio-tools~=1.76.0",
+ "pytest-playwright>=0.8.0",
]
[tool.uv]
diff --git a/tests/fixtures/lnbits-wasm-test-extension/config.json b/tests/fixtures/lnbits-wasm-test-extension/config.json
new file mode 100644
index 000000000..6385c73dd
--- /dev/null
+++ b/tests/fixtures/lnbits-wasm-test-extension/config.json
@@ -0,0 +1,173 @@
+{
+ "id": "lnbits-wasm-test-extension",
+ "name": "WASM Test Extension",
+ "short_description": "Minimal WASM extension used by LNbits e2e tests.",
+ "version": "0.0.1",
+ "min_lnbits_version": "1.5.5",
+ "extension_type": "wasm",
+ "wasm": {
+ "module": "wasm/module.wasm",
+ "exports": [
+ {
+ "name": "list-wallets",
+ "visibility": "authenticated"
+ },
+ {
+ "name": "invoice-details",
+ "visibility": "authenticated"
+ },
+ {
+ "name": "get-selection",
+ "visibility": "authenticated"
+ },
+ {
+ "name": "save-selection",
+ "visibility": "authenticated"
+ },
+ {
+ "name": "pay-large-invoice",
+ "visibility": "authenticated"
+ },
+ {
+ "name": "create-tip-jar",
+ "visibility": "authenticated"
+ },
+ {
+ "name": "list-tip-jars",
+ "visibility": "authenticated"
+ },
+ {
+ "name": "get-public-tip-jar",
+ "visibility": "public"
+ },
+ {
+ "name": "create-tip-invoice",
+ "visibility": "public"
+ },
+ {
+ "name": "record-payment",
+ "visibility": "event"
+ }
+ ]
+ },
+ "events": {
+ "onInvoicePaid": "record-payment"
+ },
+ "ui_routes": [
+ {
+ "path": "/lnbits-wasm-test-extension",
+ "entrypoint": "ui/admin.html",
+ "auth": "user"
+ },
+ {
+ "path": "/lnbits-wasm-test-extension/public/{item_id}",
+ "entrypoint": "ui/public.html",
+ "auth": "public",
+ "path_params": {
+ "item_id": "itemId"
+ }
+ }
+ ],
+ "api_routes": [
+ {
+ "method": "GET",
+ "path": "/wallets",
+ "export": "list-wallets",
+ "auth": "user"
+ },
+ {
+ "method": "POST",
+ "path": "/payments",
+ "export": "pay-large-invoice",
+ "auth": "user"
+ },
+ {
+ "method": "GET",
+ "path": "/jars/{jar_id}",
+ "export": "get-public-tip-jar",
+ "auth": "public",
+ "path_params": {
+ "jar_id": "jarId"
+ }
+ },
+ {
+ "method": "POST",
+ "path": "/invoice",
+ "export": "create-tip-invoice",
+ "auth": "public"
+ }
+ ],
+ "permissions": [
+ {
+ "id": "wallet.pay_invoice",
+ "description": "Pay Lightning invoices from selected wallets."
+ },
+ {
+ "id": "wallet.list",
+ "description": "List wallets available to the installing user."
+ },
+ {
+ "id": "wallet.balance.read",
+ "description": "Read wallet balances for payment selection."
+ },
+ {
+ "id": "extension.api.request",
+ "description": "Call APIs exposed by another installed extension.",
+ "policies": [
+ {
+ "id": "watchonly",
+ "access": [
+ "read",
+ "write"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "ui.camera.scan_qr",
+ "description": "Use the LNbits scanner to read QR codes when requested."
+ },
+ {
+ "id": "ext.storage.read",
+ "description": "Read extension storage rows."
+ },
+ {
+ "id": "ext.storage.write",
+ "description": "Write extension storage rows."
+ },
+ {
+ "id": "ext.storage.read_public",
+ "description": "Read public extension storage fields.",
+ "policies": [
+ {
+ "table_name": "tip_jars",
+ "public_fields": [
+ "id",
+ "title",
+ "description",
+ "currency",
+ "suggested_amounts"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "wallet.create_invoice",
+ "description": "Create incoming Lightning invoices from authenticated pages."
+ },
+ {
+ "id": "wallet.create_invoice_public",
+ "description": "Create incoming Lightning invoices from public pages.",
+ "policies": [
+ {
+ "table": "tip_jars",
+ "wallet_field": "wallet_id"
+ }
+ ]
+ },
+ {
+ "id": "utils.basic",
+ "description": "Use LNbits utility functions."
+ }
+ ]
+}
diff --git a/tests/fixtures/lnbits-wasm-test-extension/static/app.js b/tests/fixtures/lnbits-wasm-test-extension/static/app.js
new file mode 100644
index 000000000..f83e5ae0d
--- /dev/null
+++ b/tests/fixtures/lnbits-wasm-test-extension/static/app.js
@@ -0,0 +1 @@
+window.__lnbitsWasmTestExtensionLoaded = true
diff --git a/tests/fixtures/lnbits-wasm-test-extension/ui/admin.html b/tests/fixtures/lnbits-wasm-test-extension/ui/admin.html
new file mode 100644
index 000000000..85732ea6c
--- /dev/null
+++ b/tests/fixtures/lnbits-wasm-test-extension/ui/admin.html
@@ -0,0 +1,11 @@
+
+
+
+
+ WASM Test Extension Admin
+
+
+
+ WASM Test Admin
+
+
diff --git a/tests/fixtures/lnbits-wasm-test-extension/ui/public.html b/tests/fixtures/lnbits-wasm-test-extension/ui/public.html
new file mode 100644
index 000000000..a165feed6
--- /dev/null
+++ b/tests/fixtures/lnbits-wasm-test-extension/ui/public.html
@@ -0,0 +1,11 @@
+
+
+
+
+ WASM Test Extension Public
+
+
+
+ WASM Test Public
+
+
diff --git a/tests/fixtures/lnbits-wasm-test-extension/wasm/module.wasm b/tests/fixtures/lnbits-wasm-test-extension/wasm/module.wasm
new file mode 100644
index 000000000..fd9aba096
Binary files /dev/null and b/tests/fixtures/lnbits-wasm-test-extension/wasm/module.wasm differ
diff --git a/tests/wasm_ext/__init__.py b/tests/wasm_ext/__init__.py
new file mode 100644
index 000000000..8b1378917
--- /dev/null
+++ b/tests/wasm_ext/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/wasm_ext/conftest.py b/tests/wasm_ext/conftest.py
new file mode 100644
index 000000000..86979b173
--- /dev/null
+++ b/tests/wasm_ext/conftest.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+import os
+import shutil
+import socket
+import subprocess
+import sys
+import time
+from collections.abc import Iterator
+from pathlib import Path
+from typing import Any
+
+import httpx
+import pytest
+
+from tests.wasm_ext.helpers import EXTENSION_ID, SERVER_HOST, LiveLNbitsServer
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+
+
+@pytest.fixture(scope="session")
+def browser_name() -> str:
+ return "chromium"
+
+
+@pytest.fixture(scope="session")
+def lnbits_server(
+ tmp_path_factory: pytest.TempPathFactory,
+) -> Iterator[LiveLNbitsServer]:
+ run_root = tmp_path_factory.mktemp("wasm_ext_e2e")
+ data_dir = run_root / "data"
+ extensions_root = run_root / "extensions-root"
+ extension_target = extensions_root / "extensions" / EXTENSION_ID
+ fixture_extension = REPO_ROOT / "tests" / "fixtures" / EXTENSION_ID
+
+ shutil.copytree(fixture_extension, extension_target)
+
+ port = _free_tcp_port()
+ base_url = f"http://{SERVER_HOST}:{port}"
+ env = {
+ **os.environ,
+ "AUTH_HTTPS_ONLY": "false",
+ "DEBUG": "true",
+ "HOST": SERVER_HOST,
+ "LNBITS_ADMIN_UI": "true",
+ "LNBITS_BACKEND_WALLET_CLASS": "FakeWallet",
+ "LNBITS_DATA_FOLDER": str(data_dir),
+ "LNBITS_EXTENSIONS_DEACTIVATE_ALL": "false",
+ "LNBITS_EXTENSIONS_PATH": str(extensions_root),
+ "LNBITS_PATH": str(REPO_ROOT),
+ "PORT": str(port),
+ "PYTHONUNBUFFERED": "1",
+ }
+
+ process = subprocess.Popen( # noqa: S603
+ [
+ sys.executable,
+ "-m",
+ "uvicorn",
+ "lnbits.__main__:app",
+ "--host",
+ SERVER_HOST,
+ "--port",
+ str(port),
+ "--loop",
+ "asyncio",
+ "--log-level",
+ "warning",
+ ],
+ cwd=REPO_ROOT,
+ env=env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ )
+
+ try:
+ _wait_for_first_install_page(process, base_url)
+ auth_cookies = _complete_first_install(base_url)
+ yield LiveLNbitsServer(base_url=base_url, auth_cookies=auth_cookies)
+ finally:
+ process.terminate()
+ try:
+ process.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait(timeout=10)
+
+
+@pytest.fixture
+def authenticated_page(page: Any, lnbits_server: LiveLNbitsServer) -> Any:
+ page.context.add_cookies(lnbits_server.auth_cookies)
+ return page
+
+
+def _free_tcp_port() -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind((SERVER_HOST, 0))
+ return int(sock.getsockname()[1])
+
+
+def _wait_for_first_install_page(
+ process: subprocess.Popen[str],
+ base_url: str,
+) -> None:
+ deadline = time.monotonic() + 60
+ last_error: Exception | None = None
+ with httpx.Client(follow_redirects=False, timeout=2) as client:
+ while time.monotonic() < deadline:
+ if process.poll() is not None:
+ output = process.stdout.read() if process.stdout else ""
+ raise RuntimeError(f"LNbits exited before startup:\n{output}")
+ try:
+ response = client.get(f"{base_url}/first_install")
+ if response.status_code == 200:
+ return
+ except httpx.HTTPError as exc:
+ last_error = exc
+ time.sleep(0.25)
+
+ output = process.stdout.read() if process.stdout else ""
+ raise TimeoutError(f"LNbits did not start: {last_error}\n{output}")
+
+
+def _complete_first_install(base_url: str) -> list[dict[str, Any]]:
+ with httpx.Client(base_url=base_url, follow_redirects=False, timeout=10) as client:
+ response = client.put(
+ "/api/v1/auth/first_install",
+ json={
+ "username": "wasmtest-admin",
+ "password": "secret1234",
+ "password_repeat": "secret1234",
+ },
+ )
+ response.raise_for_status()
+
+ enable_response = client.put(f"/api/v1/extension/{EXTENSION_ID}/enable")
+ enable_response.raise_for_status()
+
+ return [
+ {
+ "name": cookie.name,
+ "value": cookie.value,
+ "url": base_url,
+ "secure": cookie.secure,
+ "sameSite": "Lax",
+ }
+ for cookie in client.cookies.jar
+ ]
diff --git a/tests/wasm_ext/helpers.py b/tests/wasm_ext/helpers.py
new file mode 100644
index 000000000..30d6425c6
--- /dev/null
+++ b/tests/wasm_ext/helpers.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+EXTENSION_ID = "lnbits-wasm-test-extension"
+SERVER_HOST = "127.0.0.1"
+
+
+@dataclass(frozen=True)
+class LiveLNbitsServer:
+ base_url: str
+ auth_cookies: list[dict[str, Any]]
+ extension_id: str = EXTENSION_ID
diff --git a/tests/wasm_ext/test_wasm_extension_e2e.py b/tests/wasm_ext/test_wasm_extension_e2e.py
new file mode 100644
index 000000000..fe8bd35c1
--- /dev/null
+++ b/tests/wasm_ext/test_wasm_extension_e2e.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+import re
+from re import Pattern
+
+from playwright.sync_api import Page, expect
+
+from tests.wasm_ext.helpers import LiveLNbitsServer
+
+
+def test_public_wasm_page_loads_sandboxed_frame(
+ page: Page,
+ lnbits_server: LiveLNbitsServer,
+) -> None:
+ public_url = (
+ f"{lnbits_server.base_url}/ext/{lnbits_server.extension_id}/public/item-123"
+ "?source=test"
+ )
+ frame_url_part = f"/ext-frame/{lnbits_server.extension_id}/1"
+
+ with page.expect_response(lambda response: frame_url_part in response.url) as info:
+ page.goto(public_url)
+
+ frame_response = info.value
+ assert frame_response.status == 200
+ assert "sandbox allow-scripts" in frame_response.headers["content-security-policy"]
+ assert frame_response.headers["cache-control"] == "no-store"
+ assert frame_response.headers["x-content-type-options"] == "nosniff"
+
+ frame = page.locator("iframe.wasm-extension-frame")
+ expect(frame).to_have_attribute("sandbox", "allow-scripts")
+ expect(frame).to_have_attribute("allow", "clipboard-write")
+ expect(frame).to_have_attribute("referrerpolicy", "no-referrer")
+ expect(frame).to_have_attribute("src", _frame_src_pattern(frame_url_part))
+ expect(
+ page.frame_locator("iframe.wasm-extension-frame").locator("body")
+ ).to_contain_text("WASM Test Public")
+
+
+def test_frame_token_is_route_bound_required_and_single_use(
+ page: Page,
+ lnbits_server: LiveLNbitsServer,
+) -> None:
+ frame_config = page.request.post(
+ f"{lnbits_server.base_url}/api/v1/ext/"
+ f"{lnbits_server.extension_id}/_ui/frame",
+ data={
+ "path": f"/ext/{lnbits_server.extension_id}/public/item-123",
+ "query": {"source": "test"},
+ },
+ )
+ assert frame_config.status == 200
+ frame_url = frame_config.json()["frameUrl"]
+
+ missing_token = page.request.get(
+ f"{lnbits_server.base_url}/ext-frame/{lnbits_server.extension_id}/1"
+ )
+ assert missing_token.status == 404
+
+ wrong_route = page.request.get(
+ f"{lnbits_server.base_url}{frame_url.replace('/1?', '/0?')}"
+ )
+ assert wrong_route.status == 404
+
+ first_use = page.request.get(f"{lnbits_server.base_url}{frame_url}")
+ assert first_use.status == 200
+ assert "form-action 'none'" in first_use.headers["content-security-policy"]
+
+ second_use = page.request.get(f"{lnbits_server.base_url}{frame_url}")
+ assert second_use.status == 404
+
+
+def test_private_wasm_page_uses_lnbits_shell_and_private_frame(
+ authenticated_page: Page,
+ lnbits_server: LiveLNbitsServer,
+) -> None:
+ page = authenticated_page
+ frame_url_part = f"/ext-frame/{lnbits_server.extension_id}/0"
+
+ with page.expect_response(lambda response: frame_url_part in response.url) as info:
+ page.goto(f"{lnbits_server.base_url}/ext/{lnbits_server.extension_id}")
+
+ assert info.value.status == 200
+ expect(page.locator("body")).to_contain_text("LNbits")
+ frame = page.locator("iframe.wasm-extension-frame")
+ expect(frame).to_have_attribute("sandbox", "allow-scripts")
+ expect(frame).to_have_attribute("src", _frame_src_pattern(frame_url_part))
+ expect(
+ page.frame_locator("iframe.wasm-extension-frame").locator("body")
+ ).to_contain_text("WASM Test Admin")
+
+
+def _frame_src_pattern(frame_url_part: str) -> Pattern[str]:
+ return re.compile(f"^{re.escape(frame_url_part)}\\?frame_token=[a-f0-9]{{32}}$")
diff --git a/uv.lock b/uv.lock
index d50cc32e9..a21d418b1 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1368,6 +1368,7 @@ dev = [
{ name = "pytest-httpserver" },
{ name = "pytest-md" },
{ name = "pytest-mock" },
+ { name = "pytest-playwright" },
{ name = "ruff" },
{ name = "types-mock" },
{ name = "types-passlib" },
@@ -1446,6 +1447,7 @@ dev = [
{ name = "pytest-httpserver", specifier = "~=1.1.3" },
{ name = "pytest-md", specifier = "~=0.2.0" },
{ name = "pytest-mock", specifier = "~=3.15.1" },
+ { name = "pytest-playwright", specifier = ">=0.8.0" },
{ name = "ruff", specifier = "~=0.14.10" },
{ name = "types-mock", specifier = "~=5.2.0.20250924" },
{ name = "types-passlib", specifier = "~=1.7.7.20250602" },
@@ -1832,6 +1834,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
+[[package]]
+name = "playwright"
+version = "1.61.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "greenlet" },
+ { name = "pyee" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/ee/31e4e0db36588b817a10b299a0285082545fde7d36543c2abe498bb3d61a/playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0", size = 43421877, upload-time = "2026-06-29T10:32:48.428Z" },
+ { url = "https://files.pythonhosted.org/packages/42/35/71395dd3ecc798965be4a3ef8c443217d4abca168e7cb34536304f9489e6/playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a", size = 42205016, upload-time = "2026-06-29T10:32:52.104Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/44/323164cf5cd1647bdefce76ffce27651aadb959d089b48f53ea40918276e/playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af", size = 43421884, upload-time = "2026-06-29T10:32:55.773Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/f8/a35bf179e4ba2522c1893635094a64e407572547bd61528820fc0abc87fe/playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e", size = 47421381, upload-time = "2026-06-29T10:32:59.903Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/eb/e3f922348ec17c315f98c463f72faa1181a1c3de0bfe31a8d2edf6561723/playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c", size = 47120545, upload-time = "2026-06-29T10:33:03.574Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/a6/5be4e52b40a9c0c8a073e7c5b0785c05cf5a9ea8f8a7b5b260e32d970342/playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b", size = 37844841, upload-time = "2026-06-29T10:33:07.361Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fd/2b78036e5fbe9d5f5645bbe08a1eac7160c51243c0093963edbcf67c35d9/playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597", size = 37844846, upload-time = "2026-06-29T10:33:10.637Z" },
+ { url = "https://files.pythonhosted.org/packages/27/0d/1b0f3c4ee4eb0514bc805b5c2f9a223e5b6de4f11a926f5235d51d0fc81b/playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51", size = 33955127, upload-time = "2026-06-29T10:33:14.008Z" },
+]
+
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -2059,6 +2080,18 @@ email = [
{ name = "email-validator" },
]
+[[package]]
+name = "pyee"
+version = "13.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
+]
+
[[package]]
name = "pygments"
version = "2.20.0"
@@ -2201,6 +2234,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
+[[package]]
+name = "pytest-base-url"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" },
+]
+
[[package]]
name = "pytest-cov"
version = "7.0.0"
@@ -2251,6 +2297,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
+[[package]]
+name = "pytest-playwright"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "playwright" },
+ { name = "pytest" },
+ { name = "pytest-base-url" },
+ { name = "python-slugify" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/ef/172eb8e23c80491fc72f1401c72f9305663873649351306a38b18406b0c9/pytest_playwright-0.8.0.tar.gz", hash = "sha256:7888d4a2443160c82e0c506c437076679f86b36d1910427250d90fbf1843a981", size = 17132, upload-time = "2026-05-18T10:16:15.919Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/71/1c545fac6a9054b52b3771238fb2dc6e8f1d0ccec116e1c7786ec191887c/pytest_playwright-0.8.0-py3-none-any.whl", hash = "sha256:856aae6efd4bc055f2ef229c647768760bcaad5cd3a5983c314ac260a974a933", size = 17143, upload-time = "2026-05-18T10:16:18.226Z" },
+]
+
[[package]]
name = "python-crontab"
version = "3.3.0"
@@ -2291,6 +2352,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" },
]
+[[package]]
+name = "python-slugify"
+version = "8.0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "text-unidecode" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" },
+]
+
[[package]]
name = "pytokens"
version = "0.4.1"
@@ -2630,6 +2703,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" },
]
+[[package]]
+name = "text-unidecode"
+version = "1.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" },
+]
+
[[package]]
name = "tibs"
version = "0.5.7"