feat(api): spa fallback route + comfyui registration + coverage gates

This commit is contained in:
DumiFlex
2026-04-26 17:00:17 +03:00
parent 625cab7682
commit 51c17565b8
7 changed files with 140 additions and 0 deletions
+2
View File
@@ -15,6 +15,8 @@ pnpm-debug.log
# Build outputs
js/
web_dist/
web/*
!web/index.html
coverage/
.vitest-cache/
+17
View File
@@ -36,3 +36,20 @@ class WildcardPipelineExtension(ComfyExtension):
async def comfy_entrypoint() -> WildcardPipelineExtension:
return WildcardPipelineExtension()
# Register SPA + REST API routes on ComfyUI's aiohttp app.
# Imported lazily inside the try-block so static analysis tools that
# don't have ComfyUI's `server` module on the path don't fail.
try:
from server import PromptServer # type: ignore[import-not-found]
from wp_api import register_routes as _register_wp_routes
_register_wp_routes(PromptServer.instance.app)
except Exception: # noqa: BLE001 - never crash ComfyUI on route registration failure
import logging
logging.getLogger(__name__).exception(
"wildcard-pipeline: route registration failed",
)
+14
View File
@@ -69,3 +69,17 @@ asyncio_mode = "auto"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.coverage.run]
source = ["engine", "wp_api"]
omit = [
"engine/db/migrations_sql/*",
"engine/handlers.py",
"engine/template.py",
"engine/pipeline.py",
"engine/context.py",
]
[tool.coverage.report]
fail_under = 80
show_missing = true
+49
View File
@@ -0,0 +1,49 @@
"""Tests for /wp + /wp/{path:.*} static + SPA fallback behavior."""
import pytest
pytestmark = pytest.mark.asyncio
async def test_root_serves_index_html(wp_client):
resp = await wp_client.get("/wp")
assert resp.status == 200
body = await resp.text()
assert "<title>" in body.lower()
assert "wildcard pipeline" in body.lower()
async def test_unknown_path_falls_back_to_index_html(wp_client):
resp = await wp_client.get("/wp/modules/some-future-route")
assert resp.status == 200
body = await resp.text()
assert "<title>" in body.lower()
async def test_static_asset_served_when_present(wp_client, tmp_path):
"""Static files in WEB_DIR are served as-is, not as the SPA shell."""
from wp_api import spa as spa_mod
asset = spa_mod.WEB_DIR / "favicon.txt"
asset.write_text("hello", encoding="utf-8")
try:
resp = await wp_client.get("/wp/favicon.txt")
assert resp.status == 200
assert (await resp.text()) == "hello"
finally:
asset.unlink()
async def test_path_traversal_blocked(wp_client):
"""Requests with .. in the path must not escape WEB_DIR."""
resp = await wp_client.get("/wp/../pyproject.toml")
# aiohttp's router normalizes path traversal before reaching the handler;
# the request resolves to /pyproject.toml which is not registered → 404.
# If it ever reaches our handler, the resolve check returns 403.
assert resp.status in (403, 404)
async def test_api_routes_not_shadowed(wp_client):
"""The catch-all /wp/{path:.*} must not shadow specific /wp/api/* routes."""
resp = await wp_client.get("/wp/api/modules")
assert resp.status == 200
body = await resp.json()
assert "items" in body
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Wildcard Pipeline Manager</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="app">
<h1>Wildcard Pipeline Manager</h1>
<p>SPA scaffold not yet built. See Plan 2.</p>
</div>
</body>
</html>
+6
View File
@@ -6,6 +6,7 @@ from aiohttp import web
from wp_api import categories as _categories
from wp_api import import_export as _import_export
from wp_api import modules as _modules
from wp_api import spa as _spa
from wp_api import test_runner as _test_runner
@@ -15,3 +16,8 @@ def register_routes(app: web.Application) -> None:
_categories.register(app.router)
_test_runner.register(app.router)
_import_export.register(app.router)
# SPA fallback last — broad catch-all `/wp/{path:.*}` must not shadow
# specific `/wp/api/...` routes. aiohttp resolves more-specific routes
# first regardless of registration order, but late registration keeps
# intent obvious.
_spa.register(app.router)
+38
View File
@@ -0,0 +1,38 @@
"""/wp + /wp/{path:.*} — static asset server with SPA fallback.
Looks up the requested path inside ``WEB_DIR``. If a file exists, serves it.
Otherwise serves ``index.html`` so the SPA router can take over (any
``/wp/<route>`` path resolves to the SPA shell).
"""
from __future__ import annotations
from pathlib import Path
from aiohttp import web
WEB_DIR = Path(__file__).parent.parent / "web"
async def _serve(path: str) -> web.Response:
if path:
candidate = (WEB_DIR / path).resolve()
try:
candidate.relative_to(WEB_DIR.resolve())
except ValueError:
return web.Response(status=403)
if candidate.is_file():
return web.FileResponse(candidate)
return web.FileResponse(WEB_DIR / "index.html")
async def serve_root(request: web.Request) -> web.Response:
return await _serve("")
async def serve_path(request: web.Request) -> web.Response:
return await _serve(request.match_info["path"])
def register(router) -> None:
router.add_get("/wp", serve_root)
router.add_get("/wp/{path:.*}", serve_path)