create-caspian-app 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/caspian.js +1 -1
- package/dist/index.js +1 -1
- package/dist/public/js/main.js +12 -12
- package/dist/settings/_component_imports.py +70 -70
- package/dist/settings/browser_log.py +498 -498
- package/dist/settings/bs-config.json +6 -6
- package/dist/settings/build-static.py +290 -290
- package/dist/settings/check.py +415 -415
- package/dist/settings/check_templates.py +274 -274
- package/dist/settings/component-map.ts +6 -6
- package/dist/settings/dev-log-bridge.ts +585 -585
- package/dist/settings/fix.py +95 -95
- package/dist/settings/python-server.ts +31 -31
- package/dist/settings/run-postcss.ts +317 -317
- package/dist/settings/serve-static.py +188 -188
- package/dist/tests/README.md +135 -135
- package/dist/tests/conftest.py +89 -89
- package/dist/tests/test_health_route.py +44 -44
- package/dist/tests/test_main_helpers.py +112 -112
- package/package.json +1 -1
package/dist/tests/conftest.py
CHANGED
|
@@ -1,89 +1,89 @@
|
|
|
1
|
-
"""Shared pytest setup for the app-level test suite.
|
|
2
|
-
|
|
3
|
-
Ensures the project root is importable so `import main` and `from src...`
|
|
4
|
-
resolve the same way they do when Caspian boots the app.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import asyncio
|
|
8
|
-
import os
|
|
9
|
-
import sys
|
|
10
|
-
from collections.abc import Coroutine
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from types import SimpleNamespace
|
|
13
|
-
from typing import Optional, TypeVar
|
|
14
|
-
|
|
15
|
-
import pytest
|
|
16
|
-
|
|
17
|
-
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
18
|
-
if str(PROJECT_ROOT) not in sys.path:
|
|
19
|
-
sys.path.insert(0, str(PROJECT_ROOT))
|
|
20
|
-
|
|
21
|
-
_T = TypeVar("_T")
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def run_async(coro: Coroutine[object, object, _T]) -> _T:
|
|
25
|
-
"""Run a coroutine to completion and return its result.
|
|
26
|
-
|
|
27
|
-
Shared helper so synchronous tests can exercise async runtime code
|
|
28
|
-
(component compilation, streaming) without a full async test harness.
|
|
29
|
-
"""
|
|
30
|
-
return asyncio.run(coro)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@pytest.fixture
|
|
34
|
-
def fake_request():
|
|
35
|
-
"""Factory for a minimal Starlette-like request stand-in.
|
|
36
|
-
|
|
37
|
-
Casp's ``Auth`` reads ``request.session`` while ``StateManager`` reads
|
|
38
|
-
``request.state.session`` and ``request.headers``. The factory shares a
|
|
39
|
-
single session dict between ``request.session`` and ``request.state.session``
|
|
40
|
-
so both runtimes observe the same store, and exposes ``url``/``method``/
|
|
41
|
-
``query_params`` defaults that the auth request-flow helpers touch.
|
|
42
|
-
"""
|
|
43
|
-
|
|
44
|
-
def _make(
|
|
45
|
-
session: Optional[dict] = None,
|
|
46
|
-
headers: Optional[dict] = None,
|
|
47
|
-
method: str = "GET",
|
|
48
|
-
path: str = "/",
|
|
49
|
-
query_params: Optional[dict] = None,
|
|
50
|
-
) -> SimpleNamespace:
|
|
51
|
-
store: dict = session if session is not None else {}
|
|
52
|
-
return SimpleNamespace(
|
|
53
|
-
session=store,
|
|
54
|
-
state=SimpleNamespace(session=store),
|
|
55
|
-
headers=headers if headers is not None else {},
|
|
56
|
-
method=method,
|
|
57
|
-
url=SimpleNamespace(path=path),
|
|
58
|
-
query_params=query_params if query_params is not None else {},
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
return _make
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
@pytest.fixture
|
|
65
|
-
def component_fixture_dir():
|
|
66
|
-
"""A throwaway directory for writing component modules a test then imports.
|
|
67
|
-
|
|
68
|
-
Tests write ``.py`` component files here and pass the path as ``base_dir``
|
|
69
|
-
to ``transform_components`` so relative ``@import`` paths resolve against a
|
|
70
|
-
clean, per-test directory. The compiler only loads components from inside a
|
|
71
|
-
configured ``componentScanDirs`` root (``src`` here), so the directory must
|
|
72
|
-
live under ``src`` rather than an arbitrary ``tmp_path``.
|
|
73
|
-
"""
|
|
74
|
-
import shutil
|
|
75
|
-
import uuid
|
|
76
|
-
|
|
77
|
-
scan_root = PROJECT_ROOT / "src" / "__test_components__"
|
|
78
|
-
fixture_dir = scan_root / uuid.uuid4().hex
|
|
79
|
-
fixture_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
-
try:
|
|
81
|
-
yield fixture_dir
|
|
82
|
-
finally:
|
|
83
|
-
shutil.rmtree(scan_root, ignore_errors=True)
|
|
84
|
-
|
|
85
|
-
# Keep the app in its non-production code paths during tests (dev security
|
|
86
|
-
# headers, readable error messages, no HTTPS-only cookies).
|
|
87
|
-
os.environ.setdefault("APP_ENV", "development")
|
|
88
|
-
# Deterministic session secret so importing main.py never raises in dev.
|
|
89
|
-
os.environ.setdefault("AUTH_SECRET", "test-secret-not-for-production")
|
|
1
|
+
"""Shared pytest setup for the app-level test suite.
|
|
2
|
+
|
|
3
|
+
Ensures the project root is importable so `import main` and `from src...`
|
|
4
|
+
resolve the same way they do when Caspian boots the app.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Coroutine
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
from typing import Optional, TypeVar
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
18
|
+
if str(PROJECT_ROOT) not in sys.path:
|
|
19
|
+
sys.path.insert(0, str(PROJECT_ROOT))
|
|
20
|
+
|
|
21
|
+
_T = TypeVar("_T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_async(coro: Coroutine[object, object, _T]) -> _T:
|
|
25
|
+
"""Run a coroutine to completion and return its result.
|
|
26
|
+
|
|
27
|
+
Shared helper so synchronous tests can exercise async runtime code
|
|
28
|
+
(component compilation, streaming) without a full async test harness.
|
|
29
|
+
"""
|
|
30
|
+
return asyncio.run(coro)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def fake_request():
|
|
35
|
+
"""Factory for a minimal Starlette-like request stand-in.
|
|
36
|
+
|
|
37
|
+
Casp's ``Auth`` reads ``request.session`` while ``StateManager`` reads
|
|
38
|
+
``request.state.session`` and ``request.headers``. The factory shares a
|
|
39
|
+
single session dict between ``request.session`` and ``request.state.session``
|
|
40
|
+
so both runtimes observe the same store, and exposes ``url``/``method``/
|
|
41
|
+
``query_params`` defaults that the auth request-flow helpers touch.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def _make(
|
|
45
|
+
session: Optional[dict] = None,
|
|
46
|
+
headers: Optional[dict] = None,
|
|
47
|
+
method: str = "GET",
|
|
48
|
+
path: str = "/",
|
|
49
|
+
query_params: Optional[dict] = None,
|
|
50
|
+
) -> SimpleNamespace:
|
|
51
|
+
store: dict = session if session is not None else {}
|
|
52
|
+
return SimpleNamespace(
|
|
53
|
+
session=store,
|
|
54
|
+
state=SimpleNamespace(session=store),
|
|
55
|
+
headers=headers if headers is not None else {},
|
|
56
|
+
method=method,
|
|
57
|
+
url=SimpleNamespace(path=path),
|
|
58
|
+
query_params=query_params if query_params is not None else {},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return _make
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.fixture
|
|
65
|
+
def component_fixture_dir():
|
|
66
|
+
"""A throwaway directory for writing component modules a test then imports.
|
|
67
|
+
|
|
68
|
+
Tests write ``.py`` component files here and pass the path as ``base_dir``
|
|
69
|
+
to ``transform_components`` so relative ``@import`` paths resolve against a
|
|
70
|
+
clean, per-test directory. The compiler only loads components from inside a
|
|
71
|
+
configured ``componentScanDirs`` root (``src`` here), so the directory must
|
|
72
|
+
live under ``src`` rather than an arbitrary ``tmp_path``.
|
|
73
|
+
"""
|
|
74
|
+
import shutil
|
|
75
|
+
import uuid
|
|
76
|
+
|
|
77
|
+
scan_root = PROJECT_ROOT / "src" / "__test_components__"
|
|
78
|
+
fixture_dir = scan_root / uuid.uuid4().hex
|
|
79
|
+
fixture_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
try:
|
|
81
|
+
yield fixture_dir
|
|
82
|
+
finally:
|
|
83
|
+
shutil.rmtree(scan_root, ignore_errors=True)
|
|
84
|
+
|
|
85
|
+
# Keep the app in its non-production code paths during tests (dev security
|
|
86
|
+
# headers, readable error messages, no HTTPS-only cookies).
|
|
87
|
+
os.environ.setdefault("APP_ENV", "development")
|
|
88
|
+
# Deterministic session secret so importing main.py never raises in dev.
|
|
89
|
+
os.environ.setdefault("AUTH_SECRET", "test-secret-not-for-production")
|
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
"""Integration test for the app's HTTP surface via httpx2's ASGI transport.
|
|
2
|
-
|
|
3
|
-
`/health` is the simplest always-on route and exercises the full middleware
|
|
4
|
-
stack (session, CSRF, security headers, auth) end to end. We drive the ASGI
|
|
5
|
-
app directly with httpx2's AsyncClient over its ASGITransport rather than
|
|
6
|
-
Starlette's TestClient.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import asyncio
|
|
10
|
-
|
|
11
|
-
import httpx2
|
|
12
|
-
|
|
13
|
-
import main
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
async def _get(path: str) -> httpx2.Response:
|
|
17
|
-
# ASGITransport speaks to the app in-process; no server/socket required.
|
|
18
|
-
transport = httpx2.ASGITransport(app=main.app)
|
|
19
|
-
async with httpx2.AsyncClient(
|
|
20
|
-
transport=transport, base_url="http://testserver"
|
|
21
|
-
) as client:
|
|
22
|
-
return await client.get(path)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _get_sync(path: str) -> httpx2.Response:
|
|
26
|
-
# Run the async request from a plain sync test (no pytest-asyncio needed).
|
|
27
|
-
return asyncio.run(_get(path))
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def test_health_returns_ok():
|
|
31
|
-
response = _get_sync("/health")
|
|
32
|
-
assert response.status_code == 200
|
|
33
|
-
assert response.json() == {"status": "ok"}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def test_health_carries_security_headers():
|
|
37
|
-
response = _get_sync("/health")
|
|
38
|
-
# SecurityHeadersMiddleware attaches baseline browser headers.
|
|
39
|
-
assert response.headers.get("x-content-type-options") == "nosniff"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_unknown_route_is_404():
|
|
43
|
-
response = _get_sync("/definitely-not-a-real-route")
|
|
44
|
-
assert response.status_code == 404
|
|
1
|
+
"""Integration test for the app's HTTP surface via httpx2's ASGI transport.
|
|
2
|
+
|
|
3
|
+
`/health` is the simplest always-on route and exercises the full middleware
|
|
4
|
+
stack (session, CSRF, security headers, auth) end to end. We drive the ASGI
|
|
5
|
+
app directly with httpx2's AsyncClient over its ASGITransport rather than
|
|
6
|
+
Starlette's TestClient.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
import httpx2
|
|
12
|
+
|
|
13
|
+
import main
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _get(path: str) -> httpx2.Response:
|
|
17
|
+
# ASGITransport speaks to the app in-process; no server/socket required.
|
|
18
|
+
transport = httpx2.ASGITransport(app=main.app)
|
|
19
|
+
async with httpx2.AsyncClient(
|
|
20
|
+
transport=transport, base_url="http://testserver"
|
|
21
|
+
) as client:
|
|
22
|
+
return await client.get(path)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_sync(path: str) -> httpx2.Response:
|
|
26
|
+
# Run the async request from a plain sync test (no pytest-asyncio needed).
|
|
27
|
+
return asyncio.run(_get(path))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_health_returns_ok():
|
|
31
|
+
response = _get_sync("/health")
|
|
32
|
+
assert response.status_code == 200
|
|
33
|
+
assert response.json() == {"status": "ok"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_health_carries_security_headers():
|
|
37
|
+
response = _get_sync("/health")
|
|
38
|
+
# SecurityHeadersMiddleware attaches baseline browser headers.
|
|
39
|
+
assert response.headers.get("x-content-type-options") == "nosniff"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_unknown_route_is_404():
|
|
43
|
+
response = _get_sync("/definitely-not-a-real-route")
|
|
44
|
+
assert response.status_code == 404
|
|
@@ -1,112 +1,112 @@
|
|
|
1
|
-
"""Unit tests for the pure helper functions in `main.py`.
|
|
2
|
-
|
|
3
|
-
These cover the app-owned logic that has no external dependencies: env
|
|
4
|
-
parsing, query-param coercion, and the component-deferral HTML transform.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import inspect
|
|
8
|
-
from typing import Optional
|
|
9
|
-
|
|
10
|
-
import main
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class TestEnvParsing:
|
|
14
|
-
def test_csv_env_splits_and_strips(self, monkeypatch):
|
|
15
|
-
monkeypatch.setenv("SOME_LIST", " a , b ,, c ")
|
|
16
|
-
assert main._csv_env("SOME_LIST") == ["a", "b", "c"]
|
|
17
|
-
|
|
18
|
-
def test_csv_env_missing_is_empty(self, monkeypatch):
|
|
19
|
-
monkeypatch.delenv("SOME_LIST", raising=False)
|
|
20
|
-
assert main._csv_env("SOME_LIST") == []
|
|
21
|
-
|
|
22
|
-
def test_bool_env_truthy_values(self, monkeypatch):
|
|
23
|
-
for raw in ("1", "true", "TRUE", "yes", "on"):
|
|
24
|
-
monkeypatch.setenv("FLAG", raw)
|
|
25
|
-
assert main._bool_env("FLAG") is True
|
|
26
|
-
|
|
27
|
-
def test_bool_env_falsy_and_default(self, monkeypatch):
|
|
28
|
-
monkeypatch.setenv("FLAG", "nope")
|
|
29
|
-
assert main._bool_env("FLAG") is False
|
|
30
|
-
monkeypatch.delenv("FLAG", raising=False)
|
|
31
|
-
assert main._bool_env("FLAG", default=True) is True
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class TestScalarCoercion:
|
|
35
|
-
def test_int_and_float(self):
|
|
36
|
-
assert main._coerce_scalar("42", int) == 42
|
|
37
|
-
assert main._coerce_scalar("3.5", float) == 3.5
|
|
38
|
-
|
|
39
|
-
def test_bool_variants(self):
|
|
40
|
-
assert main._coerce_scalar("yes", bool) is True
|
|
41
|
-
assert main._coerce_scalar("off", bool) is False
|
|
42
|
-
|
|
43
|
-
def test_none_passthrough(self):
|
|
44
|
-
assert main._coerce_scalar(None, int) is None
|
|
45
|
-
|
|
46
|
-
def test_bad_int_falls_back_to_string(self):
|
|
47
|
-
# Best-effort coercion: unparseable input returns the raw string.
|
|
48
|
-
assert main._coerce_scalar("not-a-number", int) == "not-a-number"
|
|
49
|
-
|
|
50
|
-
def test_optional_is_unwrapped(self):
|
|
51
|
-
assert main._coerce_scalar("7", Optional[int]) == 7
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class TestUnwrapOptional:
|
|
55
|
-
def test_unwraps_optional(self):
|
|
56
|
-
assert main._unwrap_optional(Optional[int]) is int
|
|
57
|
-
|
|
58
|
-
def test_leaves_plain_type(self):
|
|
59
|
-
assert main._unwrap_optional(str) is str
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
class TestQueryParamCoercion:
|
|
63
|
-
def _param(self, annotation):
|
|
64
|
-
return inspect.Parameter(
|
|
65
|
-
"x", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=annotation
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
def test_list_param(self):
|
|
69
|
-
from starlette.datastructures import QueryParams
|
|
70
|
-
from starlette.requests import Request
|
|
71
|
-
|
|
72
|
-
scope = {
|
|
73
|
-
"type": "http",
|
|
74
|
-
"query_string": b"x=1&x=2&x=3",
|
|
75
|
-
"headers": [],
|
|
76
|
-
}
|
|
77
|
-
request = Request(scope)
|
|
78
|
-
# sanity: the request exposes the multi-value list
|
|
79
|
-
assert isinstance(request.query_params, QueryParams)
|
|
80
|
-
result = main._coerce_query_param(request, "x", self._param(list[int]))
|
|
81
|
-
assert result == [1, 2, 3]
|
|
82
|
-
|
|
83
|
-
def test_scalar_param(self):
|
|
84
|
-
from starlette.requests import Request
|
|
85
|
-
|
|
86
|
-
scope = {"type": "http", "query_string": b"x=10", "headers": []}
|
|
87
|
-
request = Request(scope)
|
|
88
|
-
result = main._coerce_query_param(request, "x", self._param(int))
|
|
89
|
-
assert result == 10
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
class TestDeferComponentRoots:
|
|
93
|
-
def test_no_component_is_untouched(self):
|
|
94
|
-
html = "<div>hello</div>"
|
|
95
|
-
assert main.defer_component_roots(html) == html
|
|
96
|
-
|
|
97
|
-
def test_component_root_is_wrapped_in_template(self):
|
|
98
|
-
# The transform operates on a full document body, as in production.
|
|
99
|
-
html = "<body><div pp-component=\"abc\"><span>x</span></div></body>"
|
|
100
|
-
out = main.defer_component_roots(html)
|
|
101
|
-
assert "<template" in out
|
|
102
|
-
assert 'pp-component="abc"' in out
|
|
103
|
-
|
|
104
|
-
def test_finalize_html_defers_and_tags_scripts(self):
|
|
105
|
-
html = (
|
|
106
|
-
"<body><div pp-component=\"abc\">"
|
|
107
|
-
"<script>console.log(1)</script></div></body>"
|
|
108
|
-
)
|
|
109
|
-
out = main.finalize_html(html)
|
|
110
|
-
# transform_scripts retags author scripts before deferral.
|
|
111
|
-
assert 'type="text/pp"' in out
|
|
112
|
-
assert "<template" in out
|
|
1
|
+
"""Unit tests for the pure helper functions in `main.py`.
|
|
2
|
+
|
|
3
|
+
These cover the app-owned logic that has no external dependencies: env
|
|
4
|
+
parsing, query-param coercion, and the component-deferral HTML transform.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import inspect
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import main
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestEnvParsing:
|
|
14
|
+
def test_csv_env_splits_and_strips(self, monkeypatch):
|
|
15
|
+
monkeypatch.setenv("SOME_LIST", " a , b ,, c ")
|
|
16
|
+
assert main._csv_env("SOME_LIST") == ["a", "b", "c"]
|
|
17
|
+
|
|
18
|
+
def test_csv_env_missing_is_empty(self, monkeypatch):
|
|
19
|
+
monkeypatch.delenv("SOME_LIST", raising=False)
|
|
20
|
+
assert main._csv_env("SOME_LIST") == []
|
|
21
|
+
|
|
22
|
+
def test_bool_env_truthy_values(self, monkeypatch):
|
|
23
|
+
for raw in ("1", "true", "TRUE", "yes", "on"):
|
|
24
|
+
monkeypatch.setenv("FLAG", raw)
|
|
25
|
+
assert main._bool_env("FLAG") is True
|
|
26
|
+
|
|
27
|
+
def test_bool_env_falsy_and_default(self, monkeypatch):
|
|
28
|
+
monkeypatch.setenv("FLAG", "nope")
|
|
29
|
+
assert main._bool_env("FLAG") is False
|
|
30
|
+
monkeypatch.delenv("FLAG", raising=False)
|
|
31
|
+
assert main._bool_env("FLAG", default=True) is True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestScalarCoercion:
|
|
35
|
+
def test_int_and_float(self):
|
|
36
|
+
assert main._coerce_scalar("42", int) == 42
|
|
37
|
+
assert main._coerce_scalar("3.5", float) == 3.5
|
|
38
|
+
|
|
39
|
+
def test_bool_variants(self):
|
|
40
|
+
assert main._coerce_scalar("yes", bool) is True
|
|
41
|
+
assert main._coerce_scalar("off", bool) is False
|
|
42
|
+
|
|
43
|
+
def test_none_passthrough(self):
|
|
44
|
+
assert main._coerce_scalar(None, int) is None
|
|
45
|
+
|
|
46
|
+
def test_bad_int_falls_back_to_string(self):
|
|
47
|
+
# Best-effort coercion: unparseable input returns the raw string.
|
|
48
|
+
assert main._coerce_scalar("not-a-number", int) == "not-a-number"
|
|
49
|
+
|
|
50
|
+
def test_optional_is_unwrapped(self):
|
|
51
|
+
assert main._coerce_scalar("7", Optional[int]) == 7
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestUnwrapOptional:
|
|
55
|
+
def test_unwraps_optional(self):
|
|
56
|
+
assert main._unwrap_optional(Optional[int]) is int
|
|
57
|
+
|
|
58
|
+
def test_leaves_plain_type(self):
|
|
59
|
+
assert main._unwrap_optional(str) is str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestQueryParamCoercion:
|
|
63
|
+
def _param(self, annotation):
|
|
64
|
+
return inspect.Parameter(
|
|
65
|
+
"x", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=annotation
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def test_list_param(self):
|
|
69
|
+
from starlette.datastructures import QueryParams
|
|
70
|
+
from starlette.requests import Request
|
|
71
|
+
|
|
72
|
+
scope = {
|
|
73
|
+
"type": "http",
|
|
74
|
+
"query_string": b"x=1&x=2&x=3",
|
|
75
|
+
"headers": [],
|
|
76
|
+
}
|
|
77
|
+
request = Request(scope)
|
|
78
|
+
# sanity: the request exposes the multi-value list
|
|
79
|
+
assert isinstance(request.query_params, QueryParams)
|
|
80
|
+
result = main._coerce_query_param(request, "x", self._param(list[int]))
|
|
81
|
+
assert result == [1, 2, 3]
|
|
82
|
+
|
|
83
|
+
def test_scalar_param(self):
|
|
84
|
+
from starlette.requests import Request
|
|
85
|
+
|
|
86
|
+
scope = {"type": "http", "query_string": b"x=10", "headers": []}
|
|
87
|
+
request = Request(scope)
|
|
88
|
+
result = main._coerce_query_param(request, "x", self._param(int))
|
|
89
|
+
assert result == 10
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestDeferComponentRoots:
|
|
93
|
+
def test_no_component_is_untouched(self):
|
|
94
|
+
html = "<div>hello</div>"
|
|
95
|
+
assert main.defer_component_roots(html) == html
|
|
96
|
+
|
|
97
|
+
def test_component_root_is_wrapped_in_template(self):
|
|
98
|
+
# The transform operates on a full document body, as in production.
|
|
99
|
+
html = "<body><div pp-component=\"abc\"><span>x</span></div></body>"
|
|
100
|
+
out = main.defer_component_roots(html)
|
|
101
|
+
assert "<template" in out
|
|
102
|
+
assert 'pp-component="abc"' in out
|
|
103
|
+
|
|
104
|
+
def test_finalize_html_defers_and_tags_scripts(self):
|
|
105
|
+
html = (
|
|
106
|
+
"<body><div pp-component=\"abc\">"
|
|
107
|
+
"<script>console.log(1)</script></div></body>"
|
|
108
|
+
)
|
|
109
|
+
out = main.finalize_html(html)
|
|
110
|
+
# transform_scripts retags author scripts before deferral.
|
|
111
|
+
assert 'type="text/pp"' in out
|
|
112
|
+
assert "<template" in out
|