mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-11 09:24:29 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f55dcdd6e | ||
|
|
0694bbe56d | ||
|
|
3045948d0a | ||
|
|
1fa17b4e07 | ||
|
|
cd25a1914c | ||
|
|
a71200b199 |
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -27,8 +27,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest
|
||||
- name: Run tests (excluding browser tests)
|
||||
run: uv run pytest --ignore=tests/web/test_htmx_browser.py
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13'
|
||||
@@ -36,6 +36,26 @@ jobs:
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
browser-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: uv run playwright install chromium --with-deps
|
||||
|
||||
- name: Run browser tests
|
||||
run: uv run pytest tests/web/test_htmx_browser.py -v --no-cov
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -32,6 +32,12 @@ compose_farm/
|
||||
|
||||
Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templates/partials/icons.html` by copying SVG paths from their site. The `action_btn`, `stat_card`, and `collapse` macros in `components.html` accept an optional `icon` parameter.
|
||||
|
||||
## HTMX Patterns
|
||||
|
||||
- **Multi-element refresh**: Use custom events, not `hx-swap-oob`. Elements have `hx-trigger="cf:refresh from:body"` and JS calls `document.body.dispatchEvent(new CustomEvent('cf:refresh'))`. Simpler to debug/test.
|
||||
- **SPA navigation**: Sidebar uses `hx-boost="true"` to AJAX-ify links.
|
||||
- **Attribute inheritance**: Set `hx-target`/`hx-swap` on parent elements.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Hybrid SSH approach**: asyncssh for parallel streaming with prefixes; native `ssh -t` for raw mode (progress bars)
|
||||
|
||||
@@ -174,4 +174,6 @@ dev = [
|
||||
"websockets>=12.0",
|
||||
# For FastAPI TestClient
|
||||
"httpx>=0.28.0",
|
||||
# For browser tests (use system chromium via nix-shell -p chromium)
|
||||
"pytest-playwright>=0.7.0",
|
||||
]
|
||||
|
||||
16
shell.nix
Normal file
16
shell.nix
Normal file
@@ -0,0 +1,16 @@
|
||||
# Development shell with Chromium for browser tests
|
||||
# Usage: nix-shell --run "uv run pytest tests/web/test_htmx_browser.py -v --no-cov"
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
|
||||
pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.chromium
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "Chromium available at: $(which chromium)"
|
||||
echo ""
|
||||
echo "Run browser tests with:"
|
||||
echo " uv run pytest tests/web/test_htmx_browser.py -v --no-cov"
|
||||
'';
|
||||
}
|
||||
@@ -12,6 +12,8 @@ from typing import TYPE_CHECKING
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import ValidationError
|
||||
|
||||
from compose_farm.executor import is_local
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from compose_farm.config import Config
|
||||
|
||||
@@ -38,3 +40,11 @@ def extract_config_error(exc: Exception) -> str:
|
||||
if isinstance(exc, ValidationError):
|
||||
return "; ".join(err.get("msg", str(err)) for err in exc.errors())
|
||||
return str(exc)
|
||||
|
||||
|
||||
def get_local_host(config: Config) -> str | None:
|
||||
"""Find the local host name from config, if any."""
|
||||
for name, host in config.hosts.items():
|
||||
if is_local(host):
|
||||
return name
|
||||
return None
|
||||
|
||||
@@ -136,22 +136,34 @@ async def _get_container_states(
|
||||
# All containers should be on the same host
|
||||
host_name = containers[0]["Host"]
|
||||
|
||||
result = await run_compose_on_host(config, service, host_name, "ps --format json", stream=False)
|
||||
# Use -a to include stopped/exited containers
|
||||
result = await run_compose_on_host(
|
||||
config, service, host_name, "ps -a --format json", stream=False
|
||||
)
|
||||
if not result.success:
|
||||
return containers
|
||||
|
||||
# Build state map
|
||||
state_map: dict[str, str] = {}
|
||||
# Build state map: name -> (state, exit_code)
|
||||
state_map: dict[str, tuple[str, int]] = {}
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line.strip():
|
||||
with contextlib.suppress(json.JSONDecodeError):
|
||||
data = json.loads(line)
|
||||
state_map[data.get("Name", "")] = data.get("State", "unknown")
|
||||
name = data.get("Name", "")
|
||||
state = data.get("State", "unknown")
|
||||
exit_code = data.get("ExitCode", 0)
|
||||
state_map[name] = (state, exit_code)
|
||||
|
||||
# Update container states
|
||||
for c in containers:
|
||||
if c["Name"] in state_map:
|
||||
c["State"] = state_map[c["Name"]]
|
||||
state, exit_code = state_map[c["Name"]]
|
||||
c["State"] = state
|
||||
c["ExitCode"] = exit_code
|
||||
else:
|
||||
# Container not in ps output means it was never started
|
||||
c["State"] = "created"
|
||||
c["ExitCode"] = None
|
||||
|
||||
return containers
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from compose_farm.executor import is_local
|
||||
from compose_farm.paths import find_config_path
|
||||
from compose_farm.state import (
|
||||
get_orphaned_services,
|
||||
@@ -20,6 +19,7 @@ from compose_farm.state import (
|
||||
from compose_farm.web.deps import (
|
||||
extract_config_error,
|
||||
get_config,
|
||||
get_local_host,
|
||||
get_templates,
|
||||
)
|
||||
|
||||
@@ -32,14 +32,8 @@ async def console(request: Request) -> HTMLResponse:
|
||||
config = get_config()
|
||||
templates = get_templates()
|
||||
|
||||
# Find local host and sort it first
|
||||
local_host = None
|
||||
for name, host in config.hosts.items():
|
||||
if is_local(host):
|
||||
local_host = name
|
||||
break
|
||||
|
||||
# Sort hosts with local first
|
||||
local_host = get_local_host(config)
|
||||
hosts = sorted(config.hosts.keys())
|
||||
if local_host:
|
||||
hosts = [local_host] + [h for h in hosts if h != local_host]
|
||||
@@ -201,6 +195,7 @@ async def sidebar_partial(request: Request) -> HTMLResponse:
|
||||
"services": sorted(config.services.keys()),
|
||||
"service_hosts": service_hosts,
|
||||
"hosts": sorted(config.hosts.keys()),
|
||||
"local_host": get_local_host(config),
|
||||
"state": state,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -100,9 +100,7 @@ function createTerminal(container, extraOptions = {}, onResize = null) {
|
||||
|
||||
const handleResize = () => {
|
||||
fitAddon.fit();
|
||||
if (onResize) {
|
||||
onResize(term.cols, term.rows);
|
||||
}
|
||||
onResize?.(term.cols, term.rows);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
@@ -212,15 +210,11 @@ function initExecTerminal(service, container, host) {
|
||||
window.initExecTerminal = initExecTerminal;
|
||||
|
||||
/**
|
||||
* Refresh dashboard partials while preserving collapse states
|
||||
* Refresh dashboard partials by dispatching a custom event.
|
||||
* Elements with hx-trigger="cf:refresh from:body" will automatically refresh.
|
||||
*/
|
||||
function refreshDashboard() {
|
||||
const isExpanded = (id) => document.getElementById(id)?.checked ?? true;
|
||||
htmx.ajax('GET', '/partials/sidebar', {target: '#sidebar nav', swap: 'innerHTML'});
|
||||
htmx.ajax('GET', '/partials/stats', {target: '#stats-cards', swap: 'outerHTML'});
|
||||
htmx.ajax('GET', `/partials/pending?expanded=${isExpanded('pending-collapse')}`, {target: '#pending-operations', swap: 'outerHTML'});
|
||||
htmx.ajax('GET', `/partials/services-by-host?expanded=${isExpanded('services-by-host-collapse')}`, {target: '#services-by-host', swap: 'outerHTML'});
|
||||
htmx.ajax('GET', '/partials/config-error', {target: '#config-error', swap: 'innerHTML'});
|
||||
document.body.dispatchEvent(new CustomEvent('cf:refresh'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,15 +262,11 @@ function loadMonaco(callback) {
|
||||
* @returns {object} Monaco editor instance
|
||||
*/
|
||||
function createEditor(container, content, language, opts = {}) {
|
||||
// Support legacy boolean readonly parameter
|
||||
if (typeof opts === 'boolean') {
|
||||
opts = { readonly: opts };
|
||||
}
|
||||
const { readonly = false, onSave = null } = opts;
|
||||
|
||||
const options = {
|
||||
value: content,
|
||||
language: language,
|
||||
language,
|
||||
theme: 'vs-dark',
|
||||
minimap: { enabled: false },
|
||||
automaticLayout: true,
|
||||
@@ -295,7 +285,7 @@ function createEditor(container, content, language, opts = {}) {
|
||||
|
||||
// Add Command+S / Ctrl+S handler for editable editors
|
||||
if (!readonly) {
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, function() {
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
if (onSave) {
|
||||
onSave(editor);
|
||||
} else {
|
||||
@@ -313,10 +303,8 @@ window.createEditor = createEditor;
|
||||
*/
|
||||
function initMonacoEditors() {
|
||||
// Dispose existing editors
|
||||
Object.values(editors).forEach(ed => {
|
||||
if (ed && ed.dispose) ed.dispose();
|
||||
});
|
||||
Object.keys(editors).forEach(key => delete editors[key]);
|
||||
Object.values(editors).forEach(ed => ed?.dispose?.());
|
||||
for (const key in editors) delete editors[key];
|
||||
|
||||
const editorConfigs = [
|
||||
{ id: 'compose-editor', language: 'yaml', readonly: false },
|
||||
@@ -336,7 +324,7 @@ function initMonacoEditors() {
|
||||
if (!el) return;
|
||||
|
||||
const content = el.dataset.content || '';
|
||||
editors[id] = createEditor(el, content, language, readonly);
|
||||
editors[id] = createEditor(el, content, language, { readonly });
|
||||
if (!readonly) {
|
||||
editors[id].saveUrl = el.dataset.saveUrl;
|
||||
}
|
||||
@@ -400,7 +388,7 @@ function initKeyboardShortcuts() {
|
||||
// Only handle if we have editors and no Monaco editor is focused
|
||||
if (Object.keys(editors).length > 0) {
|
||||
// Check if any Monaco editor is focused
|
||||
const focusedEditor = Object.values(editors).find(ed => ed && ed.hasTextFocus && ed.hasTextFocus());
|
||||
const focusedEditor = Object.values(editors).find(ed => ed?.hasTextFocus?.());
|
||||
if (!focusedEditor) {
|
||||
e.preventDefault();
|
||||
saveAllEditors();
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<span class="font-semibold rainbow-hover">Compose Farm</span>
|
||||
</header>
|
||||
|
||||
<main id="main-content" class="flex-1 p-6 overflow-y-auto" hx-boost="true" hx-target="#main-content" hx-select="#main-content" hx-swap="outerHTML">
|
||||
<main id="main-content" class="flex-1 p-6 overflow-y-auto">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
@@ -47,7 +47,7 @@
|
||||
</a>
|
||||
</h2>
|
||||
</header>
|
||||
<nav class="flex-1 overflow-y-auto p-2" hx-get="/partials/sidebar" hx-trigger="load" hx-swap="innerHTML">
|
||||
<nav class="flex-1 overflow-y-auto p-2" hx-get="/partials/sidebar" hx-trigger="load, cf:refresh from:body" hx-swap="innerHTML">
|
||||
<span class="loading loading-spinner loading-sm"></span> Loading...
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
{{ page_header("Compose Farm", "Cluster overview and management") }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
{% include "partials/stats.html" %}
|
||||
<div id="stats-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6"
|
||||
hx-get="/partials/stats" hx-trigger="cf:refresh from:body" hx-swap="innerHTML">
|
||||
{% include "partials/stats.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Global Actions -->
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
@@ -20,7 +23,10 @@
|
||||
{% include "partials/terminal.html" %}
|
||||
|
||||
<!-- Config Error Banner -->
|
||||
<div id="config-error">
|
||||
<div id="config-error"
|
||||
hx-get="/partials/config-error"
|
||||
hx-trigger="cf:refresh from:body"
|
||||
hx-swap="innerHTML">
|
||||
{% if config_error %}
|
||||
{% include "partials/config_error.html" %}
|
||||
{% endif %}
|
||||
@@ -34,10 +40,16 @@
|
||||
{% endcall %}
|
||||
|
||||
<!-- Pending Operations -->
|
||||
{% include "partials/pending.html" %}
|
||||
<div id="pending-operations"
|
||||
hx-get="/partials/pending" hx-trigger="cf:refresh from:body" hx-swap="innerHTML">
|
||||
{% include "partials/pending.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Services by Host -->
|
||||
{% include "partials/services_by_host.html" %}
|
||||
<div id="services-by-host"
|
||||
hx-get="/partials/services-by-host" hx-trigger="cf:refresh from:body" hx-swap="innerHTML">
|
||||
{% include "partials/services_by_host.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Hosts Configuration -->
|
||||
{% call collapse("Hosts (" ~ (hosts | length) ~ ")", icon=server()) %}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
<span class="badge badge-success">running</span>
|
||||
{% elif container.State == "unknown" %}
|
||||
<span class="badge badge-ghost"><span class="loading loading-spinner loading-xs"></span></span>
|
||||
{% elif container.State == "exited" %}
|
||||
{% if container.ExitCode == 0 %}
|
||||
<span class="badge badge-neutral">exited (0)</span>
|
||||
{% else %}
|
||||
<span class="badge badge-error">exited ({{ container.ExitCode }})</span>
|
||||
{% endif %}
|
||||
{% elif container.State == "created" %}
|
||||
<span class="badge badge-neutral">created</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">{{ container.State }}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% from "partials/components.html" import collapse %}
|
||||
<div id="pending-operations">
|
||||
{% if orphaned or migrations or not_started %}
|
||||
{% call collapse("Pending Operations", id="pending-collapse", checked=expanded|default(true)) %}
|
||||
{% if orphaned %}
|
||||
@@ -35,4 +34,3 @@
|
||||
<span>All services are in sync with configuration.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{% from "partials/components.html" import collapse %}
|
||||
{% from "partials/icons.html" import layers, search %}
|
||||
<div id="services-by-host">
|
||||
{% call collapse("Services by Host", id="services-by-host-collapse", checked=expanded|default(true), icon=layers()) %}
|
||||
<div class="flex flex-wrap gap-2 mb-4 items-center">
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 bg-base-200">
|
||||
@@ -38,4 +37,3 @@
|
||||
}
|
||||
</script>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</label>
|
||||
<select id="sidebar-host-select" class="select select-xs bg-base-200 w-full" onchange="sidebarFilter()">
|
||||
<option value="">All hosts</option>
|
||||
{% for h in hosts %}<option value="{{ h }}">{{ h }}</option>{% endfor %}
|
||||
{% for h in hosts %}<option value="{{ h }}">{{ h }}{% if h == local_host %} (local){% endif %}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<ul class="menu menu-sm" id="sidebar-services" hx-boost="true" hx-target="#main-content" hx-select="#main-content" hx-swap="outerHTML">
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{% from "partials/components.html" import stat_card %}
|
||||
{% from "partials/icons.html" import server, layers, circle_check, circle_x %}
|
||||
<div id="stats-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{{ stat_card("Hosts", hosts | length, icon=server()) }}
|
||||
{{ stat_card("Services", services | length, icon=layers()) }}
|
||||
{{ stat_card("Running", running_count, "success", circle_check()) }}
|
||||
{{ stat_card("Stopped", stopped_count, icon=circle_x()) }}
|
||||
</div>
|
||||
{{ stat_card("Hosts", hosts | length, icon=server()) }}
|
||||
{{ stat_card("Services", services | length, icon=layers()) }}
|
||||
{{ stat_card("Running", running_count, "success", circle_check()) }}
|
||||
{{ stat_card("Stopped", stopped_count, icon=circle_x()) }}
|
||||
|
||||
@@ -75,14 +75,32 @@ class TestRenderContainers:
|
||||
|
||||
assert "loading-spinner" in html
|
||||
|
||||
def test_render_exited_success(self, mock_config: Config) -> None:
|
||||
from compose_farm.web.routes.api import _render_containers
|
||||
|
||||
containers = [{"Name": "plex", "State": "exited", "ExitCode": 0}]
|
||||
html = _render_containers("plex", "server-1", containers)
|
||||
|
||||
assert "badge-neutral" in html
|
||||
assert "exited (0)" in html
|
||||
|
||||
def test_render_exited_error(self, mock_config: Config) -> None:
|
||||
from compose_farm.web.routes.api import _render_containers
|
||||
|
||||
containers = [{"Name": "plex", "State": "exited", "ExitCode": 1}]
|
||||
html = _render_containers("plex", "server-1", containers)
|
||||
|
||||
assert "badge-error" in html
|
||||
assert "exited (1)" in html
|
||||
|
||||
def test_render_other_state(self, mock_config: Config) -> None:
|
||||
from compose_farm.web.routes.api import _render_containers
|
||||
|
||||
containers = [{"Name": "plex", "State": "exited"}]
|
||||
containers = [{"Name": "plex", "State": "restarting"}]
|
||||
html = _render_containers("plex", "server-1", containers)
|
||||
|
||||
assert "badge-warning" in html
|
||||
assert "exited" in html
|
||||
assert "restarting" in html
|
||||
|
||||
def test_render_with_header(self, mock_config: Config) -> None:
|
||||
from compose_farm.web.routes.api import _render_containers
|
||||
|
||||
902
tests/web/test_htmx_browser.py
Normal file
902
tests/web/test_htmx_browser.py
Normal file
@@ -0,0 +1,902 @@
|
||||
"""Browser tests for HTMX behavior using Playwright.
|
||||
|
||||
Run with: nix-shell --run "uv run pytest tests/web/test_htmx_browser.py -v --no-cov"
|
||||
Or on CI: playwright install chromium --with-deps
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
|
||||
from compose_farm.config import load_config
|
||||
from compose_farm.web import deps as web_deps
|
||||
from compose_farm.web.app import create_app
|
||||
from compose_farm.web.routes import api as web_api
|
||||
from compose_farm.web.routes import pages as web_pages
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.sync_api import Page, Route
|
||||
|
||||
|
||||
def _browser_available() -> bool:
|
||||
"""Check if any chromium browser is available (system or Playwright-managed)."""
|
||||
# Check for system browser
|
||||
if shutil.which("chromium") or shutil.which("google-chrome"):
|
||||
return True
|
||||
|
||||
# Check for Playwright-managed browser
|
||||
try:
|
||||
from playwright._impl._driver import compute_driver_executable
|
||||
|
||||
driver_path = compute_driver_executable()
|
||||
return Path(driver_path).exists()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Skip all tests if no browser available
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _browser_available(),
|
||||
reason="No browser available (install via: playwright install chromium --with-deps)",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_type_launch_args() -> dict[str, str]:
|
||||
"""Configure Playwright to use system Chromium if available, else use bundled."""
|
||||
# Prefer system browser if available (for nix-shell usage)
|
||||
for name in ["chromium", "chromium-browser", "google-chrome", "chrome"]:
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return {"executable_path": path}
|
||||
# Fall back to Playwright's bundled browser (for CI)
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def test_config(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""Create test config and compose files.
|
||||
|
||||
Creates a multi-host, multi-service config for comprehensive testing:
|
||||
- server-1: plex (running), sonarr (not started)
|
||||
- server-2: radarr (running), jellyfin (not started)
|
||||
"""
|
||||
tmp: Path = tmp_path_factory.mktemp("data")
|
||||
|
||||
# Create compose dir with services
|
||||
compose_dir = tmp / "compose"
|
||||
compose_dir.mkdir()
|
||||
for name in ["plex", "sonarr", "radarr", "jellyfin"]:
|
||||
svc = compose_dir / name
|
||||
svc.mkdir()
|
||||
(svc / "compose.yaml").write_text(f"services:\n {name}:\n image: test/{name}\n")
|
||||
|
||||
# Create config with multiple hosts
|
||||
config = tmp / "compose-farm.yaml"
|
||||
config.write_text(f"""
|
||||
compose_dir: {compose_dir}
|
||||
hosts:
|
||||
server-1:
|
||||
address: 192.168.1.10
|
||||
user: docker
|
||||
server-2:
|
||||
address: 192.168.1.20
|
||||
user: docker
|
||||
services:
|
||||
plex: server-1
|
||||
sonarr: server-1
|
||||
radarr: server-2
|
||||
jellyfin: server-2
|
||||
""")
|
||||
|
||||
# Create state (plex and radarr running, sonarr and jellyfin not started)
|
||||
(tmp / "compose-farm-state.yaml").write_text(
|
||||
"deployed:\n plex: server-1\n radarr: server-2\n"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def server_url(
|
||||
test_config: Path, monkeypatch_module: pytest.MonkeyPatch
|
||||
) -> Generator[str, None, None]:
|
||||
"""Start test server and return URL."""
|
||||
# Load the test config
|
||||
config = load_config(test_config)
|
||||
|
||||
# Patch get_config in all modules that import it
|
||||
monkeypatch_module.setattr(web_deps, "get_config", lambda: config)
|
||||
monkeypatch_module.setattr(web_api, "get_config", lambda: config)
|
||||
monkeypatch_module.setattr(web_pages, "get_config", lambda: config)
|
||||
|
||||
# Also set CF_CONFIG for any code that reads it directly
|
||||
os.environ["CF_CONFIG"] = str(test_config)
|
||||
|
||||
# Find free port
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
|
||||
app = create_app()
|
||||
uvicorn_config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
|
||||
server = uvicorn.Server(uvicorn_config)
|
||||
|
||||
# Run in thread
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
# Wait for startup
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
for _ in range(50):
|
||||
try:
|
||||
urllib.request.urlopen(url, timeout=0.5) # noqa: S310
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.1)
|
||||
|
||||
yield url
|
||||
|
||||
server.should_exit = True
|
||||
thread.join(timeout=2)
|
||||
|
||||
# Clean up env
|
||||
os.environ.pop("CF_CONFIG", None)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def monkeypatch_module() -> Generator[pytest.MonkeyPatch, None, None]:
|
||||
"""Module-scoped monkeypatch."""
|
||||
mp = pytest.MonkeyPatch()
|
||||
yield mp
|
||||
mp.undo()
|
||||
|
||||
|
||||
class TestHTMXSidebarLoading:
|
||||
"""Test that sidebar loads dynamically via HTMX."""
|
||||
|
||||
def test_sidebar_initially_shows_loading(self, page: Page, server_url: str) -> None:
|
||||
"""Sidebar shows loading spinner before HTMX loads content."""
|
||||
# Intercept the sidebar request to delay it
|
||||
page.route("**/partials/sidebar", lambda route: route.abort())
|
||||
|
||||
page.goto(server_url)
|
||||
|
||||
# Before HTMX loads, should see loading indicator
|
||||
nav = page.locator("nav")
|
||||
assert "Loading" in nav.inner_text() or nav.locator(".loading").count() > 0
|
||||
|
||||
def test_sidebar_loads_services_via_htmx(self, page: Page, server_url: str) -> None:
|
||||
"""Sidebar fetches and displays services via hx-get on load."""
|
||||
page.goto(server_url)
|
||||
|
||||
# Wait for HTMX to load sidebar content
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Verify actual services from test config appear
|
||||
services = page.locator("#sidebar-services li")
|
||||
assert services.count() == 4 # plex, sonarr, radarr, jellyfin
|
||||
|
||||
# Check specific services are present
|
||||
content = page.locator("#sidebar-services").inner_text()
|
||||
assert "plex" in content
|
||||
assert "sonarr" in content
|
||||
assert "radarr" in content
|
||||
assert "jellyfin" in content
|
||||
|
||||
def test_dashboard_content_persists_after_sidebar_loads(
|
||||
self, page: Page, server_url: str
|
||||
) -> None:
|
||||
"""Dashboard content must remain visible after HTMX loads sidebar.
|
||||
|
||||
Regression test: conflicting hx-select attributes on the nav element
|
||||
were causing the dashboard to disappear when sidebar loaded.
|
||||
"""
|
||||
page.goto(server_url)
|
||||
|
||||
# Dashboard content should be visible immediately (server-rendered)
|
||||
stats = page.locator("#stats-cards")
|
||||
assert stats.is_visible()
|
||||
|
||||
# Wait for sidebar to fully load via HTMX
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Dashboard content must STILL be visible after sidebar loads
|
||||
assert stats.is_visible(), "Dashboard disappeared after sidebar loaded"
|
||||
assert page.locator("#stats-cards .card").count() >= 4
|
||||
|
||||
def test_sidebar_shows_running_status(self, page: Page, server_url: str) -> None:
|
||||
"""Sidebar shows running/stopped status indicators for services."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# plex and radarr are in state (running) - should have success status
|
||||
plex_item = page.locator("#sidebar-services li", has_text="plex")
|
||||
assert plex_item.locator(".status-success").count() == 1
|
||||
radarr_item = page.locator("#sidebar-services li", has_text="radarr")
|
||||
assert radarr_item.locator(".status-success").count() == 1
|
||||
|
||||
# sonarr and jellyfin are NOT in state (not started) - should have neutral status
|
||||
sonarr_item = page.locator("#sidebar-services li", has_text="sonarr")
|
||||
assert sonarr_item.locator(".status-neutral").count() == 1
|
||||
jellyfin_item = page.locator("#sidebar-services li", has_text="jellyfin")
|
||||
assert jellyfin_item.locator(".status-neutral").count() == 1
|
||||
|
||||
|
||||
class TestHTMXBoostNavigation:
|
||||
"""Test hx-boost SPA-like navigation."""
|
||||
|
||||
def test_navigation_updates_url_without_full_reload(self, page: Page, server_url: str) -> None:
|
||||
"""Clicking boosted link updates URL without full page reload."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services a", timeout=5000)
|
||||
|
||||
# Add a marker to detect full page reload
|
||||
page.evaluate("window.__htmxTestMarker = 'still-here'")
|
||||
|
||||
# Click a service link (boosted via hx-boost on parent)
|
||||
page.locator("#sidebar-services a", has_text="plex").click()
|
||||
|
||||
# Wait for navigation
|
||||
page.wait_for_url("**/service/plex", timeout=5000)
|
||||
|
||||
# Verify URL changed
|
||||
assert "/service/plex" in page.url
|
||||
|
||||
# Verify NO full page reload (marker should still exist)
|
||||
marker = page.evaluate("window.__htmxTestMarker")
|
||||
assert marker == "still-here", "Full page reload occurred - hx-boost not working"
|
||||
|
||||
def test_main_content_replaced_on_navigation(self, page: Page, server_url: str) -> None:
|
||||
"""Navigation replaces #main-content via hx-target/hx-select."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services a", timeout=5000)
|
||||
|
||||
# Get initial main content
|
||||
initial_content = page.locator("#main-content").inner_text()
|
||||
assert "Compose Farm" in initial_content # Dashboard title
|
||||
|
||||
# Navigate to service page
|
||||
page.locator("#sidebar-services a", has_text="plex").click()
|
||||
page.wait_for_url("**/service/plex", timeout=5000)
|
||||
|
||||
# Main content should now show service page
|
||||
new_content = page.locator("#main-content").inner_text()
|
||||
assert "plex" in new_content.lower()
|
||||
assert "Compose Farm" not in new_content # Dashboard title should be gone
|
||||
|
||||
|
||||
class TestDashboardContent:
|
||||
"""Test dashboard displays correct data."""
|
||||
|
||||
def test_stats_show_correct_counts(self, page: Page, server_url: str) -> None:
|
||||
"""Stats cards show accurate host/service counts from config."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#stats-cards", timeout=5000)
|
||||
|
||||
stats = page.locator("#stats-cards").inner_text()
|
||||
|
||||
# From test config: 2 hosts, 4 services, 2 running (plex, radarr)
|
||||
assert "2" in stats # hosts count
|
||||
assert "4" in stats # services count
|
||||
|
||||
def test_pending_shows_not_started_services(self, page: Page, server_url: str) -> None:
|
||||
"""Pending operations shows sonarr and jellyfin as not started."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#pending-operations", timeout=5000)
|
||||
|
||||
pending = page.locator("#pending-operations")
|
||||
content = pending.inner_text().lower()
|
||||
|
||||
# sonarr and jellyfin are not in state, should show as not started
|
||||
assert "sonarr" in content or "not started" in content
|
||||
assert "jellyfin" in content or "not started" in content
|
||||
|
||||
|
||||
class TestSaveConfigButton:
|
||||
"""Test save config button behavior."""
|
||||
|
||||
def test_save_button_shows_saved_feedback(self, page: Page, server_url: str) -> None:
|
||||
"""Clicking save shows 'Saved!' feedback text."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#save-config-btn", timeout=5000)
|
||||
|
||||
save_btn = page.locator("#save-config-btn")
|
||||
initial_text = save_btn.inner_text()
|
||||
assert "Save" in initial_text
|
||||
|
||||
# Click save
|
||||
save_btn.click()
|
||||
|
||||
# Wait for feedback
|
||||
page.wait_for_function(
|
||||
"document.querySelector('#save-config-btn')?.textContent?.includes('Saved')",
|
||||
timeout=5000,
|
||||
)
|
||||
|
||||
# Verify feedback shown
|
||||
assert "Saved" in save_btn.inner_text()
|
||||
|
||||
|
||||
class TestServiceDetailPage:
|
||||
"""Test service detail page via HTMX navigation."""
|
||||
|
||||
def test_service_page_shows_service_info(self, page: Page, server_url: str) -> None:
|
||||
"""Service page displays service information."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services a", timeout=5000)
|
||||
|
||||
# Navigate to plex service
|
||||
page.locator("#sidebar-services a", has_text="plex").click()
|
||||
page.wait_for_url("**/service/plex", timeout=5000)
|
||||
|
||||
# Should show service name and host info
|
||||
content = page.locator("#main-content").inner_text()
|
||||
assert "plex" in content.lower()
|
||||
assert "server-1" in content # assigned host from config
|
||||
# Should show compose file path
|
||||
assert "compose.yaml" in content
|
||||
|
||||
def test_back_navigation_works(self, page: Page, server_url: str) -> None:
|
||||
"""Browser back button works after HTMX navigation."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services a", timeout=5000)
|
||||
|
||||
# Navigate to service
|
||||
page.locator("#sidebar-services a", has_text="plex").click()
|
||||
page.wait_for_url("**/service/plex", timeout=5000)
|
||||
|
||||
# Go back
|
||||
page.go_back()
|
||||
page.wait_for_url(server_url, timeout=5000)
|
||||
|
||||
# Should be back on dashboard
|
||||
assert page.url.rstrip("/") == server_url.rstrip("/")
|
||||
|
||||
|
||||
class TestSidebarFilter:
|
||||
"""Test JavaScript sidebar filtering functionality."""
|
||||
|
||||
@staticmethod
|
||||
def _filter_sidebar(page: Page, text: str) -> None:
|
||||
"""Fill the sidebar filter and trigger the keyup event.
|
||||
|
||||
The sidebar uses onkeyup, which fill() doesn't trigger.
|
||||
"""
|
||||
filter_input = page.locator("#sidebar-filter")
|
||||
filter_input.fill(text)
|
||||
filter_input.dispatch_event("keyup")
|
||||
|
||||
def test_text_filter_hides_non_matching_services(self, page: Page, server_url: str) -> None:
|
||||
"""Typing in filter input hides services that don't match."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Initially all 4 services visible
|
||||
visible_items = page.locator("#sidebar-services li:not([hidden])")
|
||||
assert visible_items.count() == 4
|
||||
|
||||
# Type in filter to match only "plex"
|
||||
self._filter_sidebar(page, "plex")
|
||||
|
||||
# Only plex should be visible now
|
||||
visible_after = page.locator("#sidebar-services li:not([hidden])")
|
||||
assert visible_after.count() == 1
|
||||
assert "plex" in visible_after.first.inner_text()
|
||||
|
||||
def test_text_filter_updates_count_badge(self, page: Page, server_url: str) -> None:
|
||||
"""Filter updates the service count badge."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Initial count should be (4)
|
||||
count_badge = page.locator("#sidebar-count")
|
||||
assert "(4)" in count_badge.inner_text()
|
||||
|
||||
# Filter to show only services containing "arr" (sonarr, radarr)
|
||||
self._filter_sidebar(page, "arr")
|
||||
|
||||
# Count should update to (2)
|
||||
assert "(2)" in count_badge.inner_text()
|
||||
|
||||
def test_text_filter_is_case_insensitive(self, page: Page, server_url: str) -> None:
|
||||
"""Filter matching is case-insensitive."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Type uppercase
|
||||
self._filter_sidebar(page, "PLEX")
|
||||
|
||||
# Should still match plex
|
||||
visible = page.locator("#sidebar-services li:not([hidden])")
|
||||
assert visible.count() == 1
|
||||
assert "plex" in visible.first.inner_text().lower()
|
||||
|
||||
def test_host_dropdown_filters_by_host(self, page: Page, server_url: str) -> None:
|
||||
"""Host dropdown filters services by their assigned host."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Select server-1 from dropdown
|
||||
page.locator("#sidebar-host-select").select_option("server-1")
|
||||
|
||||
# Only plex and sonarr (server-1 services) should be visible
|
||||
visible = page.locator("#sidebar-services li:not([hidden])")
|
||||
assert visible.count() == 2
|
||||
|
||||
content = visible.all_inner_texts()
|
||||
assert any("plex" in s for s in content)
|
||||
assert any("sonarr" in s for s in content)
|
||||
assert not any("radarr" in s for s in content)
|
||||
assert not any("jellyfin" in s for s in content)
|
||||
|
||||
def test_combined_text_and_host_filter(self, page: Page, server_url: str) -> None:
|
||||
"""Text filter and host filter work together."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Filter by server-2 host
|
||||
page.locator("#sidebar-host-select").select_option("server-2")
|
||||
|
||||
# Then filter by text "arr" (should match only radarr on server-2)
|
||||
self._filter_sidebar(page, "arr")
|
||||
|
||||
visible = page.locator("#sidebar-services li:not([hidden])")
|
||||
assert visible.count() == 1
|
||||
assert "radarr" in visible.first.inner_text()
|
||||
|
||||
def test_clearing_filter_shows_all_services(self, page: Page, server_url: str) -> None:
|
||||
"""Clearing filter restores all services."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Apply filter
|
||||
self._filter_sidebar(page, "plex")
|
||||
assert page.locator("#sidebar-services li:not([hidden])").count() == 1
|
||||
|
||||
# Clear filter
|
||||
self._filter_sidebar(page, "")
|
||||
|
||||
# All services visible again
|
||||
assert page.locator("#sidebar-services li:not([hidden])").count() == 4
|
||||
|
||||
|
||||
class TestCommandPalette:
|
||||
"""Test command palette (Cmd+K) JavaScript functionality."""
|
||||
|
||||
def test_cmd_k_opens_palette(self, page: Page, server_url: str) -> None:
|
||||
"""Cmd+K keyboard shortcut opens the command palette."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Palette should be closed initially
|
||||
assert not page.locator("#cmd-palette").is_visible()
|
||||
|
||||
# Press Cmd+K (Meta+k on Mac, Control+k otherwise)
|
||||
page.keyboard.press("Control+k")
|
||||
|
||||
# Palette should now be open
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
assert page.locator("#cmd-palette").is_visible()
|
||||
|
||||
def test_palette_input_is_focused_on_open(self, page: Page, server_url: str) -> None:
|
||||
"""Input field is focused when palette opens."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
page.keyboard.press("Control+k")
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
|
||||
# Input should be focused - we can type directly
|
||||
page.keyboard.type("test")
|
||||
assert page.locator("#cmd-input").input_value() == "test"
|
||||
|
||||
def test_palette_shows_navigation_commands(self, page: Page, server_url: str) -> None:
|
||||
"""Palette shows Dashboard and Console navigation commands."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
page.keyboard.press("Control+k")
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
|
||||
cmd_list = page.locator("#cmd-list").inner_text()
|
||||
assert "Dashboard" in cmd_list
|
||||
assert "Console" in cmd_list
|
||||
|
||||
def test_palette_shows_service_navigation(self, page: Page, server_url: str) -> None:
|
||||
"""Palette includes service names for navigation."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
page.keyboard.press("Control+k")
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
|
||||
cmd_list = page.locator("#cmd-list").inner_text()
|
||||
# Services should appear as navigation options
|
||||
assert "plex" in cmd_list
|
||||
assert "radarr" in cmd_list
|
||||
|
||||
def test_palette_filters_on_input(self, page: Page, server_url: str) -> None:
|
||||
"""Typing in palette filters the command list."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
page.keyboard.press("Control+k")
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
|
||||
# Type to filter
|
||||
page.locator("#cmd-input").fill("plex")
|
||||
|
||||
# Should show plex, hide others
|
||||
cmd_list = page.locator("#cmd-list").inner_text()
|
||||
assert "plex" in cmd_list
|
||||
assert "Dashboard" not in cmd_list # Filtered out
|
||||
|
||||
def test_arrow_down_moves_selection(self, page: Page, server_url: str) -> None:
|
||||
"""Arrow down key moves selection to next item."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
page.keyboard.press("Control+k")
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
|
||||
# First item should be selected (has bg-base-300)
|
||||
first_item = page.locator("#cmd-list a").first
|
||||
assert "bg-base-300" in (first_item.get_attribute("class") or "")
|
||||
|
||||
# Press arrow down
|
||||
page.keyboard.press("ArrowDown")
|
||||
|
||||
# Second item should now be selected
|
||||
second_item = page.locator("#cmd-list a").nth(1)
|
||||
assert "bg-base-300" in (second_item.get_attribute("class") or "")
|
||||
# First should no longer be selected
|
||||
assert "bg-base-300" not in (first_item.get_attribute("class") or "")
|
||||
|
||||
def test_enter_executes_and_closes_palette(self, page: Page, server_url: str) -> None:
|
||||
"""Enter key executes selected command and closes palette."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
page.keyboard.press("Control+k")
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
|
||||
# Filter to plex service
|
||||
page.locator("#cmd-input").fill("plex")
|
||||
page.keyboard.press("Enter")
|
||||
|
||||
# Palette should close
|
||||
page.wait_for_selector("#cmd-palette:not([open])", timeout=2000)
|
||||
|
||||
# Should navigate to plex service page
|
||||
page.wait_for_url("**/service/plex", timeout=5000)
|
||||
|
||||
def test_click_executes_command(self, page: Page, server_url: str) -> None:
|
||||
"""Clicking a command executes it."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
page.keyboard.press("Control+k")
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
|
||||
# Click on Console command
|
||||
page.locator("#cmd-list a", has_text="Console").click()
|
||||
|
||||
# Should navigate to console page
|
||||
page.wait_for_url("**/console", timeout=5000)
|
||||
|
||||
def test_escape_closes_palette(self, page: Page, server_url: str) -> None:
|
||||
"""Escape key closes the palette without executing."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
page.keyboard.press("Control+k")
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
|
||||
page.keyboard.press("Escape")
|
||||
|
||||
# Palette should close, URL unchanged
|
||||
page.wait_for_selector("#cmd-palette:not([open])", timeout=2000)
|
||||
assert page.url.rstrip("/") == server_url.rstrip("/")
|
||||
|
||||
def test_fab_button_opens_palette(self, page: Page, server_url: str) -> None:
|
||||
"""Floating action button opens the command palette."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Click the FAB
|
||||
page.locator("#cmd-fab").click()
|
||||
|
||||
# Palette should open
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
|
||||
|
||||
class TestActionButtons:
|
||||
"""Test action button HTMX POST requests."""
|
||||
|
||||
def test_apply_button_makes_post_request(self, page: Page, server_url: str) -> None:
|
||||
"""Apply button triggers POST to /api/apply."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Intercept the API call
|
||||
api_calls: list[str] = []
|
||||
|
||||
def handle_route(route: Route) -> None:
|
||||
api_calls.append(route.request.url)
|
||||
# Return a mock response
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"task_id": "test-apply-123"}',
|
||||
)
|
||||
|
||||
page.route("**/api/apply", handle_route)
|
||||
|
||||
# Click Apply button
|
||||
page.locator("button", has_text="Apply").click()
|
||||
|
||||
# Wait for request to be made
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# Verify API was called
|
||||
assert len(api_calls) == 1
|
||||
assert "/api/apply" in api_calls[0]
|
||||
|
||||
def test_refresh_button_makes_post_request(self, page: Page, server_url: str) -> None:
|
||||
"""Refresh button triggers POST to /api/refresh."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
api_calls: list[str] = []
|
||||
|
||||
def handle_route(route: Route) -> None:
|
||||
api_calls.append(route.request.url)
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"task_id": "test-refresh-123"}',
|
||||
)
|
||||
|
||||
page.route("**/api/refresh", handle_route)
|
||||
|
||||
page.locator("button", has_text="Refresh").click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
assert len(api_calls) == 1
|
||||
assert "/api/refresh" in api_calls[0]
|
||||
|
||||
def test_action_response_expands_terminal(self, page: Page, server_url: str) -> None:
|
||||
"""Action button response with task_id expands terminal section."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Terminal should be collapsed initially
|
||||
terminal_toggle = page.locator("#terminal-toggle")
|
||||
assert not terminal_toggle.is_checked()
|
||||
|
||||
# Mock the API to return a task_id
|
||||
page.route(
|
||||
"**/api/apply",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"task_id": "test-123"}',
|
||||
),
|
||||
)
|
||||
|
||||
# Click Apply
|
||||
page.locator("button", has_text="Apply").click()
|
||||
|
||||
# Terminal should expand
|
||||
page.wait_for_function(
|
||||
"document.getElementById('terminal-toggle')?.checked === true",
|
||||
timeout=3000,
|
||||
)
|
||||
|
||||
def test_service_page_action_buttons(self, page: Page, server_url: str) -> None:
|
||||
"""Service page has working action buttons."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services a", timeout=5000)
|
||||
|
||||
# Navigate to plex service
|
||||
page.locator("#sidebar-services a", has_text="plex").click()
|
||||
page.wait_for_url("**/service/plex", timeout=5000)
|
||||
|
||||
# Intercept service-specific API calls
|
||||
api_calls: list[str] = []
|
||||
|
||||
def handle_route(route: Route) -> None:
|
||||
api_calls.append(route.request.url)
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"task_id": "test-up-123"}',
|
||||
)
|
||||
|
||||
page.route("**/api/service/plex/up", handle_route)
|
||||
|
||||
# Click Up button (use get_by_role for exact match, avoiding "Update")
|
||||
page.get_by_role("button", name="Up", exact=True).click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
assert len(api_calls) == 1
|
||||
assert "/api/service/plex/up" in api_calls[0]
|
||||
|
||||
|
||||
class TestKeyboardShortcuts:
|
||||
"""Test global keyboard shortcuts."""
|
||||
|
||||
def test_ctrl_s_triggers_save(self, page: Page, server_url: str) -> None:
|
||||
"""Ctrl+S triggers save when editors are present."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#save-config-btn", timeout=5000)
|
||||
|
||||
# Wait for Monaco editor to load (it takes a moment)
|
||||
page.wait_for_function(
|
||||
"typeof monaco !== 'undefined'",
|
||||
timeout=10000,
|
||||
)
|
||||
|
||||
# Press Ctrl+S
|
||||
page.keyboard.press("Control+s")
|
||||
|
||||
# Should trigger save - button shows "Saved!"
|
||||
page.wait_for_function(
|
||||
"document.querySelector('#save-config-btn')?.textContent?.includes('Saved')",
|
||||
timeout=5000,
|
||||
)
|
||||
|
||||
|
||||
class TestContentStability:
|
||||
"""Test that HTMX operations don't accidentally destroy other page content.
|
||||
|
||||
These tests verify that when one element updates, other elements remain stable.
|
||||
This catches bugs where HTMX attributes (hx-select, hx-swap-oob, etc.) are
|
||||
misconfigured and cause unintended side effects.
|
||||
"""
|
||||
|
||||
def test_all_dashboard_sections_visible_after_full_load(
|
||||
self, page: Page, server_url: str
|
||||
) -> None:
|
||||
"""All dashboard sections remain visible after HTMX completes loading."""
|
||||
page.goto(server_url)
|
||||
|
||||
# Wait for all HTMX requests to complete
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# All major dashboard sections must be visible
|
||||
assert page.locator("#stats-cards").is_visible(), "Stats cards missing"
|
||||
assert page.locator("#stats-cards .card").count() >= 4, "Stats incomplete"
|
||||
assert page.locator("#pending-operations").is_visible(), "Pending ops missing"
|
||||
assert page.locator("#services-by-host").is_visible(), "Services by host missing"
|
||||
assert page.locator("#sidebar-services").is_visible(), "Sidebar missing"
|
||||
|
||||
def test_sidebar_persists_after_navigation_and_back(self, page: Page, server_url: str) -> None:
|
||||
"""Sidebar content persists through navigation cycle."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Remember sidebar state
|
||||
initial_count = page.locator("#sidebar-services li").count()
|
||||
assert initial_count == 4
|
||||
|
||||
# Navigate away
|
||||
page.locator("#sidebar-services a", has_text="plex").click()
|
||||
page.wait_for_url("**/service/plex", timeout=5000)
|
||||
|
||||
# Sidebar should still be there with same content
|
||||
assert page.locator("#sidebar-services").is_visible()
|
||||
assert page.locator("#sidebar-services li").count() == initial_count
|
||||
|
||||
# Navigate back
|
||||
page.go_back()
|
||||
page.wait_for_url(server_url, timeout=5000)
|
||||
|
||||
# Sidebar still intact
|
||||
assert page.locator("#sidebar-services").is_visible()
|
||||
assert page.locator("#sidebar-services li").count() == initial_count
|
||||
|
||||
def test_dashboard_sections_persist_after_save(self, page: Page, server_url: str) -> None:
|
||||
"""Dashboard sections remain after save triggers cf:refresh event."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Capture initial state - all must be visible
|
||||
assert page.locator("#stats-cards").is_visible()
|
||||
assert page.locator("#pending-operations").is_visible()
|
||||
assert page.locator("#services-by-host").is_visible()
|
||||
|
||||
# Trigger save (which dispatches cf:refresh)
|
||||
page.locator("#save-config-btn").click()
|
||||
page.wait_for_function(
|
||||
"document.querySelector('#save-config-btn')?.textContent?.includes('Saved')",
|
||||
timeout=5000,
|
||||
)
|
||||
|
||||
# Wait for refresh requests to complete
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# All sections must still be visible
|
||||
assert page.locator("#stats-cards").is_visible(), "Stats disappeared after save"
|
||||
assert page.locator("#pending-operations").is_visible(), "Pending disappeared"
|
||||
assert page.locator("#services-by-host").is_visible(), "Services disappeared"
|
||||
assert page.locator("#sidebar-services").is_visible(), "Sidebar disappeared"
|
||||
|
||||
def test_filter_state_not_affected_by_other_htmx_requests(
|
||||
self, page: Page, server_url: str
|
||||
) -> None:
|
||||
"""Sidebar filter state persists during other HTMX activity."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Apply a filter
|
||||
filter_input = page.locator("#sidebar-filter")
|
||||
filter_input.fill("plex")
|
||||
filter_input.dispatch_event("keyup")
|
||||
|
||||
# Verify filter is applied
|
||||
assert page.locator("#sidebar-services li:not([hidden])").count() == 1
|
||||
|
||||
# Trigger a save (causes cf:refresh on multiple elements)
|
||||
page.locator("#save-config-btn").click()
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# Filter input should still have our text
|
||||
# (Note: sidebar reloads so filter clears - this tests the sidebar reload works)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
assert page.locator("#sidebar-services").is_visible()
|
||||
|
||||
def test_main_content_not_affected_by_sidebar_refresh(
|
||||
self, page: Page, server_url: str
|
||||
) -> None:
|
||||
"""Main content area stays intact when sidebar refreshes."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Get main content text
|
||||
main_content = page.locator("#main-content")
|
||||
initial_text = main_content.inner_text()
|
||||
assert "Compose Farm" in initial_text
|
||||
|
||||
# Trigger cf:refresh (which refreshes sidebar)
|
||||
page.evaluate("document.body.dispatchEvent(new CustomEvent('cf:refresh'))")
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# Main content should be unchanged (same page, just refreshed partials)
|
||||
assert "Compose Farm" in main_content.inner_text()
|
||||
assert page.locator("#stats-cards").is_visible()
|
||||
|
||||
def test_no_duplicate_elements_after_multiple_refreshes(
|
||||
self, page: Page, server_url: str
|
||||
) -> None:
|
||||
"""Multiple refresh cycles don't create duplicate elements."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Count initial elements
|
||||
initial_stat_count = page.locator("#stats-cards .card").count()
|
||||
initial_service_count = page.locator("#sidebar-services li").count()
|
||||
|
||||
# Trigger multiple refreshes
|
||||
for _ in range(3):
|
||||
page.evaluate("document.body.dispatchEvent(new CustomEvent('cf:refresh'))")
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Counts should be same (no duplicates created)
|
||||
assert page.locator("#stats-cards .card").count() == initial_stat_count
|
||||
assert page.locator("#sidebar-services li").count() == initial_service_count
|
||||
217
uv.lock
generated
217
uv.lock
generated
@@ -134,6 +134,79 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
@@ -184,6 +257,7 @@ dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-playwright" },
|
||||
{ name = "ruff" },
|
||||
{ name = "types-pyyaml" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
@@ -214,6 +288,7 @@ dev = [
|
||||
{ name = "pytest", specifier = ">=9.0.2" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||
{ name = "pytest-cov", specifier = ">=6.0.0" },
|
||||
{ name = "pytest-playwright", specifier = ">=0.7.0" },
|
||||
{ name = "ruff", specifier = ">=0.14.8" },
|
||||
{ name = "types-pyyaml", specifier = ">=6.0.12.20250915" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" },
|
||||
@@ -573,6 +648,53 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164, upload-time = "2025-12-04T14:42:51.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -936,6 +1058,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.57.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet" },
|
||||
{ name = "pyee" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/b6/e17543cea8290ae4dced10be21d5a43c360096aa2cce0aa7039e60c50df3/playwright-1.57.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9351c1ac3dfd9b3820fe7fc4340d96c0d3736bb68097b9b7a69bd45d25e9370c", size = 41985039, upload-time = "2025-12-09T08:06:18.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/04/ef95b67e1ff59c080b2effd1a9a96984d6953f667c91dfe9d77c838fc956/playwright-1.57.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4a9d65027bce48eeba842408bcc1421502dfd7e41e28d207e94260fa93ca67e", size = 40775575, upload-time = "2025-12-09T08:06:22.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bd/5563850322a663956c927eefcf1457d12917e8f118c214410e815f2147d1/playwright-1.57.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:99104771abc4eafee48f47dac2369e0015516dc1ce8c409807d2dd440828b9a4", size = 41985042, upload-time = "2025-12-09T08:06:25.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/61/3a803cb5ae0321715bfd5247ea871d25b32c8f372aeb70550a90c5f586df/playwright-1.57.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:284ed5a706b7c389a06caa431b2f0ba9ac4130113c3a779767dda758c2497bb1", size = 45975252, upload-time = "2025-12-09T08:06:29.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/d7/b72eb59dfbea0013a7f9731878df8c670f5f35318cedb010c8a30292c118/playwright-1.57.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a1bae6c0a07839cdeaddbc0756b3b2b85e476c07945f64ece08f1f956a86f1", size = 45706917, upload-time = "2025-12-09T08:06:32.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/09/3fc9ebd7c95ee54ba6a68d5c0bc23e449f7235f4603fc60534a364934c16/playwright-1.57.0-py3-none-win32.whl", hash = "sha256:1dd93b265688da46e91ecb0606d36f777f8eadcf7fbef12f6426b20bf0c9137c", size = 36553860, upload-time = "2025-12-09T08:06:35.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/d4/dcdfd2a33096aeda6ca0d15584800443dd2be64becca8f315634044b135b/playwright-1.57.0-py3-none-win_amd64.whl", hash = "sha256:6caefb08ed2c6f29d33b8088d05d09376946e49a73be19271c8cd5384b82b14c", size = 36553864, upload-time = "2025-12-09T08:06:38.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/60/fe31d7e6b8907789dcb0584f88be741ba388413e4fbce35f1eba4e3073de/playwright-1.57.0-py3-none-win_arm64.whl", hash = "sha256:5f065f5a133dbc15e6e7c71e7bc04f258195755b1c32a432b792e28338c8335e", size = 32837940, upload-time = "2025-12-09T08:06:42.268Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -1087,6 +1228,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
@@ -1125,6 +1278,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -1139,6 +1305,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-playwright"
|
||||
version = "0.7.2"
|
||||
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/e8/6b/913e36aa421b35689ec95ed953ff7e8df3f2ee1c7b8ab2a3f1fd39d95faf/pytest_playwright-0.7.2.tar.gz", hash = "sha256:247b61123b28c7e8febb993a187a07e54f14a9aa04edc166f7a976d88f04c770", size = 16928, upload-time = "2025-11-24T03:43:22.53Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/61/4d333d8354ea2bea2c2f01bad0a4aa3c1262de20e1241f78e73360e9b620/pytest_playwright-0.7.2-py3-none-any.whl", hash = "sha256:8084e015b2b3ecff483c2160f1c8219b38b66c0d4578b23c0f700d1b0240ea38", size = 16881, upload-time = "2025-11-24T03:43:24.423Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.1"
|
||||
@@ -1157,6 +1338,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" },
|
||||
]
|
||||
|
||||
[[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 = "pyyaml"
|
||||
version = "6.0.3"
|
||||
@@ -1212,6 +1405,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.2.0"
|
||||
@@ -1395,6 +1603,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
|
||||
]
|
||||
|
||||
[[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 = "tomli"
|
||||
version = "2.3.0"
|
||||
|
||||
Reference in New Issue
Block a user