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
|
@@ -1,188 +1,188 @@
|
|
|
1
|
-
"""Robust static preview server for the exported `static/` build.
|
|
2
|
-
|
|
3
|
-
Replaces the fragile ``python -m http.server ... 8000`` one-liner, which crashes
|
|
4
|
-
with ``OSError: [Errno 98/10048] address already in use`` the moment port 8000 is
|
|
5
|
-
taken (a second preview, another dev tool, XAMPP, etc.). This script instead:
|
|
6
|
-
|
|
7
|
-
- serves ONLY the ``static/`` directory (no traversal outside it),
|
|
8
|
-
- binds to loopback ``127.0.0.1`` by default so the preview is NOT exposed to
|
|
9
|
-
the local network,
|
|
10
|
-
- auto-selects a free port: it tries the preferred port and walks upward until
|
|
11
|
-
one binds, so an occupied port never aborts the preview,
|
|
12
|
-
- fails fast with a clear message if ``static/`` was never built,
|
|
13
|
-
- shuts down cleanly on Ctrl+C.
|
|
14
|
-
|
|
15
|
-
Config via environment (all optional):
|
|
16
|
-
HOST bind address (default 127.0.0.1; use 0.0.0.0 to expose)
|
|
17
|
-
PORT preferred start port (default 8000)
|
|
18
|
-
PORT_TRIES how many ports to try (default 50)
|
|
19
|
-
|
|
20
|
-
Run via ``npm run static:serve`` (after ``npm run static`` has produced static/).
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
from __future__ import annotations
|
|
24
|
-
|
|
25
|
-
import errno
|
|
26
|
-
import os
|
|
27
|
-
import socket
|
|
28
|
-
import sys
|
|
29
|
-
from functools import partial
|
|
30
|
-
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
31
|
-
from pathlib import Path
|
|
32
|
-
|
|
33
|
-
ROOT = Path(__file__).resolve().parent.parent
|
|
34
|
-
STATIC_DIR = ROOT / "static"
|
|
35
|
-
|
|
36
|
-
DEFAULT_HOST = "127.0.0.1"
|
|
37
|
-
DEFAULT_PORT = 8000
|
|
38
|
-
DEFAULT_PORT_TRIES = 50
|
|
39
|
-
|
|
40
|
-
GREEN = "\033[32m"
|
|
41
|
-
YELLOW = "\033[33m"
|
|
42
|
-
RED = "\033[31m"
|
|
43
|
-
DIM = "\033[2m"
|
|
44
|
-
RESET = "\033[0m"
|
|
45
|
-
|
|
46
|
-
# Errnos that mean "this port is taken, try the next one" across platforms.
|
|
47
|
-
# EADDRINUSE covers POSIX (98) and Windows WSAEADDRINUSE (10048);
|
|
48
|
-
# EACCES/WSAEACCES can appear on Windows when a port is held by another owner.
|
|
49
|
-
_PORT_TAKEN = {errno.EADDRINUSE, errno.EACCES}
|
|
50
|
-
if hasattr(errno, "WSAEADDRINUSE"): # Windows-only alias
|
|
51
|
-
_PORT_TAKEN.add(errno.WSAEADDRINUSE) # type: ignore[attr-defined]
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class SecureThreadingHTTPServer(ThreadingHTTPServer):
|
|
55
|
-
"""Threaded server with reliable "is this port really free?" semantics.
|
|
56
|
-
|
|
57
|
-
``allow_reuse_address`` maps to ``SO_REUSEADDR``. On Windows that flag lets a
|
|
58
|
-
NEW socket bind a port another socket is ACTIVELY listening on, which would
|
|
59
|
-
make our probe silently "succeed" onto an occupied port and collide. So we
|
|
60
|
-
disable it here: bind() then genuinely fails on a taken port and our
|
|
61
|
-
port-walk loop can move on. (On POSIX SO_REUSEADDR only affects TIME_WAIT
|
|
62
|
-
sockets, but keeping it off is harmless and keeps behavior identical.)
|
|
63
|
-
"""
|
|
64
|
-
|
|
65
|
-
allow_reuse_address = False
|
|
66
|
-
daemon_threads = True
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
class StaticRequestHandler(SimpleHTTPRequestHandler):
|
|
70
|
-
"""Serve static/ with light hardening and quieter, useful logging.
|
|
71
|
-
|
|
72
|
-
SimpleHTTPRequestHandler already normalizes and confines paths to the served
|
|
73
|
-
``directory`` (no ``..`` traversal), so this only adds conservative response
|
|
74
|
-
headers appropriate for a local static preview.
|
|
75
|
-
"""
|
|
76
|
-
|
|
77
|
-
def end_headers(self) -> None:
|
|
78
|
-
self.send_header("X-Content-Type-Options", "nosniff")
|
|
79
|
-
self.send_header("Cache-Control", "no-store")
|
|
80
|
-
super().end_headers()
|
|
81
|
-
|
|
82
|
-
def log_message(self, format: str, *args) -> None:
|
|
83
|
-
sys.stderr.write(f"{DIM} {self.address_string()} - {format % args}{RESET}\n")
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _looks_built(static_dir: Path) -> bool:
|
|
87
|
-
"""True only if static/ exists and holds a real export (an index.html)."""
|
|
88
|
-
return static_dir.is_dir() and (static_dir / "index.html").is_file()
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def _env_int(name: str, default: int) -> int:
|
|
92
|
-
raw = os.environ.get(name, "").strip()
|
|
93
|
-
if not raw:
|
|
94
|
-
return default
|
|
95
|
-
try:
|
|
96
|
-
value = int(raw)
|
|
97
|
-
except ValueError:
|
|
98
|
-
print(f"{YELLOW} Ignoring invalid {name}={raw!r}; using {default}.{RESET}")
|
|
99
|
-
return default
|
|
100
|
-
return value
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def _serve(host: str, start_port: int, tries: int) -> int:
|
|
104
|
-
handler = partial(StaticRequestHandler, directory=str(STATIC_DIR))
|
|
105
|
-
|
|
106
|
-
last_error: OSError | None = None
|
|
107
|
-
for offset in range(max(1, tries)):
|
|
108
|
-
port = start_port + offset
|
|
109
|
-
if port > 65535:
|
|
110
|
-
break
|
|
111
|
-
try:
|
|
112
|
-
httpd = SecureThreadingHTTPServer((host, port), handler)
|
|
113
|
-
except OSError as exc:
|
|
114
|
-
if exc.errno in _PORT_TAKEN:
|
|
115
|
-
last_error = exc
|
|
116
|
-
if offset == 0:
|
|
117
|
-
print(
|
|
118
|
-
f"{YELLOW} Port {start_port} is busy; searching for a free "
|
|
119
|
-
f"port...{RESET}"
|
|
120
|
-
)
|
|
121
|
-
continue
|
|
122
|
-
# A different error (bad host, permissions, etc.) is not recoverable
|
|
123
|
-
# by trying another port -- surface it.
|
|
124
|
-
print(f"{RED} Failed to bind {host}:{port} -- {exc}{RESET}")
|
|
125
|
-
return 1
|
|
126
|
-
|
|
127
|
-
with httpd:
|
|
128
|
-
shown_host = "localhost" if host in ("127.0.0.1", "0.0.0.0") else host
|
|
129
|
-
url = f"http://{shown_host}:{port}"
|
|
130
|
-
if port != start_port:
|
|
131
|
-
print(
|
|
132
|
-
f"{YELLOW} Default port {start_port} was occupied; "
|
|
133
|
-
f"using {port} instead.{RESET}"
|
|
134
|
-
)
|
|
135
|
-
print(f"\n{GREEN}Serving{RESET} {DIM}{STATIC_DIR}{RESET}")
|
|
136
|
-
print(f"{GREEN} -> {url}{RESET}")
|
|
137
|
-
if host == "0.0.0.0":
|
|
138
|
-
lan = _lan_hint(port)
|
|
139
|
-
if lan:
|
|
140
|
-
print(f"{DIM} -> {lan} (exposed on your network){RESET}")
|
|
141
|
-
print(f"{DIM} Press Ctrl+C to stop.{RESET}\n")
|
|
142
|
-
try:
|
|
143
|
-
httpd.serve_forever()
|
|
144
|
-
except KeyboardInterrupt:
|
|
145
|
-
print(f"\n{DIM} Stopped.{RESET}")
|
|
146
|
-
return 0
|
|
147
|
-
|
|
148
|
-
ports = f"{start_port}-{start_port + max(1, tries) - 1}"
|
|
149
|
-
print(
|
|
150
|
-
f"{RED} Could not find a free port in {ports} on {host}.{RESET}\n"
|
|
151
|
-
f"{DIM} Last error: {last_error}. Free a port or set PORT to another "
|
|
152
|
-
f"start value.{RESET}"
|
|
153
|
-
)
|
|
154
|
-
return 1
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def _lan_hint(port: int) -> str | None:
|
|
158
|
-
"""Best-effort LAN URL to show only when the user opted into 0.0.0.0."""
|
|
159
|
-
try:
|
|
160
|
-
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
161
|
-
s.connect(("8.8.8.8", 80))
|
|
162
|
-
ip = s.getsockname()[0]
|
|
163
|
-
return f"http://{ip}:{port}"
|
|
164
|
-
except OSError:
|
|
165
|
-
return None
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def main() -> int:
|
|
169
|
-
if not _looks_built(STATIC_DIR):
|
|
170
|
-
print(
|
|
171
|
-
f"{RED}No static build found at {STATIC_DIR}.{RESET}\n"
|
|
172
|
-
f"{DIM} Build it first: {RESET}{GREEN}npm run static{RESET}"
|
|
173
|
-
)
|
|
174
|
-
return 1
|
|
175
|
-
|
|
176
|
-
host = os.environ.get("HOST", "").strip() or DEFAULT_HOST
|
|
177
|
-
start_port = _env_int("PORT", DEFAULT_PORT)
|
|
178
|
-
tries = _env_int("PORT_TRIES", DEFAULT_PORT_TRIES)
|
|
179
|
-
|
|
180
|
-
if not (1 <= start_port <= 65535):
|
|
181
|
-
print(f"{RED} PORT must be 1-65535 (got {start_port}).{RESET}")
|
|
182
|
-
return 1
|
|
183
|
-
|
|
184
|
-
return _serve(host, start_port, tries)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if __name__ == "__main__":
|
|
188
|
-
raise SystemExit(main())
|
|
1
|
+
"""Robust static preview server for the exported `static/` build.
|
|
2
|
+
|
|
3
|
+
Replaces the fragile ``python -m http.server ... 8000`` one-liner, which crashes
|
|
4
|
+
with ``OSError: [Errno 98/10048] address already in use`` the moment port 8000 is
|
|
5
|
+
taken (a second preview, another dev tool, XAMPP, etc.). This script instead:
|
|
6
|
+
|
|
7
|
+
- serves ONLY the ``static/`` directory (no traversal outside it),
|
|
8
|
+
- binds to loopback ``127.0.0.1`` by default so the preview is NOT exposed to
|
|
9
|
+
the local network,
|
|
10
|
+
- auto-selects a free port: it tries the preferred port and walks upward until
|
|
11
|
+
one binds, so an occupied port never aborts the preview,
|
|
12
|
+
- fails fast with a clear message if ``static/`` was never built,
|
|
13
|
+
- shuts down cleanly on Ctrl+C.
|
|
14
|
+
|
|
15
|
+
Config via environment (all optional):
|
|
16
|
+
HOST bind address (default 127.0.0.1; use 0.0.0.0 to expose)
|
|
17
|
+
PORT preferred start port (default 8000)
|
|
18
|
+
PORT_TRIES how many ports to try (default 50)
|
|
19
|
+
|
|
20
|
+
Run via ``npm run static:serve`` (after ``npm run static`` has produced static/).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import errno
|
|
26
|
+
import os
|
|
27
|
+
import socket
|
|
28
|
+
import sys
|
|
29
|
+
from functools import partial
|
|
30
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
34
|
+
STATIC_DIR = ROOT / "static"
|
|
35
|
+
|
|
36
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
37
|
+
DEFAULT_PORT = 8000
|
|
38
|
+
DEFAULT_PORT_TRIES = 50
|
|
39
|
+
|
|
40
|
+
GREEN = "\033[32m"
|
|
41
|
+
YELLOW = "\033[33m"
|
|
42
|
+
RED = "\033[31m"
|
|
43
|
+
DIM = "\033[2m"
|
|
44
|
+
RESET = "\033[0m"
|
|
45
|
+
|
|
46
|
+
# Errnos that mean "this port is taken, try the next one" across platforms.
|
|
47
|
+
# EADDRINUSE covers POSIX (98) and Windows WSAEADDRINUSE (10048);
|
|
48
|
+
# EACCES/WSAEACCES can appear on Windows when a port is held by another owner.
|
|
49
|
+
_PORT_TAKEN = {errno.EADDRINUSE, errno.EACCES}
|
|
50
|
+
if hasattr(errno, "WSAEADDRINUSE"): # Windows-only alias
|
|
51
|
+
_PORT_TAKEN.add(errno.WSAEADDRINUSE) # type: ignore[attr-defined]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SecureThreadingHTTPServer(ThreadingHTTPServer):
|
|
55
|
+
"""Threaded server with reliable "is this port really free?" semantics.
|
|
56
|
+
|
|
57
|
+
``allow_reuse_address`` maps to ``SO_REUSEADDR``. On Windows that flag lets a
|
|
58
|
+
NEW socket bind a port another socket is ACTIVELY listening on, which would
|
|
59
|
+
make our probe silently "succeed" onto an occupied port and collide. So we
|
|
60
|
+
disable it here: bind() then genuinely fails on a taken port and our
|
|
61
|
+
port-walk loop can move on. (On POSIX SO_REUSEADDR only affects TIME_WAIT
|
|
62
|
+
sockets, but keeping it off is harmless and keeps behavior identical.)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
allow_reuse_address = False
|
|
66
|
+
daemon_threads = True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class StaticRequestHandler(SimpleHTTPRequestHandler):
|
|
70
|
+
"""Serve static/ with light hardening and quieter, useful logging.
|
|
71
|
+
|
|
72
|
+
SimpleHTTPRequestHandler already normalizes and confines paths to the served
|
|
73
|
+
``directory`` (no ``..`` traversal), so this only adds conservative response
|
|
74
|
+
headers appropriate for a local static preview.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def end_headers(self) -> None:
|
|
78
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
79
|
+
self.send_header("Cache-Control", "no-store")
|
|
80
|
+
super().end_headers()
|
|
81
|
+
|
|
82
|
+
def log_message(self, format: str, *args) -> None:
|
|
83
|
+
sys.stderr.write(f"{DIM} {self.address_string()} - {format % args}{RESET}\n")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _looks_built(static_dir: Path) -> bool:
|
|
87
|
+
"""True only if static/ exists and holds a real export (an index.html)."""
|
|
88
|
+
return static_dir.is_dir() and (static_dir / "index.html").is_file()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _env_int(name: str, default: int) -> int:
|
|
92
|
+
raw = os.environ.get(name, "").strip()
|
|
93
|
+
if not raw:
|
|
94
|
+
return default
|
|
95
|
+
try:
|
|
96
|
+
value = int(raw)
|
|
97
|
+
except ValueError:
|
|
98
|
+
print(f"{YELLOW} Ignoring invalid {name}={raw!r}; using {default}.{RESET}")
|
|
99
|
+
return default
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _serve(host: str, start_port: int, tries: int) -> int:
|
|
104
|
+
handler = partial(StaticRequestHandler, directory=str(STATIC_DIR))
|
|
105
|
+
|
|
106
|
+
last_error: OSError | None = None
|
|
107
|
+
for offset in range(max(1, tries)):
|
|
108
|
+
port = start_port + offset
|
|
109
|
+
if port > 65535:
|
|
110
|
+
break
|
|
111
|
+
try:
|
|
112
|
+
httpd = SecureThreadingHTTPServer((host, port), handler)
|
|
113
|
+
except OSError as exc:
|
|
114
|
+
if exc.errno in _PORT_TAKEN:
|
|
115
|
+
last_error = exc
|
|
116
|
+
if offset == 0:
|
|
117
|
+
print(
|
|
118
|
+
f"{YELLOW} Port {start_port} is busy; searching for a free "
|
|
119
|
+
f"port...{RESET}"
|
|
120
|
+
)
|
|
121
|
+
continue
|
|
122
|
+
# A different error (bad host, permissions, etc.) is not recoverable
|
|
123
|
+
# by trying another port -- surface it.
|
|
124
|
+
print(f"{RED} Failed to bind {host}:{port} -- {exc}{RESET}")
|
|
125
|
+
return 1
|
|
126
|
+
|
|
127
|
+
with httpd:
|
|
128
|
+
shown_host = "localhost" if host in ("127.0.0.1", "0.0.0.0") else host
|
|
129
|
+
url = f"http://{shown_host}:{port}"
|
|
130
|
+
if port != start_port:
|
|
131
|
+
print(
|
|
132
|
+
f"{YELLOW} Default port {start_port} was occupied; "
|
|
133
|
+
f"using {port} instead.{RESET}"
|
|
134
|
+
)
|
|
135
|
+
print(f"\n{GREEN}Serving{RESET} {DIM}{STATIC_DIR}{RESET}")
|
|
136
|
+
print(f"{GREEN} -> {url}{RESET}")
|
|
137
|
+
if host == "0.0.0.0":
|
|
138
|
+
lan = _lan_hint(port)
|
|
139
|
+
if lan:
|
|
140
|
+
print(f"{DIM} -> {lan} (exposed on your network){RESET}")
|
|
141
|
+
print(f"{DIM} Press Ctrl+C to stop.{RESET}\n")
|
|
142
|
+
try:
|
|
143
|
+
httpd.serve_forever()
|
|
144
|
+
except KeyboardInterrupt:
|
|
145
|
+
print(f"\n{DIM} Stopped.{RESET}")
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
ports = f"{start_port}-{start_port + max(1, tries) - 1}"
|
|
149
|
+
print(
|
|
150
|
+
f"{RED} Could not find a free port in {ports} on {host}.{RESET}\n"
|
|
151
|
+
f"{DIM} Last error: {last_error}. Free a port or set PORT to another "
|
|
152
|
+
f"start value.{RESET}"
|
|
153
|
+
)
|
|
154
|
+
return 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _lan_hint(port: int) -> str | None:
|
|
158
|
+
"""Best-effort LAN URL to show only when the user opted into 0.0.0.0."""
|
|
159
|
+
try:
|
|
160
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
161
|
+
s.connect(("8.8.8.8", 80))
|
|
162
|
+
ip = s.getsockname()[0]
|
|
163
|
+
return f"http://{ip}:{port}"
|
|
164
|
+
except OSError:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main() -> int:
|
|
169
|
+
if not _looks_built(STATIC_DIR):
|
|
170
|
+
print(
|
|
171
|
+
f"{RED}No static build found at {STATIC_DIR}.{RESET}\n"
|
|
172
|
+
f"{DIM} Build it first: {RESET}{GREEN}npm run static{RESET}"
|
|
173
|
+
)
|
|
174
|
+
return 1
|
|
175
|
+
|
|
176
|
+
host = os.environ.get("HOST", "").strip() or DEFAULT_HOST
|
|
177
|
+
start_port = _env_int("PORT", DEFAULT_PORT)
|
|
178
|
+
tries = _env_int("PORT_TRIES", DEFAULT_PORT_TRIES)
|
|
179
|
+
|
|
180
|
+
if not (1 <= start_port <= 65535):
|
|
181
|
+
print(f"{RED} PORT must be 1-65535 (got {start_port}).{RESET}")
|
|
182
|
+
return 1
|
|
183
|
+
|
|
184
|
+
return _serve(host, start_port, tries)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
raise SystemExit(main())
|
package/dist/tests/README.md
CHANGED
|
@@ -1,135 +1,135 @@
|
|
|
1
|
-
# App tests & quality gate
|
|
2
|
-
|
|
3
|
-
Type check + lint + template lint + tests for the **application** code
|
|
4
|
-
(`main.py`, `src/**`, and authored markup) — not the Caspian framework under
|
|
5
|
-
`.venv/` or `node_modules/`.
|
|
6
|
-
|
|
7
|
-
## The command
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npm run check
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
That is the single production gate. It runs **pyright** (types), **ruff**
|
|
14
|
-
(lint), **templates** (markup lint), and **pytest** (tests) in one pass, prints
|
|
15
|
-
every problem as `path:line:col [tool:code] message`, and exits non-zero on
|
|
16
|
-
failure — so CI, a pre-commit hook, or an agent is told exactly which file and
|
|
17
|
-
location to fix.
|
|
18
|
-
|
|
19
|
-
While debugging you can narrow to one tool:
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
uv run python settings/check.py --only pyright # or ruff / templates / pytest
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
## The `templates` check
|
|
26
|
-
|
|
27
|
-
`settings/check_templates.py` scans `src/**/*.html` and the markup inside
|
|
28
|
-
single-file Python components for JSX and directives PulsePoint does not have.
|
|
29
|
-
|
|
30
|
-
PulsePoint borrows React's hook API inside `<script>` and React's component
|
|
31
|
-
decomposition — never React's markup syntax. Before this check existed, the
|
|
32
|
-
whole `.html` surface was unvalidated, and two failures shipped repeatedly:
|
|
33
|
-
|
|
34
|
-
- `{users.map(user => (<tr/>))}` renders one literal row plus stray text.
|
|
35
|
-
- `class={...}` (unquoted) is **invalid HTML** — the parser shreds the element,
|
|
36
|
-
the component root never compiles, and the route serves a blank page with *no
|
|
37
|
-
console error at all*.
|
|
38
|
-
|
|
39
|
-
It skips `<script>`, `<pre>`/`<code>`, and HTML comments, so real component
|
|
40
|
-
JavaScript (`rows.map(...)` is correct there) and documentation samples do not
|
|
41
|
-
trip it. Run it alone with:
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
uv run python settings/check_templates.py
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Its own coverage lives in `tests/test_check_templates.py`, which asserts both
|
|
48
|
-
directions — every JSX shape is caught, and correct markup stays silent — plus a
|
|
49
|
-
repo-wide assertion that `src/` is currently clean.
|
|
50
|
-
|
|
51
|
-
## Browser errors in the dev terminal — and in a file
|
|
52
|
-
|
|
53
|
-
`npm run dev` forwards PulsePoint's browser-side `[PP-ERROR]` / `[PP-WARN]`
|
|
54
|
-
output, uncaught errors, and unhandled rejections into the terminal, so a broken
|
|
55
|
-
route is visible without opening DevTools. See the `AGENTS.md` entry for how the
|
|
56
|
-
pieces fit (`settings/dev-log-bridge.ts` + `_inject_dev_console_bridge` in
|
|
57
|
-
`main.py`). It is development-only: the injecting branch is gated on
|
|
58
|
-
`CASPIAN_BROWSER_SYNC_PORT`, which only the dev stack sets.
|
|
59
|
-
|
|
60
|
-
The same events are appended to `.casp/browser-log.jsonl`, because stdout only
|
|
61
|
-
reaches whoever owns that terminal. Read it with:
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
npm run logs
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
`npm run check` prints the same digest at the end of its run, but never lets it
|
|
68
|
-
change the exit code — whether a route has been exercised depends on someone
|
|
69
|
-
opening a browser, and a gate that flaky gets ignored. Use `--fail-on-error` if
|
|
70
|
-
you want a non-zero exit in a script you control.
|
|
71
|
-
|
|
72
|
-
The log records **successful page loads too**, which is what makes it safe to
|
|
73
|
-
trust: a route's status is whatever happened during its most recent load, so a
|
|
74
|
-
fixed error stops being reported after one clean reload, and an empty log reads
|
|
75
|
-
as "nothing observed" rather than "healthy".
|
|
76
|
-
|
|
77
|
-
A reload only proves what a reload re-runs. Errors are classified by how long
|
|
78
|
-
after their page load they arrived: a **mount** error is cleared by a later load,
|
|
79
|
-
an **interaction** error (a click handler throwing seconds later) is not — it
|
|
80
|
-
carries forward as `NEEDS RECHECK` until you repeat the interaction. Reporting
|
|
81
|
-
those as clean is how a live bug gets signed off.
|
|
82
|
-
|
|
83
|
-
Every source change **compacts** the log to the session header, a `restart`
|
|
84
|
-
marker, and the errors still open, so a dev session running for hours cannot grow
|
|
85
|
-
an unbounded file. **Don't diagnose from the raw JSONL** — it is history, not
|
|
86
|
-
state. `npm run logs` derives the current status and costs the same tokens
|
|
87
|
-
regardless of session length. An error with no matching load — a tab left open
|
|
88
|
-
across a dev restart — is reported as `UNCONFIRMED` rather than as a fresh
|
|
89
|
-
failure. Coverage is in `tests/test_browser_log.py`.
|
|
90
|
-
|
|
91
|
-
## Auto-fixing lint issues
|
|
92
|
-
|
|
93
|
-
`npm run check` only **reports**. To auto-fix the ruff findings it lists, run:
|
|
94
|
-
|
|
95
|
-
```bash
|
|
96
|
-
npm run check:fix
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
That runs `settings/fix.py` (safely fixes lint issues — dead imports, redundant
|
|
100
|
-
code, etc.) and then re-runs the full gate so you see what's left. Type errors
|
|
101
|
-
(pyright) and failing tests (pytest) are never auto-fixed — fix those at the
|
|
102
|
-
reported `path:line:col`.
|
|
103
|
-
|
|
104
|
-
### How unused-import (F401) removal stays safe
|
|
105
|
-
|
|
106
|
-
Removing "unused" imports is the one fix that is dangerous in this app. Caspian
|
|
107
|
-
single-file components import their children and then use them only as `<x-*>`
|
|
108
|
-
tags inside `html(...)`/`render_html(...)` template strings (e.g. `from .Dialog
|
|
109
|
-
import DialogContent` → `<x-dialog-content>`). Ruff can't parse the template, so
|
|
110
|
-
it sees the import as unused — but casp resolves the tag from the module's
|
|
111
|
-
globals at render time, so deleting it breaks the page.
|
|
112
|
-
|
|
113
|
-
Two layers keep this safe, so `check:fix` still cleans real dead imports:
|
|
114
|
-
|
|
115
|
-
- **A raw `ruff check --fix` never deletes any import.** `F401` is marked
|
|
116
|
-
`unfixable` in `pyproject.toml`, so even if someone runs ruff directly, no
|
|
117
|
-
component import is ever stripped.
|
|
118
|
-
- **`npm run check:fix` removes only genuinely dead imports.** `settings/fix.py`
|
|
119
|
-
asks ruff which files have an `F401`, skips any file that contains an import
|
|
120
|
-
used as an `<x-*>` tag (leaving those whole), and removes dead imports from the
|
|
121
|
-
rest via an isolated ruff run. Component-guarded files are left for the gate to
|
|
122
|
-
report, so you decide by hand there.
|
|
123
|
-
|
|
124
|
-
`settings/check.py` also suppresses the `F401` *reports* whose symbol is used as
|
|
125
|
-
an `<x-*>` tag, so **the gate fails only on genuinely dead imports**. The
|
|
126
|
-
`<x-*>`-tag detection is shared between the fixer and the gate in
|
|
127
|
-
`settings/_component_imports.py`.
|
|
128
|
-
|
|
129
|
-
## Tools (Python dev group in `pyproject.toml`)
|
|
130
|
-
|
|
131
|
-
- **pyright** — type checker. Config in `[tool.pyright]`: `include = ["main.py", "src", "settings/*.py"]` with `exclude = [".venv", "node_modules", "**/__pycache__"]`, so it checks `main.py`, all of `src` (including the generated `src/lib/prisma/**` ORM), and the top-level `settings/*.py` tooling scripts (mirroring ruff's `include`). Pylance reads the same config, so the IDE and `npm run check` agree.
|
|
132
|
-
- **ruff** — linter. Config in `[tool.ruff]`; correctness-focused rules.
|
|
133
|
-
- **pytest** — test runner. Tests live in `tests/`.
|
|
134
|
-
|
|
135
|
-
Install/refresh them with `uv sync --group dev`.
|
|
1
|
+
# App tests & quality gate
|
|
2
|
+
|
|
3
|
+
Type check + lint + template lint + tests for the **application** code
|
|
4
|
+
(`main.py`, `src/**`, and authored markup) — not the Caspian framework under
|
|
5
|
+
`.venv/` or `node_modules/`.
|
|
6
|
+
|
|
7
|
+
## The command
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm run check
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
That is the single production gate. It runs **pyright** (types), **ruff**
|
|
14
|
+
(lint), **templates** (markup lint), and **pytest** (tests) in one pass, prints
|
|
15
|
+
every problem as `path:line:col [tool:code] message`, and exits non-zero on
|
|
16
|
+
failure — so CI, a pre-commit hook, or an agent is told exactly which file and
|
|
17
|
+
location to fix.
|
|
18
|
+
|
|
19
|
+
While debugging you can narrow to one tool:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv run python settings/check.py --only pyright # or ruff / templates / pytest
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## The `templates` check
|
|
26
|
+
|
|
27
|
+
`settings/check_templates.py` scans `src/**/*.html` and the markup inside
|
|
28
|
+
single-file Python components for JSX and directives PulsePoint does not have.
|
|
29
|
+
|
|
30
|
+
PulsePoint borrows React's hook API inside `<script>` and React's component
|
|
31
|
+
decomposition — never React's markup syntax. Before this check existed, the
|
|
32
|
+
whole `.html` surface was unvalidated, and two failures shipped repeatedly:
|
|
33
|
+
|
|
34
|
+
- `{users.map(user => (<tr/>))}` renders one literal row plus stray text.
|
|
35
|
+
- `class={...}` (unquoted) is **invalid HTML** — the parser shreds the element,
|
|
36
|
+
the component root never compiles, and the route serves a blank page with *no
|
|
37
|
+
console error at all*.
|
|
38
|
+
|
|
39
|
+
It skips `<script>`, `<pre>`/`<code>`, and HTML comments, so real component
|
|
40
|
+
JavaScript (`rows.map(...)` is correct there) and documentation samples do not
|
|
41
|
+
trip it. Run it alone with:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv run python settings/check_templates.py
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Its own coverage lives in `tests/test_check_templates.py`, which asserts both
|
|
48
|
+
directions — every JSX shape is caught, and correct markup stays silent — plus a
|
|
49
|
+
repo-wide assertion that `src/` is currently clean.
|
|
50
|
+
|
|
51
|
+
## Browser errors in the dev terminal — and in a file
|
|
52
|
+
|
|
53
|
+
`npm run dev` forwards PulsePoint's browser-side `[PP-ERROR]` / `[PP-WARN]`
|
|
54
|
+
output, uncaught errors, and unhandled rejections into the terminal, so a broken
|
|
55
|
+
route is visible without opening DevTools. See the `AGENTS.md` entry for how the
|
|
56
|
+
pieces fit (`settings/dev-log-bridge.ts` + `_inject_dev_console_bridge` in
|
|
57
|
+
`main.py`). It is development-only: the injecting branch is gated on
|
|
58
|
+
`CASPIAN_BROWSER_SYNC_PORT`, which only the dev stack sets.
|
|
59
|
+
|
|
60
|
+
The same events are appended to `.casp/browser-log.jsonl`, because stdout only
|
|
61
|
+
reaches whoever owns that terminal. Read it with:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm run logs
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`npm run check` prints the same digest at the end of its run, but never lets it
|
|
68
|
+
change the exit code — whether a route has been exercised depends on someone
|
|
69
|
+
opening a browser, and a gate that flaky gets ignored. Use `--fail-on-error` if
|
|
70
|
+
you want a non-zero exit in a script you control.
|
|
71
|
+
|
|
72
|
+
The log records **successful page loads too**, which is what makes it safe to
|
|
73
|
+
trust: a route's status is whatever happened during its most recent load, so a
|
|
74
|
+
fixed error stops being reported after one clean reload, and an empty log reads
|
|
75
|
+
as "nothing observed" rather than "healthy".
|
|
76
|
+
|
|
77
|
+
A reload only proves what a reload re-runs. Errors are classified by how long
|
|
78
|
+
after their page load they arrived: a **mount** error is cleared by a later load,
|
|
79
|
+
an **interaction** error (a click handler throwing seconds later) is not — it
|
|
80
|
+
carries forward as `NEEDS RECHECK` until you repeat the interaction. Reporting
|
|
81
|
+
those as clean is how a live bug gets signed off.
|
|
82
|
+
|
|
83
|
+
Every source change **compacts** the log to the session header, a `restart`
|
|
84
|
+
marker, and the errors still open, so a dev session running for hours cannot grow
|
|
85
|
+
an unbounded file. **Don't diagnose from the raw JSONL** — it is history, not
|
|
86
|
+
state. `npm run logs` derives the current status and costs the same tokens
|
|
87
|
+
regardless of session length. An error with no matching load — a tab left open
|
|
88
|
+
across a dev restart — is reported as `UNCONFIRMED` rather than as a fresh
|
|
89
|
+
failure. Coverage is in `tests/test_browser_log.py`.
|
|
90
|
+
|
|
91
|
+
## Auto-fixing lint issues
|
|
92
|
+
|
|
93
|
+
`npm run check` only **reports**. To auto-fix the ruff findings it lists, run:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npm run check:fix
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
That runs `settings/fix.py` (safely fixes lint issues — dead imports, redundant
|
|
100
|
+
code, etc.) and then re-runs the full gate so you see what's left. Type errors
|
|
101
|
+
(pyright) and failing tests (pytest) are never auto-fixed — fix those at the
|
|
102
|
+
reported `path:line:col`.
|
|
103
|
+
|
|
104
|
+
### How unused-import (F401) removal stays safe
|
|
105
|
+
|
|
106
|
+
Removing "unused" imports is the one fix that is dangerous in this app. Caspian
|
|
107
|
+
single-file components import their children and then use them only as `<x-*>`
|
|
108
|
+
tags inside `html(...)`/`render_html(...)` template strings (e.g. `from .Dialog
|
|
109
|
+
import DialogContent` → `<x-dialog-content>`). Ruff can't parse the template, so
|
|
110
|
+
it sees the import as unused — but casp resolves the tag from the module's
|
|
111
|
+
globals at render time, so deleting it breaks the page.
|
|
112
|
+
|
|
113
|
+
Two layers keep this safe, so `check:fix` still cleans real dead imports:
|
|
114
|
+
|
|
115
|
+
- **A raw `ruff check --fix` never deletes any import.** `F401` is marked
|
|
116
|
+
`unfixable` in `pyproject.toml`, so even if someone runs ruff directly, no
|
|
117
|
+
component import is ever stripped.
|
|
118
|
+
- **`npm run check:fix` removes only genuinely dead imports.** `settings/fix.py`
|
|
119
|
+
asks ruff which files have an `F401`, skips any file that contains an import
|
|
120
|
+
used as an `<x-*>` tag (leaving those whole), and removes dead imports from the
|
|
121
|
+
rest via an isolated ruff run. Component-guarded files are left for the gate to
|
|
122
|
+
report, so you decide by hand there.
|
|
123
|
+
|
|
124
|
+
`settings/check.py` also suppresses the `F401` *reports* whose symbol is used as
|
|
125
|
+
an `<x-*>` tag, so **the gate fails only on genuinely dead imports**. The
|
|
126
|
+
`<x-*>`-tag detection is shared between the fixer and the gate in
|
|
127
|
+
`settings/_component_imports.py`.
|
|
128
|
+
|
|
129
|
+
## Tools (Python dev group in `pyproject.toml`)
|
|
130
|
+
|
|
131
|
+
- **pyright** — type checker. Config in `[tool.pyright]`: `include = ["main.py", "src", "settings/*.py"]` with `exclude = [".venv", "node_modules", "**/__pycache__"]`, so it checks `main.py`, all of `src` (including the generated `src/lib/prisma/**` ORM), and the top-level `settings/*.py` tooling scripts (mirroring ruff's `include`). Pylance reads the same config, so the IDE and `npm run check` agree.
|
|
132
|
+
- **ruff** — linter. Config in `[tool.ruff]`; correctness-focused rules.
|
|
133
|
+
- **pytest** — test runner. Tests live in `tests/`.
|
|
134
|
+
|
|
135
|
+
Install/refresh them with `uv sync --group dev`.
|