mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-12 09:52:19 +02:00
Merge lab into refactor/split-style-css
This commit is contained in:
@@ -36,6 +36,19 @@ IS_APPLE_SILICON = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── procfs ──────────────────────────────────────────────────────────────────
|
||||||
|
# Linux exposes one directory per pid under /proc; macOS and Windows have no
|
||||||
|
# procfs at all. Any code that walks it must skip the walk rather than raise.
|
||||||
|
# Kept as a module attribute so both branches stay testable on either kind of
|
||||||
|
# host.
|
||||||
|
PROC_ROOT = Path("/proc")
|
||||||
|
|
||||||
|
|
||||||
|
def has_procfs() -> bool:
|
||||||
|
"""True when the host exposes a procfs pid tree that can be scanned."""
|
||||||
|
return PROC_ROOT.is_dir()
|
||||||
|
|
||||||
|
|
||||||
# ── File permissions ────────────────────────────────────────────────────────
|
# ── File permissions ────────────────────────────────────────────────────────
|
||||||
def safe_chmod(path, mode: int) -> bool:
|
def safe_chmod(path, mode: int) -> bool:
|
||||||
"""``os.chmod`` that is a harmless no-op on Windows.
|
"""``os.chmod`` that is a harmless no-op on Windows.
|
||||||
|
|||||||
@@ -181,10 +181,17 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
|||||||
if transport == "http" and not url:
|
if transport == "http" and not url:
|
||||||
raise HTTPException(400, "url is required for HTTP transport")
|
raise HTTPException(400, "url is required for HTTP transport")
|
||||||
|
|
||||||
# Parse JSON fields
|
# Parse JSON fields. args is not defaulted on a parse failure: an
|
||||||
try:
|
# unparseable value is silently discarded downstream (stdio spawns
|
||||||
parsed_args = json.loads(args) if args else []
|
# with an empty argv), so the caller must be told instead.
|
||||||
except json.JSONDecodeError:
|
if args:
|
||||||
|
try:
|
||||||
|
parsed_args = json.loads(args)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(400, "args must be valid JSON, e.g. [\"-y\", \"pkg\"]")
|
||||||
|
if not isinstance(parsed_args, list):
|
||||||
|
raise HTTPException(400, "args must be a JSON array, e.g. [\"-y\", \"pkg\"]")
|
||||||
|
else:
|
||||||
parsed_args = []
|
parsed_args = []
|
||||||
try:
|
try:
|
||||||
parsed_env = json.loads(env) if env else {}
|
parsed_env = json.loads(env) if env else {}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Computed-style snapshot harness for ``static/style.css``.
|
"""Computed-style snapshot harness for the shipped app CSS cascade.
|
||||||
|
|
||||||
``static/style.css`` is a single 51k-line stylesheet whose rendered result
|
The app CSS is an ordered multi-file cascade whose rendered result depends
|
||||||
depends on source order: hundreds of selectors are declared more than once and
|
on source order: hundreds of selectors are declared more than once and
|
||||||
``!important`` is used throughout. Any restructuring - extracting a block into
|
``!important`` is used throughout. Any restructuring - extracting a block into
|
||||||
its own file, reordering ``<link>`` tags, moving an ``@media`` rule - can
|
its own file, reordering ``<link>`` tags, moving an ``@media`` rule - can
|
||||||
silently change which declaration wins, and nothing else in the suite would
|
silently change which declaration wins, and nothing else in the suite would
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ def discover_tailscale_hosts() -> List[str]:
|
|||||||
global _hosts_cache, _hosts_cache_time
|
global _hosts_cache, _hosts_cache_time
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if _hosts_cache and (now - _hosts_cache_time) < _HOSTS_CACHE_TTL:
|
# Gate on the timestamp, not the list: a successful query that found no
|
||||||
|
# eligible peers is a real answer, and testing the list's truthiness made
|
||||||
|
# that case re-run `tailscale status` (up to a 5s timeout) on every call.
|
||||||
|
if _hosts_cache_time and (now - _hosts_cache_time) < _HOSTS_CACHE_TTL:
|
||||||
return list(_hosts_cache)
|
return list(_hosts_cache)
|
||||||
|
|
||||||
hosts = []
|
hosts = []
|
||||||
|
|||||||
+12
-7
@@ -17,6 +17,7 @@ import re
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from core import platform_compat
|
||||||
from routes._validators import validate_remote_host, validate_ssh_port
|
from routes._validators import validate_remote_host, validate_ssh_port
|
||||||
|
|
||||||
from src.tools._common import _parse_tool_args
|
from src.tools._common import _parse_tool_args
|
||||||
@@ -676,17 +677,17 @@ def _scan_running_model_processes() -> List[Dict[str, Any]]:
|
|||||||
a dict shaped like a cookbook task so the caller can merge cleanly.
|
a dict shaped like a cookbook task so the caller can merge cleanly.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
if not os.path.isdir("/proc"):
|
if not platform_compat.has_procfs():
|
||||||
return []
|
return []
|
||||||
|
proc_root = platform_compat.PROC_ROOT
|
||||||
out: List[Dict[str, Any]] = []
|
out: List[Dict[str, Any]] = []
|
||||||
seen_keys = set()
|
seen_keys = set()
|
||||||
try:
|
try:
|
||||||
for pid_dir in os.listdir("/proc"):
|
for pid_dir in os.listdir(proc_root):
|
||||||
if not pid_dir.isdigit():
|
if not pid_dir.isdigit():
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
with open(f"/proc/{pid_dir}/cmdline", "rb") as f:
|
raw = (proc_root / pid_dir / "cmdline").read_bytes()
|
||||||
raw = f.read()
|
|
||||||
except (OSError, PermissionError):
|
except (OSError, PermissionError):
|
||||||
continue
|
continue
|
||||||
if not raw:
|
if not raw:
|
||||||
@@ -1124,12 +1125,16 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
|
|||||||
import signal
|
import signal
|
||||||
tracked_cmd = str((matched.get("payload") or {}).get("_cmd") or "").strip()
|
tracked_cmd = str((matched.get("payload") or {}).get("_cmd") or "").strip()
|
||||||
matched_pids: list[int] = []
|
matched_pids: list[int] = []
|
||||||
if tracked_cmd:
|
# No procfs means no way to match a survivor by its command line.
|
||||||
for pid_name in os.listdir("/proc"):
|
# The tmux kill above already stopped the session, so skip the
|
||||||
|
# sweep instead of failing a stop that worked.
|
||||||
|
if tracked_cmd and platform_compat.has_procfs():
|
||||||
|
proc_root = platform_compat.PROC_ROOT
|
||||||
|
for pid_name in os.listdir(proc_root):
|
||||||
if not pid_name.isdigit() or int(pid_name) == os.getpid():
|
if not pid_name.isdigit() or int(pid_name) == os.getpid():
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
raw = open(f"/proc/{pid_name}/cmdline", "rb").read()
|
raw = (proc_root / pid_name / "cmdline").read_bytes()
|
||||||
process_cmd = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace").strip()
|
process_cmd = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace").strip()
|
||||||
except (OSError, PermissionError):
|
except (OSError, PermissionError):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1328,7 +1328,7 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
0% { opacity: 1; transform: translateY(0) translateX(0) scale(1); }
|
0% { opacity: 1; transform: translateY(0) translateX(0) scale(1); }
|
||||||
100% { opacity: 0; transform: translateY(6px) translateX(-3px) scale(0.94); }
|
100% { opacity: 0; transform: translateY(6px) translateX(-3px) scale(0.94); }
|
||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
.row { display:flex; gap:6px; align-items:center; }
|
.row { display:flex; gap:6px; align-items:center; }
|
||||||
.list-item:hover,
|
.list-item:hover,
|
||||||
.models-row:hover {
|
.models-row:hover {
|
||||||
@@ -2407,7 +2407,7 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure code block headers are only slightly larger than regular text */
|
/* Ensure code block headers are only slightly larger than regular text */
|
||||||
pre h1, pre h2, pre h3, pre h4, pre h5, pre h6 {
|
pre h1, pre h2, pre h3, pre h4, pre h5, pre h6 {
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
@@ -3944,11 +3944,11 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
.attach-strip:empty {
|
.attach-strip:empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.attach-strip {
|
.attach-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
@@ -4991,10 +4991,10 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
display:flex; align-items:center; gap:6px; border:1px solid var(--border); padding:4px; margin:4px 0; border-radius: 4px;
|
display:flex; align-items:center; gap:6px; border:1px solid var(--border); padding:4px; margin:4px 0; border-radius: 4px;
|
||||||
}
|
}
|
||||||
.models-row .grow,
|
.models-row .grow,
|
||||||
.models-row select {
|
.models-row select {
|
||||||
flex:1;
|
flex:1;
|
||||||
display:flex;
|
display:flex;
|
||||||
align-items:center;
|
align-items:center;
|
||||||
font-size: 9.75px;
|
font-size: 9.75px;
|
||||||
}
|
}
|
||||||
.model-fav-btn {
|
.model-fav-btn {
|
||||||
@@ -5967,9 +5967,7 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
#mobile-backdrop, #mobile-menu-btn { display:none !important; }
|
#mobile-backdrop, #mobile-menu-btn { display:none !important; }
|
||||||
#sidebar-backdrop { display:none !important; }
|
#sidebar-backdrop { display:none !important; }
|
||||||
/* ----- Loading spinner ----- */
|
/* ----- Loading spinner ----- */
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@@ -5997,7 +5995,7 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-left: 2px solid var(--red);
|
border-left: 2px solid var(--red);
|
||||||
background: color-mix(in srgb, var(--red) 5%, transparent);
|
background: color-mix(in srgb, var(--red) 5%, transparent);
|
||||||
}
|
}
|
||||||
/* Loading indicator for messages */
|
/* Loading indicator for messages */
|
||||||
.loading-indicator {
|
.loading-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -6088,14 +6086,7 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
.session-skeleton-bubble.is-user { width: 72%; }
|
.session-skeleton-bubble.is-user { width: 72%; }
|
||||||
.session-skeleton-line { height: 9px; margin-top: 7px; }
|
.session-skeleton-line { height: 9px; margin-top: 7px; }
|
||||||
}
|
}
|
||||||
@keyframes loading-bounce {
|
|
||||||
0%, 80%, 100% {
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
40% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* Modal styling */
|
/* Modal styling */
|
||||||
.modal {
|
.modal {
|
||||||
position:fixed;
|
position:fixed;
|
||||||
@@ -6689,7 +6680,7 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Drag & Drop ── */
|
/* ── Drag & Drop ── */
|
||||||
|
|
||||||
/* ---------- Color palette (dark / light) ---------- */
|
/* ---------- Color palette (dark / light) ---------- */
|
||||||
@@ -6699,28 +6690,28 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
background: color-mix(in srgb, var(--red) 10%, transparent) !important;
|
background: color-mix(in srgb, var(--red) 10%, transparent) !important;
|
||||||
border-color: var(--red) !important;
|
border-color: var(--red) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section[dnd-over="true"] {
|
.section[dnd-over="true"] {
|
||||||
background: color-mix(in srgb, var(--red) 20%, transparent) !important;
|
background: color-mix(in srgb, var(--red) 20%, transparent) !important;
|
||||||
border-color: var(--red) !important;
|
border-color: var(--red) !important;
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-handle:hover {
|
.drag-handle:hover {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-handle:active {
|
.drag-handle:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── UI Controls (Radio, Presets, Toolbar, Settings) ── */
|
/* ── UI Controls (Radio, Presets, Toolbar, Settings) ── */
|
||||||
|
|
||||||
|
|
||||||
@@ -6736,12 +6727,12 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-option:hover {
|
.radio-option:hover {
|
||||||
background: color-mix(in srgb, var(--fg) 9%, transparent);
|
background: color-mix(in srgb, var(--fg) 9%, transparent);
|
||||||
border-color: var(--fg);
|
border-color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-option input[type="radio"] {
|
.radio-option input[type="radio"] {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
@@ -6756,22 +6747,22 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-option input[type="radio"]:checked {
|
.radio-option input[type="radio"]:checked {
|
||||||
border-color: var(--red);
|
border-color: var(--red);
|
||||||
background: var(--red);
|
background: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-option input[type="radio"]:focus {
|
.radio-option input[type="radio"]:focus {
|
||||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 30%, transparent);
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-label {
|
.radio-label {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Preset buttons */
|
/* Preset buttons */
|
||||||
.preset-btn {
|
.preset-btn {
|
||||||
height: 27.2px; /* 15% smaller than 32px */
|
height: 27.2px; /* 15% smaller than 32px */
|
||||||
@@ -6786,29 +6777,29 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-btn:hover {
|
.preset-btn:hover {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-color: var(--fg);
|
border-color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-btn.active {
|
.preset-btn.active {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-color: var(--fg);
|
border-color: var(--fg);
|
||||||
box-shadow: 0 0 0 1px var(--fg), 0 0 8px color-mix(in srgb, var(--fg) 16%, transparent);
|
box-shadow: 0 0 0 1px var(--fg), 0 0 8px color-mix(in srgb, var(--fg) 16%, transparent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* All preset buttons use the same blue color */
|
/* All preset buttons use the same blue color */
|
||||||
.preset-btn {
|
.preset-btn {
|
||||||
border-color: var(--red); /* Blue color */
|
border-color: var(--red); /* Blue color */
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-btn.active {
|
.preset-btn.active {
|
||||||
border-color: var(--red); /* Blue color when active */
|
border-color: var(--red); /* Blue color when active */
|
||||||
box-shadow: 0 0 0 1px var(--red), 0 0 8px color-mix(in srgb, var(--red) 30%, transparent);
|
box-shadow: 0 0 0 1px var(--red), 0 0 8px color-mix(in srgb, var(--red) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom preset modal: the base .preset-modal-content sets overflow:hidden
|
/* Custom preset modal: the base .preset-modal-content sets overflow:hidden
|
||||||
(for desktop rounded-corner clipping), which on the mobile sheet clipped
|
(for desktop rounded-corner clipping), which on the mobile sheet clipped
|
||||||
the footer (Start/Cancel) off the bottom with no way to reach it. Let the
|
the footer (Start/Cancel) off the bottom with no way to reach it. Let the
|
||||||
@@ -7366,7 +7357,7 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
background: var(--border);
|
background: var(--border);
|
||||||
margin: 4px 8px;
|
margin: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search toggle styles */
|
/* Search toggle styles */
|
||||||
.search-toggle {
|
.search-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -7376,7 +7367,7 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-provider-label {
|
.search-provider-label {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
@@ -7391,24 +7382,20 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
#mic-btn:hover {
|
#mic-btn:hover {
|
||||||
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
||||||
border-color: var(--fg);
|
border-color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
#mic-btn.recording {
|
#mic-btn.recording {
|
||||||
background: var(--color-recording);
|
background: var(--color-recording);
|
||||||
border-color: var(--color-recording);
|
border-color: var(--color-recording);
|
||||||
animation: pulse 1.5s infinite;
|
animation: pulse 1.5s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% { opacity: 1; }
|
|
||||||
50% { opacity: 0.7; }
|
|
||||||
100% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
#recording-indicator {
|
#recording-indicator {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
@@ -7426,29 +7413,29 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
#recording-indicator.hidden {
|
#recording-indicator.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recording-content {
|
.recording-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recording-icon {
|
.recording-icon {
|
||||||
color: var(--color-recording);
|
color: var(--color-recording);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
animation: pulse 1.5s infinite;
|
animation: pulse 1.5s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recording-text {
|
.recording-text {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
#stop-recording {
|
#stop-recording {
|
||||||
background: var(--color-recording);
|
background: var(--color-recording);
|
||||||
color: white;
|
color: white;
|
||||||
@@ -7460,32 +7447,32 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
#stop-recording:hover {
|
#stop-recording:hover {
|
||||||
background: var(--color-recording-hover);
|
background: var(--color-recording-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Error state for recording */
|
/* Error state for recording */
|
||||||
#recording-indicator.error {
|
#recording-indicator.error {
|
||||||
background: rgba(173, 26, 26, 0.9);
|
background: rgba(173, 26, 26, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recording-error {
|
.recording-error {
|
||||||
color: var(--color-recording);
|
color: var(--color-recording);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#recording-indicator {
|
#recording-indicator {
|
||||||
margin: 8px;
|
margin: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recording-text {
|
.recording-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#stop-recording {
|
#stop-recording {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -764,7 +764,7 @@
|
|||||||
}
|
}
|
||||||
.compare-probe-start-anyway svg { color: currentColor; }
|
.compare-probe-start-anyway svg { color: currentColor; }
|
||||||
.compare-probe-start-anyway span { font-weight: 700; }
|
.compare-probe-start-anyway span { font-weight: 700; }
|
||||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
||||||
@keyframes pane-shake {
|
@keyframes pane-shake {
|
||||||
0%, 100% { transform: translateX(0); }
|
0%, 100% { transform: translateX(0); }
|
||||||
15% { transform: translateX(-3px) rotate(-0.5deg); }
|
15% { transform: translateX(-3px) rotate(-0.5deg); }
|
||||||
|
|||||||
@@ -2085,11 +2085,11 @@ body.fullwidth-chat .chat-history {
|
|||||||
margin: 8px;
|
margin: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recording-text {
|
.recording-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stop-recording-btn {
|
.stop-recording-btn {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
/* Cookbook (including What Fits?), deep research, memory, settings and admin.
|
/* Cookbook (including What Fits?), deep research, memory, settings and admin.
|
||||||
*
|
*
|
||||||
* Split out of style.css for maintainability, not for performance: this file
|
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
|
||||||
* is loaded eagerly from index.html immediately after style.css, so the rules
|
* former static/style.css now lives in the numbered 00-17 fragments; these
|
||||||
* land in the cascade in exactly the order they had inside the single file.
|
* three pre-existing panel files follow those fragments without changing
|
||||||
* Nothing here is lazy-loaded.
|
* their relative order. Nothing here is lazy-loaded.
|
||||||
*
|
*
|
||||||
* LOAD ORDER IS PART OF THE CONTRACT. The <link> tags in static/index.html and
|
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
|
||||||
* the PRECACHE list in static/sw.js must both list the stylesheets in this
|
* in static/sw.js must agree:
|
||||||
* order:
|
* 1. static/css/00-tokens.css through static/css/17-research.css,
|
||||||
* 1. static/style.css
|
* in numeric order
|
||||||
* 2. static/css/documents-gallery-editor.css
|
* 2. static/css/documents-gallery-editor.css
|
||||||
* 3. static/css/email-calendar-notes-tasks.css
|
* 3. static/css/email-calendar-notes-tasks.css
|
||||||
* 4. static/css/cookbook-research-memory-settings.css
|
* 4. static/css/cookbook-research-memory-settings.css
|
||||||
*
|
*
|
||||||
* Rules were moved verbatim. A rule stays in style.css when its selector group
|
* Rules in this file retain their existing cascade position. Shared rules
|
||||||
* covers more than one panel, when the class is shared design language used by
|
* formerly kept in static/style.css now live in the numbered fragments.
|
||||||
* modules outside this panel, or when moving it would change which of two
|
|
||||||
* equally specific rules wins.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Cookbook already shows its own running/served-status dot
|
/* Cookbook already shows its own running/served-status dot
|
||||||
@@ -28,10 +26,6 @@
|
|||||||
0%, 100% { transform: scale(1); opacity: 1; }
|
0%, 100% { transform: scale(1); opacity: 1; }
|
||||||
50% { transform: scale(1.5); opacity: 0.7; }
|
50% { transform: scale(1.5); opacity: 0.7; }
|
||||||
}
|
}
|
||||||
@keyframes research-pulse {
|
|
||||||
0%, 100% { background: color-mix(in srgb, var(--red) 12%, transparent); }
|
|
||||||
50% { background: color-mix(in srgb, var(--red) 22%, transparent); }
|
|
||||||
}
|
|
||||||
#cookbook-gguf-delete-overlay {
|
#cookbook-gguf-delete-overlay {
|
||||||
background:rgba(0,0,0,0.5);
|
background:rgba(0,0,0,0.5);
|
||||||
backdrop-filter:blur(4px);
|
backdrop-filter:blur(4px);
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
/* Document library and editor, image gallery, image editor.
|
/* Document library and editor, image gallery, image editor.
|
||||||
*
|
*
|
||||||
* Split out of style.css for maintainability, not for performance: this file
|
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
|
||||||
* is loaded eagerly from index.html immediately after style.css, so the rules
|
* former static/style.css now lives in the numbered 00-17 fragments; these
|
||||||
* land in the cascade in exactly the order they had inside the single file.
|
* three pre-existing panel files follow those fragments without changing
|
||||||
* Nothing here is lazy-loaded.
|
* their relative order. Nothing here is lazy-loaded.
|
||||||
*
|
*
|
||||||
* LOAD ORDER IS PART OF THE CONTRACT. The <link> tags in static/index.html and
|
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
|
||||||
* the PRECACHE list in static/sw.js must both list the stylesheets in this
|
* in static/sw.js must agree:
|
||||||
* order:
|
* 1. static/css/00-tokens.css through static/css/17-research.css,
|
||||||
* 1. static/style.css
|
* in numeric order
|
||||||
* 2. static/css/documents-gallery-editor.css
|
* 2. static/css/documents-gallery-editor.css
|
||||||
* 3. static/css/email-calendar-notes-tasks.css
|
* 3. static/css/email-calendar-notes-tasks.css
|
||||||
* 4. static/css/cookbook-research-memory-settings.css
|
* 4. static/css/cookbook-research-memory-settings.css
|
||||||
*
|
*
|
||||||
* Rules were moved verbatim. A rule stays in style.css when its selector group
|
* Rules in this file retain their existing cascade position. Shared rules
|
||||||
* covers more than one panel, when the class is shared design language used by
|
* formerly kept in static/style.css now live in the numbered fragments.
|
||||||
* modules outside this panel, or when moving it would change which of two
|
|
||||||
* equally specific rules wins.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Document library search-term highlight — clear, high-contrast. */
|
/* Document library search-term highlight — clear, high-contrast. */
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
/* Email, calendar, notes and tasks panels.
|
/* Email, calendar, notes and tasks panels.
|
||||||
*
|
*
|
||||||
* Split out of style.css for maintainability, not for performance: this file
|
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
|
||||||
* is loaded eagerly from index.html immediately after style.css, so the rules
|
* former static/style.css now lives in the numbered 00-17 fragments; these
|
||||||
* land in the cascade in exactly the order they had inside the single file.
|
* three pre-existing panel files follow those fragments without changing
|
||||||
* Nothing here is lazy-loaded.
|
* their relative order. Nothing here is lazy-loaded.
|
||||||
*
|
*
|
||||||
* LOAD ORDER IS PART OF THE CONTRACT. The <link> tags in static/index.html and
|
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
|
||||||
* the PRECACHE list in static/sw.js must both list the stylesheets in this
|
* in static/sw.js must agree:
|
||||||
* order:
|
* 1. static/css/00-tokens.css through static/css/17-research.css,
|
||||||
* 1. static/style.css
|
* in numeric order
|
||||||
* 2. static/css/documents-gallery-editor.css
|
* 2. static/css/documents-gallery-editor.css
|
||||||
* 3. static/css/email-calendar-notes-tasks.css
|
* 3. static/css/email-calendar-notes-tasks.css
|
||||||
* 4. static/css/cookbook-research-memory-settings.css
|
* 4. static/css/cookbook-research-memory-settings.css
|
||||||
*
|
*
|
||||||
* Rules were moved verbatim. A rule stays in style.css when its selector group
|
* Rules in this file retain their existing cascade position. Shared rules
|
||||||
* covers more than one panel, when the class is shared design language used by
|
* formerly kept in static/style.css now live in the numbered fragments.
|
||||||
* modules outside this panel, or when moving it would change which of two
|
|
||||||
* equally specific rules wins.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@keyframes email-notif-breathe {
|
@keyframes email-notif-breathe {
|
||||||
|
|||||||
@@ -3142,6 +3142,7 @@ function initMcpForm() {
|
|||||||
if (transport === 'stdio' && !command) { msg.textContent = 'Command is required for stdio'; msg.className = 'admin-error'; return; }
|
if (transport === 'stdio' && !command) { msg.textContent = 'Command is required for stdio'; msg.className = 'admin-error'; return; }
|
||||||
if (transport === 'sse' && !url) { msg.textContent = 'URL is required for SSE'; msg.className = 'admin-error'; return; }
|
if (transport === 'sse' && !url) { msg.textContent = 'URL is required for SSE'; msg.className = 'admin-error'; return; }
|
||||||
try { JSON.parse(env); } catch { msg.textContent = 'Env must be valid JSON'; msg.className = 'admin-error'; return; }
|
try { JSON.parse(env); } catch { msg.textContent = 'Env must be valid JSON'; msg.className = 'admin-error'; return; }
|
||||||
|
try { JSON.parse(args); } catch { msg.textContent = 'Args must be valid JSON, e.g. ["-y", "pkg"]'; msg.className = 'admin-error'; return; }
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('name', name); fd.append('transport', transport); fd.append('command', command); fd.append('args', args); fd.append('env', env); fd.append('url', url);
|
fd.append('name', name); fd.append('transport', transport); fd.append('command', command); fd.append('args', args); fd.append('env', env); fd.append('url', url);
|
||||||
// If preset has oauthFile config, send credentials for file generation
|
// If preset has oauthFile config, send credentials for file generation
|
||||||
@@ -3162,6 +3163,10 @@ function initMcpForm() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/mcp/servers', { method: 'POST', body: fd, credentials: 'same-origin' });
|
const res = await fetch('/api/mcp/servers', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
msg.textContent = data.detail || `Failed (${res.status})`; msg.className = 'admin-error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.needs_oauth) {
|
if (data.needs_oauth) {
|
||||||
msg.innerHTML = `Added ${esc(name)} — <a href="/api/mcp/oauth/authorize/${data.id}" target="_blank" style="color:var(--red);font-weight:600;">Authorize with Google</a> to connect`;
|
msg.innerHTML = `Added ${esc(name)} — <a href="/api/mcp/oauth/authorize/${data.id}" target="_blank" style="color:var(--red);font-weight:600;">Authorize with Google</a> to connect`;
|
||||||
msg.className = 'admin-success';
|
msg.className = 'admin-success';
|
||||||
|
|||||||
+11
-2
@@ -5102,7 +5102,11 @@ async function initUnifiedIntegrations() {
|
|||||||
fd.append('transport', transport);
|
fd.append('transport', transport);
|
||||||
if (transport === 'stdio') {
|
if (transport === 'stdio') {
|
||||||
fd.append('command', el('uf-mcp-cmd').value);
|
fd.append('command', el('uf-mcp-cmd').value);
|
||||||
let args = '[]'; try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); } catch (_) {}
|
// Unlike env below, an unparseable args value is not silently
|
||||||
|
// defaulted: it would spawn the subprocess with an empty argv.
|
||||||
|
let args;
|
||||||
|
try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); }
|
||||||
|
catch (_) { el('uf-mcp-msg').textContent = 'Args must be valid JSON, e.g. ["-y", "pkg"]'; return; }
|
||||||
let env = '{}'; try { env = JSON.stringify(JSON.parse(el('uf-mcp-env').value || '{}')); } catch (_) {}
|
let env = '{}'; try { env = JSON.stringify(JSON.parse(el('uf-mcp-env').value || '{}')); } catch (_) {}
|
||||||
fd.append('args', args);
|
fd.append('args', args);
|
||||||
fd.append('env', env);
|
fd.append('env', env);
|
||||||
@@ -5124,7 +5128,12 @@ async function initUnifiedIntegrations() {
|
|||||||
} else if (r.ok) {
|
} else if (r.ok) {
|
||||||
el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList();
|
el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList();
|
||||||
} else {
|
} else {
|
||||||
el('uf-mcp-msg').textContent = `Failed (${r.status})`;
|
// Surface the server's reason. The Args validation above rejects
|
||||||
|
// unparseable JSON, but `"x"` and `{}` parse and are refused by
|
||||||
|
// routes/mcp/mcp_routes.py with a message naming the expected
|
||||||
|
// shape; a bare status code sends the user looking in the wrong
|
||||||
|
// place. Matches what admin.js shows for the same endpoint.
|
||||||
|
el('uf-mcp-msg').textContent = data.detail || `Failed (${r.status})`;
|
||||||
}
|
}
|
||||||
} catch (_) { el('uf-mcp-msg').textContent = 'Failed'; }
|
} catch (_) { el('uf-mcp-msg').textContent = 'Failed'; }
|
||||||
finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; }
|
finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; }
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ class SmoothPad {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _modal(innerHtml) {
|
function _modal(innerHtml) {
|
||||||
// Match the app's standard .modal pattern (defined in static/style.css).
|
// Match the app's standard .modal pattern from the main stylesheet cascade.
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.className = 'modal sig-modal-overlay';
|
overlay.className = 'modal sig-modal-overlay';
|
||||||
overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.45);z-index:10100;';
|
overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.45);z-index:10100;';
|
||||||
|
|||||||
+4
-4
@@ -106,7 +106,7 @@
|
|||||||
<style>
|
<style>
|
||||||
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
||||||
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
||||||
/* Mirror the main app's :root defaults (static/style.css ~line 18) so an
|
/* Mirror the main app's :root defaults from the stylesheet cascade so an
|
||||||
uncustomized theme — or a fresh browser with no `odysseus-theme` in
|
uncustomized theme — or a fresh browser with no `odysseus-theme` in
|
||||||
localStorage — renders the login page in the same palette as the rest
|
localStorage — renders the login page in the same palette as the rest
|
||||||
of the app instead of a different greys+offwhite scheme. */
|
of the app instead of a different greys+offwhite scheme. */
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
--red: #e06c75; --green: #50fa7b;
|
--red: #e06c75; --green: #50fa7b;
|
||||||
--bg-effect-intensity: 1;
|
--bg-effect-intensity: 1;
|
||||||
}
|
}
|
||||||
/* Background patterns — mirrors static/style.css "Background Patterns"
|
/* Background patterns — mirrors the main app "Background Patterns" styles
|
||||||
block so the theme's chosen bgPattern renders on login too. The
|
block so the theme's chosen bgPattern renders on login too. The
|
||||||
canvas-based effects (rain, constellations, perlin-flow, petals,
|
canvas-based effects (rain, constellations, perlin-flow, petals,
|
||||||
sparkles, embers) are spawned by the deferred theme.js import below;
|
sparkles, embers) are spawned by the deferred theme.js import below;
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
font-size: 2rem; font-weight: 600;
|
font-size: 2rem; font-weight: 600;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
/* Same gradient as the main app's welcome-screen "Odysseus" title
|
/* Same gradient as the main app's welcome-screen "Odysseus" title
|
||||||
(static/style.css #welcome-screen .welcome-name) so the brand
|
(the main cascade's #welcome-screen .welcome-name) so the brand
|
||||||
wordmark is consistent across login + first-launch screens. */
|
wordmark is consistent across login + first-launch screens. */
|
||||||
background: linear-gradient(135deg, var(--brand-color, var(--red)), color-mix(in srgb, var(--brand-color, var(--red)) 60%, var(--fg)));
|
background: linear-gradient(135deg, var(--brand-color, var(--red)), color-mix(in srgb, var(--brand-color, var(--red)) 60%, var(--fg)));
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
/* On touch devices keep inputs at >=16px so iOS Safari doesn't zoom the whole
|
/* On touch devices keep inputs at >=16px so iOS Safari doesn't zoom the whole
|
||||||
page when a field is focused (it auto-zooms any focused input under 16px).
|
page when a field is focused (it auto-zooms any focused input under 16px).
|
||||||
This page has its own inline styles, so it doesn't inherit the main app's
|
This page has its own inline styles, so it doesn't inherit the main app's
|
||||||
equivalent rule in static/style.css; mirror it here. !important also lifts
|
equivalent rule in the main stylesheet cascade; mirror it here. !important also lifts
|
||||||
the dynamically-inserted 2FA input, which pins font-size:14px inline. */
|
the dynamically-inserted 2FA input, which pins font-size:14px inline. */
|
||||||
@media (hover: none) and (pointer: coarse) {
|
@media (hover: none) and (pointer: coarse) {
|
||||||
input:not(.remember-check) { font-size: 16px !important; }
|
input:not(.remember-check) { font-size: 16px !important; }
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Known full-suite failures
|
||||||
|
|
||||||
|
`python -m pytest -q` does not come back clean on every machine, and it never
|
||||||
|
has. Without a list of which failures are expected, a first local run is
|
||||||
|
uninterpretable: you cannot tell "you broke something" from "you are on a Mac",
|
||||||
|
so the usual result is either chasing a non-bug or ignoring a real one.
|
||||||
|
|
||||||
|
This is that list. It is a record of observation, not a permission slip: a test
|
||||||
|
here is still a test that does not pass, and the three that remain below are
|
||||||
|
all still worth someone's time.
|
||||||
|
|
||||||
|
Last measured: `lab @ c499c01b` plus the fixes in this change, macOS 15 on
|
||||||
|
Apple Silicon, Python 3.11, with the **default** `$TMPDIR` — see the socket
|
||||||
|
entry below for why that qualifier is load-bearing.
|
||||||
|
|
||||||
|
```
|
||||||
|
3 failed, 10658 passed, 6 skipped
|
||||||
|
```
|
||||||
|
|
||||||
|
## Get the prerequisites right first
|
||||||
|
|
||||||
|
Most "surprise" failures are a missing dependency rather than anything in this
|
||||||
|
file. A clean run needs all of:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3.11 -m venv venv
|
||||||
|
./venv/bin/python -m pip install -r requirements.txt
|
||||||
|
npm ci # the browser tests shell out to node
|
||||||
|
npx playwright install chromium # ~30 tests drive a real browser
|
||||||
|
mkdir -p data # SQLite lives at ./data/app.db
|
||||||
|
```
|
||||||
|
|
||||||
|
plus `ffmpeg` on `PATH` for the media tests.
|
||||||
|
|
||||||
|
If you already have a ChromaDB running, point `CHROMADB_PORT` at a closed port
|
||||||
|
for the run. The client reaches Chroma over HTTP regardless of the data
|
||||||
|
directory, so a test run will otherwise attach to whatever store is listening,
|
||||||
|
including one holding real data.
|
||||||
|
|
||||||
|
Miss `npm ci` and roughly 36 browser tests fail on `Cannot find package
|
||||||
|
'playwright'`. That is not a regression, it is the missing install.
|
||||||
|
|
||||||
|
## The three that remain, and the seven that no longer do
|
||||||
|
|
||||||
|
### Test bugs: comparing an unresolved path against a resolved one
|
||||||
|
|
||||||
|
Three failures compared an unresolved `/tmp` path against a resolved
|
||||||
|
`/private/tmp` one, and are fixed rather than listed:
|
||||||
|
|
||||||
|
- `tests/test_code_nav_tools.py` (two tests) built a fixture under
|
||||||
|
`tempfile.mkdtemp(dir="/tmp")` and compared it against the path the code
|
||||||
|
reports, which it resolves.
|
||||||
|
- `tests/test_workspace_confine.py::test_glob_confined_e2e` mixed
|
||||||
|
`os.path.realpath(ws)` with an unresolved secret directory, so `relpath`
|
||||||
|
produced `../../../../tmp/<absolute path>` and the assertion that the
|
||||||
|
absolute path was absent matched it as a substring.
|
||||||
|
|
||||||
|
Both now resolve consistently. They are recorded here because the shape recurs:
|
||||||
|
on macOS, mixing a resolved and an unresolved temp path is a test bug that
|
||||||
|
looks like a platform failure.
|
||||||
|
|
||||||
|
### Test bugs: a temp path too long to bind a socket to
|
||||||
|
|
||||||
|
Four more, same family, invisible unless `$TMPDIR` is long enough:
|
||||||
|
|
||||||
|
- `tests/test_shell_routes.py::TestHostDockerAccess` (three tests)
|
||||||
|
- `tests/test_cookbook_docker_access.py::test_container_opt_in_with_unix_socket_is_allowed`
|
||||||
|
|
||||||
|
```
|
||||||
|
OSError: AF_UNIX path too long
|
||||||
|
```
|
||||||
|
|
||||||
|
Each bound an `AF_UNIX` socket at `tmp_path / "docker.sock"`. macOS gives
|
||||||
|
`sun_path` 104 bytes including the terminator, and pytest's `tmp_path` is
|
||||||
|
rooted at `$TMPDIR`, which on a stock Mac is a 49-character
|
||||||
|
`/var/folders/<2>/<30>/T/`. Add `pytest-of-<user>/pytest-<n>/` and the test's
|
||||||
|
own name and the bind path is 115 bytes before the filename.
|
||||||
|
|
||||||
|
This is why the counts above depend on where you run from: under a shortened
|
||||||
|
`$TMPDIR` the path lands at 103 and the tests pass, and it tips over the moment
|
||||||
|
pytest's run counter reaches two digits. Linux allows 108 bytes and roots
|
||||||
|
`$TMPDIR` at `/tmp`, so it never bites there and CI stays green.
|
||||||
|
|
||||||
|
They now bind through `tests/helpers/unix_sockets.bound_unix_socket`, which
|
||||||
|
puts the socket under a short directory. **Measure with the default `$TMPDIR`**
|
||||||
|
— `env -u TMPDIR` or an explicit `/var/folders/...` — or this whole file
|
||||||
|
records a run nobody else has.
|
||||||
|
|
||||||
|
### Optional dependency: ffmpeg without a WebP encoder
|
||||||
|
|
||||||
|
- `tests/test_inspect_media_tool.py::test_inspect_media_exports_final_decodable_frame_at_exact_duration`
|
||||||
|
|
||||||
|
```
|
||||||
|
ffmpeg still extraction failed: Automatic encoder selection failed ...
|
||||||
|
Error opening output files: Encoder not found
|
||||||
|
```
|
||||||
|
|
||||||
|
The test asks ffmpeg for a `.webp` still and asserts `exit_code == 0`. WebP
|
||||||
|
encoding is a build option, and Homebrew's ffmpeg does not always carry it. CI
|
||||||
|
installs a build that does, which is why this is green there.
|
||||||
|
|
||||||
|
**Needs a decision**: skip when the encoder is absent, or fall back to PNG. The
|
||||||
|
current shape asserts success from a codec that is not guaranteed present.
|
||||||
|
|
||||||
|
### Environmental: real sockets
|
||||||
|
|
||||||
|
- `tests/test_integration_api_call_ssrf.py::test_real_socket_falls_back_from_dead_first_to_live_second`
|
||||||
|
|
||||||
|
```
|
||||||
|
httpcore.ConnectTimeout / httpx.ConnectTimeout
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens real sockets and depends on a connection to a dead address being refused
|
||||||
|
quickly rather than hanging. Sandboxed and restricted-network machines time out
|
||||||
|
instead. Genuinely environmental.
|
||||||
|
|
||||||
|
### Unexplained: rich-text colour contrast
|
||||||
|
|
||||||
|
- `tests/test_document_rich_color_reset_and_contrast.py::test_rich_colors_follow_theme_and_undo_as_one_edit`
|
||||||
|
|
||||||
|
A Playwright run times out waiting for `#doc-email-richbody p` to contain a
|
||||||
|
`span` after a colour is applied.
|
||||||
|
|
||||||
|
**This one is not flaky.** Three consecutive runs failed identically, each at
|
||||||
|
about 31 seconds. It was previously written off as timing noise and that was
|
||||||
|
wrong. The cause is not established, and until it is, treat it as a possible
|
||||||
|
real defect in the rich-text colour path rather than a platform artifact.
|
||||||
|
|
||||||
|
## Keeping this current
|
||||||
|
|
||||||
|
Re-measure on a clean checkout of `lab` with the prerequisites above, and
|
||||||
|
update the header revision, the counts and any entry that changed. A failure
|
||||||
|
that appears and is not listed here is a regression until shown otherwise.
|
||||||
+6
-5
@@ -33,6 +33,8 @@ the sub-area. The `area_*` names are registered in `pyproject.toml`; the dynamic
|
|||||||
`sub_*` names are registered before collection by `pytest_configure` in
|
`sub_*` names are registered before collection by `pytest_configure` in
|
||||||
`tests/conftest.py`, so unknown-mark warnings still flag genuine typos.
|
`tests/conftest.py`, so unknown-mark warnings still flag genuine typos.
|
||||||
|
|
||||||
|
The full suite does not come back clean on every machine. [KNOWN_FAILURES.md](KNOWN_FAILURES.md) lists which failures are expected, which are test bugs worth fixing, and the prerequisites a clean run needs; anything not on that list is a regression until shown otherwise.
|
||||||
|
|
||||||
For common focused runs, use `tests/run_focus.py`. It validates area and
|
For common focused runs, use `tests/run_focus.py`. It validates area and
|
||||||
sub-area names, accepts sub-areas with or without the `sub_` prefix, and passes
|
sub-area names, accepts sub-areas with or without the `sub_` prefix, and passes
|
||||||
extra pytest arguments after `--`:
|
extra pytest arguments after `--`:
|
||||||
@@ -140,7 +142,7 @@ swallowed.
|
|||||||
## CSS computed-style snapshot
|
## CSS computed-style snapshot
|
||||||
|
|
||||||
`tests/test_css_computed_style_snapshot.py` pins the rendered result of
|
`tests/test_css_computed_style_snapshot.py` pins the rendered result of
|
||||||
`static/style.css` - one 51k-line file whose behavior depends on source order -
|
the shipped ordered stylesheet cascade, whose behavior depends on source order,
|
||||||
by hashing `getComputedStyle` over a fixed element inventory across pages,
|
by hashing `getComputedStyle` over a fixed element inventory across pages,
|
||||||
viewports, themes and density modes. Any PR that moves CSS has to produce an
|
viewports, themes and density modes. Any PR that moves CSS has to produce an
|
||||||
identical digest or explain why it did not.
|
identical digest or explain why it did not.
|
||||||
@@ -178,14 +180,13 @@ Use when a test asserts on a CSS rule.
|
|||||||
|
|
||||||
- Returns every app stylesheet concatenated in the order `static/index.html`
|
- Returns every app stylesheet concatenated in the order `static/index.html`
|
||||||
loads them, which is the order the cascade actually has.
|
loads them, which is the order the cascade actually has.
|
||||||
- Panel styles no longer all live in `static/style.css`; reading that file
|
- App styles live across an ordered cascade; reading one fragment alone ties
|
||||||
alone ties the test to whichever file a rule sits in today, so it goes red
|
the test to whichever file a rule sits in today, so it goes red
|
||||||
when a rule moves without the rendered page changing.
|
when a rule moves without the rendered page changing.
|
||||||
- `stylesheet_paths()` and `stylesheet_urls()` are there when a test needs the
|
- `stylesheet_paths()` and `stylesheet_urls()` are there when a test needs the
|
||||||
files or the request URLs rather than their contents.
|
files or the request URLs rather than their contents.
|
||||||
`stylesheet_link_tags()` returns the `<link>` markup for a synthetic page
|
`stylesheet_link_tags()` returns the `<link>` markup for a synthetic page
|
||||||
driven through Playwright, so it gets the whole cascade instead of only
|
driven through Playwright, so it gets the whole shipped cascade.
|
||||||
`style.css`.
|
|
||||||
- All of them fail loudly if `index.html` links a stylesheet that is missing.
|
- All of them fail loudly if `index.html` links a stylesheet that is missing.
|
||||||
- Not for vendored CSS under `static/lib/`, which they deliberately skip.
|
- Not for vendored CSS under `static/lib/`, which they deliberately skip.
|
||||||
|
|
||||||
|
|||||||
@@ -159,3 +159,43 @@ def _serve_test_static():
|
|||||||
os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"] = previous_origin
|
os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"] = previous_origin
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_leaked_module_stubs():
|
||||||
|
"""Fail the test that leaves a bare ``src.*``/``core.*`` stub behind.
|
||||||
|
|
||||||
|
Several test modules install empty stand-in modules so an import-heavy
|
||||||
|
production module can be loaded under the mocks above. When one of those
|
||||||
|
writes is not undone, the stub stays in ``sys.modules`` for the rest of the
|
||||||
|
session and every later test that imports the real module silently gets an
|
||||||
|
empty one instead. The suite still passes as a whole, because the victims
|
||||||
|
usually run before the leak; it only breaks under a different collection
|
||||||
|
order, which is why this class of bug reaches CI green.
|
||||||
|
|
||||||
|
This fixture is declared in the root conftest, so it is set up before any
|
||||||
|
test-module fixture and torn down after all of them — a stub that a test's
|
||||||
|
own teardown removes is not reported. The leaked entries are dropped here
|
||||||
|
as well as reported, so the failure stays attributed to the test that
|
||||||
|
introduced it instead of cascading into the rest of the run.
|
||||||
|
|
||||||
|
Bare stubs present before the test starts are ignored: this guards against
|
||||||
|
new leaks, it does not police import state the session began with.
|
||||||
|
"""
|
||||||
|
from tests.helpers.import_state import bare_module_stubs, clear_module
|
||||||
|
|
||||||
|
before = bare_module_stubs()
|
||||||
|
yield
|
||||||
|
leaked = sorted(bare_module_stubs() - before)
|
||||||
|
if not leaked:
|
||||||
|
return
|
||||||
|
for name in leaked:
|
||||||
|
clear_module(name)
|
||||||
|
pytest.fail(
|
||||||
|
"test left bare module stub(s) in sys.modules: "
|
||||||
|
+ ", ".join(leaked)
|
||||||
|
+ ". Register the stub through monkeypatch.setitem(sys.modules, ...) "
|
||||||
|
"or tests.helpers.import_state.preserve_import_state so it is undone "
|
||||||
|
"at teardown.",
|
||||||
|
pytrace=False,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Computed-style snapshot harness
|
# Computed-style snapshot harness
|
||||||
|
|
||||||
`static/style.css` is 51,425 lines in one file. Hundreds of selectors are
|
The app CSS is an ordered multi-file cascade. Hundreds of selectors are
|
||||||
declared more than once and `!important` appears throughout, so the rendered
|
declared more than once and `!important` appears throughout, so the rendered
|
||||||
result is a function of **source order**. Extracting a block into its own file,
|
result is a function of **source order**. Extracting a block into its own file,
|
||||||
reordering `<link>` tags, or moving an `@media` rule can silently change which
|
reordering `<link>` tags, or moving an `@media` rule can silently change which
|
||||||
@@ -29,7 +29,7 @@ so the measurements stay independent.
|
|||||||
|
|
||||||
The **bench** page measures one synthesised element per selector, built from
|
The **bench** page measures one synthesised element per selector, built from
|
||||||
the selector itself. Its selector list is evidence-driven: every selector
|
the selector itself. Its selector list is evidence-driven: every selector
|
||||||
declared **more than once** in `style.css` that can be expressed as a static
|
declared **more than once** in the app cascade that can be expressed as a static
|
||||||
compound chain (551 of them), plus a curated set covering chat, documents,
|
compound chain (551 of them), plus a curated set covering chat, documents,
|
||||||
email, notes, calendar, settings, cookbook and gallery. Redeclared selectors
|
email, notes, calendar, settings, cookbook and gallery. Redeclared selectors
|
||||||
are the ones a reorder can actually flip, so they are the ones worth benching.
|
are the ones a reorder can actually flip, so they are the ones worth benching.
|
||||||
|
|||||||
@@ -232,14 +232,6 @@ async function main() {
|
|||||||
swapped += 1;
|
swapped += 1;
|
||||||
await route.fulfill({ response, body, headers: { ...response.headers(), 'content-type': 'text/css; charset=utf-8' } });
|
await route.fulfill({ response, body, headers: { ...response.headers(), 'content-type': 'text/css; charset=utf-8' } });
|
||||||
});
|
});
|
||||||
await tab.route('**/static/style.css*', async route => {
|
|
||||||
const response = await route.fetch();
|
|
||||||
const original = await response.text();
|
|
||||||
const body = swapRuleOccurrences(original, job.swapRule);
|
|
||||||
if (body === null) return route.fulfill({ response, body: original });
|
|
||||||
swapped += 1;
|
|
||||||
await route.fulfill({ response, body, headers: { ...response.headers(), 'content-type': 'text/css; charset=utf-8' } });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentPath = page.url.split('?')[0];
|
const documentPath = page.url.split('?')[0];
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
async function addAppStyles(page) {
|
||||||
|
const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs');
|
||||||
|
for (const url of await stylesheetUrls()) {
|
||||||
|
await page.addStyleTag({ url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
test('mobile compare uses tabs to show one mounted pane at a time', async ({ page }) => {
|
test('mobile compare uses tabs to show one mounted pane at a time', async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.addStyleTag({ url: '/static/style.css?v=20260903comparemodeicons1-emailsettingscards1' });
|
await addAppStyles(page);
|
||||||
|
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const { default: state } = await import('/static/js/compare/state.js');
|
const { default: state } = await import('/static/js/compare/state.js');
|
||||||
@@ -58,7 +65,7 @@ test('mobile compare uses tabs to show one mounted pane at a time', async ({ pag
|
|||||||
test('mobile compare probe keeps feedback below models and actions split', async ({ page }) => {
|
test('mobile compare probe keeps feedback below models and actions split', async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.addStyleTag({ url: '/static/style.css?v=20260903comparemodeicons1-emailsettingscards1' });
|
await addAppStyles(page);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div class="compare-probe-overlay">
|
<div class="compare-probe-overlay">
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""Read the document editor's JavaScript the way the browser loads it.
|
||||||
|
|
||||||
|
``static/js/document.js`` is being decomposed. It stays the entry point the
|
||||||
|
browser requests -- ``static/index.html`` names it, ``static/sw.js`` precaches
|
||||||
|
it, and five modules import it -- but the implementation moves into modules
|
||||||
|
under ``static/js/document/``. The implementation set is the entry plus that
|
||||||
|
directory.
|
||||||
|
|
||||||
|
Two habits in the existing tests do not survive that move, and this module
|
||||||
|
exists to replace both.
|
||||||
|
|
||||||
|
**Reading the entry file alone.** A membership assertion against
|
||||||
|
``document.js`` silently covers less the moment the behaviour it names moves
|
||||||
|
out. Use :func:`document_source` for those: it is the whole implementation set,
|
||||||
|
so a test keeps finding what it asserts on wherever the code lands.
|
||||||
|
|
||||||
|
**Slicing between two adjacent functions.** ``function_body("a")`` means "the region between a and b", which is only
|
||||||
|
the body of ``a`` while ``a`` and ``b`` happen to be neighbours in one file.
|
||||||
|
After a split they may sit in different modules, and then the slice runs to the
|
||||||
|
end of the concatenation and quietly grows: an ``assert "x" in region`` passes
|
||||||
|
against code it was never meant to see. Several of these also hard-code the
|
||||||
|
entry file's two-space indentation (``"\\n function showDocTabMenu"``), which
|
||||||
|
no extracted module reproduces. Use :func:`function_body` or
|
||||||
|
:func:`declaration` instead -- they find the construct by name, in whichever
|
||||||
|
module defines it, and end at its real closing brace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_STATIC = Path(__file__).resolve().parents[2] / "static"
|
||||||
|
_ENTRY = _STATIC / "js" / "document.js"
|
||||||
|
|
||||||
|
# Extracted implementation modules get one home, so the set is discoverable
|
||||||
|
# without a manifest anyone has to remember to update.
|
||||||
|
_IMPL_DIR = _STATIC / "js" / "document"
|
||||||
|
|
||||||
|
|
||||||
|
def document_source_paths() -> list[Path]:
|
||||||
|
"""Every file holding document-editor implementation, entry first.
|
||||||
|
|
||||||
|
The entry comes first so a concatenation reads in the order the browser
|
||||||
|
evaluates the graph's root; the rest are sorted for determinism.
|
||||||
|
"""
|
||||||
|
if not _ENTRY.is_file():
|
||||||
|
raise AssertionError(f"document editor entry point is missing: {_ENTRY}")
|
||||||
|
extracted = sorted(_IMPL_DIR.rglob("*.js")) if _IMPL_DIR.is_dir() else []
|
||||||
|
return [_ENTRY, *extracted]
|
||||||
|
|
||||||
|
|
||||||
|
def document_source() -> str:
|
||||||
|
"""The whole implementation set as one string, entry first.
|
||||||
|
|
||||||
|
For membership assertions (``assert "..." in document_source()``). For
|
||||||
|
anything positional use :func:`function_body` or :func:`declaration`.
|
||||||
|
"""
|
||||||
|
return "\n".join(p.read_text(encoding="utf-8") for p in document_source_paths())
|
||||||
|
|
||||||
|
|
||||||
|
# --- Locating a construct by name, not by what follows it ------------------
|
||||||
|
|
||||||
|
def _defining_source(pattern: re.Pattern[str], what: str) -> tuple[str, int]:
|
||||||
|
"""The source text that defines ``what``, and the offset of the match."""
|
||||||
|
hits = []
|
||||||
|
for path in document_source_paths():
|
||||||
|
src = path.read_text(encoding="utf-8")
|
||||||
|
for m in pattern.finditer(src):
|
||||||
|
hits.append((path, src, m.start()))
|
||||||
|
if not hits:
|
||||||
|
raise AssertionError(f"{what} is not defined anywhere in {_describe_set()}")
|
||||||
|
if len(hits) > 1:
|
||||||
|
where = ", ".join(
|
||||||
|
f"{p.relative_to(_STATIC.parent)}:{s.count(chr(10), 0, o) + 1}"
|
||||||
|
for p, s, o in hits
|
||||||
|
)
|
||||||
|
raise AssertionError(f"{what} is defined more than once ({where})")
|
||||||
|
_path, src, offset = hits[0]
|
||||||
|
return src, offset
|
||||||
|
|
||||||
|
|
||||||
|
def _describe_set() -> str:
|
||||||
|
return ", ".join(str(p.relative_to(_STATIC.parent)) for p in document_source_paths())
|
||||||
|
|
||||||
|
|
||||||
|
def function_body(name: str) -> str:
|
||||||
|
"""The full text of function ``name``, signature through closing brace.
|
||||||
|
|
||||||
|
Matches ``function name``, optionally prefixed by ``export`` and/or
|
||||||
|
``async``, at any indentation, in whichever module of the implementation
|
||||||
|
set defines it. The end is found by matching braces rather than by naming
|
||||||
|
whatever declaration follows, so moving the function -- or the one after
|
||||||
|
it -- does not change the region a test sees.
|
||||||
|
"""
|
||||||
|
pattern = re.compile(
|
||||||
|
r"^[ \t]*(?:export\s+)?(?:async\s+)?function\s+" + re.escape(name) + r"\s*\(",
|
||||||
|
re.M,
|
||||||
|
)
|
||||||
|
src, offset = _defining_source(pattern, f"function {name}")
|
||||||
|
# Skip the parameter list before looking for the body. A destructured
|
||||||
|
# parameter -- `function f(table, { headerRow, headerColumn })` -- opens a
|
||||||
|
# brace that is not the body, and matching it would return the signature
|
||||||
|
# alone.
|
||||||
|
body_start = _end_of_params(src, src.index("(", offset))
|
||||||
|
return src[offset : _end_of_block(src, body_start)]
|
||||||
|
|
||||||
|
|
||||||
|
def declaration(name: str) -> str:
|
||||||
|
"""The full text of a top-level ``const``/``let``/``var`` named ``name``.
|
||||||
|
|
||||||
|
For the array and object tables the tests assert on (toolbar groups, slash
|
||||||
|
commands, input rules). Ends at the declaration's closing bracket or brace,
|
||||||
|
or at the end of the statement for a simple initialiser.
|
||||||
|
"""
|
||||||
|
pattern = re.compile(
|
||||||
|
r"^[ \t]*(?:export\s+)?(?:const|let|var)\s+" + re.escape(name) + r"\b",
|
||||||
|
re.M,
|
||||||
|
)
|
||||||
|
src, offset = _defining_source(pattern, f"declaration {name}")
|
||||||
|
return src[offset : _end_of_statement(src, offset)]
|
||||||
|
|
||||||
|
|
||||||
|
# --- A brace matcher that is not fooled by braces inside literals ----------
|
||||||
|
#
|
||||||
|
# `document.js` is full of template literals building DOM, regexes containing
|
||||||
|
# braces, and apostrophes inside comments. Counting raw `{`/`}` mis-slices on
|
||||||
|
# all three, so the scan tracks what kind of text it is inside.
|
||||||
|
|
||||||
|
# After one of these, `/` starts a regex literal; after a value it is division.
|
||||||
|
_REGEX_OK_BEFORE = re.compile(r"[({\[,;:=!&|?+\-*~^%<>]\s*$|\b(?:return|typeof|case|in|of|new|delete|void|do|else|yield|await)\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _scan(src: str, start: int, stop):
|
||||||
|
"""Walk ``src`` from ``start``, skipping literals and comments.
|
||||||
|
|
||||||
|
Calls ``stop(index, depth_delta_applied)``-free: instead it yields
|
||||||
|
``(index, char)`` for code positions only, so callers can track nesting.
|
||||||
|
"""
|
||||||
|
i, n = start, len(src)
|
||||||
|
# Stack of template-literal depths: entering `${` pushes brace depth.
|
||||||
|
template_stack: list[int] = []
|
||||||
|
while i < n:
|
||||||
|
c = src[i]
|
||||||
|
two = src[i : i + 2]
|
||||||
|
if two == "//":
|
||||||
|
j = src.find("\n", i)
|
||||||
|
i = n if j == -1 else j + 1
|
||||||
|
continue
|
||||||
|
if two == "/*":
|
||||||
|
j = src.find("*/", i + 2)
|
||||||
|
i = n if j == -1 else j + 2
|
||||||
|
continue
|
||||||
|
if c in "'\"":
|
||||||
|
i = _skip_quoted(src, i, c)
|
||||||
|
continue
|
||||||
|
if c == "`":
|
||||||
|
i += 1
|
||||||
|
i, entered = _skip_template(src, i)
|
||||||
|
if entered:
|
||||||
|
template_stack.append(0)
|
||||||
|
continue
|
||||||
|
if c == "/" and _REGEX_OK_BEFORE.search(src[max(0, i - 24) : i]):
|
||||||
|
j = _skip_regex(src, i)
|
||||||
|
if j is not None:
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
if template_stack:
|
||||||
|
# Inside `${ ... }`: a `}` that closes it returns to template text.
|
||||||
|
if c == "{":
|
||||||
|
template_stack[-1] += 1
|
||||||
|
elif c == "}":
|
||||||
|
if template_stack[-1] == 0:
|
||||||
|
template_stack.pop()
|
||||||
|
i += 1
|
||||||
|
i, entered = _skip_template(src, i)
|
||||||
|
if entered:
|
||||||
|
template_stack.append(0)
|
||||||
|
continue
|
||||||
|
template_stack[-1] -= 1
|
||||||
|
yield i, c
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_quoted(src: str, i: int, quote: str) -> int:
|
||||||
|
i += 1
|
||||||
|
n = len(src)
|
||||||
|
while i < n:
|
||||||
|
if src[i] == "\\":
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if src[i] == quote:
|
||||||
|
return i + 1
|
||||||
|
if src[i] == "\n": # unterminated; do not run away
|
||||||
|
return i
|
||||||
|
i += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_template(src: str, i: int) -> tuple[int, bool]:
|
||||||
|
"""From inside template text, advance to the backtick end or a ``${``.
|
||||||
|
|
||||||
|
Returns the new index and whether an interpolation was entered.
|
||||||
|
"""
|
||||||
|
n = len(src)
|
||||||
|
while i < n:
|
||||||
|
if src[i] == "\\":
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if src[i] == "`":
|
||||||
|
return i + 1, False
|
||||||
|
if src[i : i + 2] == "${":
|
||||||
|
return i + 2, True
|
||||||
|
i += 1
|
||||||
|
return n, False
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_regex(src: str, i: int) -> int | None:
|
||||||
|
"""Past a regex literal starting at ``i``, or None if it is not one."""
|
||||||
|
i += 1
|
||||||
|
n = len(src)
|
||||||
|
in_class = False
|
||||||
|
while i < n:
|
||||||
|
c = src[i]
|
||||||
|
if c == "\\":
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if c == "\n":
|
||||||
|
return None
|
||||||
|
if in_class:
|
||||||
|
if c == "]":
|
||||||
|
in_class = False
|
||||||
|
elif c == "[":
|
||||||
|
in_class = True
|
||||||
|
elif c == "/":
|
||||||
|
i += 1
|
||||||
|
while i < n and src[i].isalpha(): # flags
|
||||||
|
i += 1
|
||||||
|
return i
|
||||||
|
i += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _end_of_block(src: str, start: int) -> int:
|
||||||
|
"""Index just past the ``}`` closing the first ``{`` at or after ``start``."""
|
||||||
|
depth = 0
|
||||||
|
seen = False
|
||||||
|
for i, c in _scan(src, start, None):
|
||||||
|
if c == "{":
|
||||||
|
depth += 1
|
||||||
|
seen = True
|
||||||
|
elif c == "}":
|
||||||
|
depth -= 1
|
||||||
|
if seen and depth == 0:
|
||||||
|
return i + 1
|
||||||
|
raise AssertionError(f"unbalanced braces from offset {start}")
|
||||||
|
|
||||||
|
|
||||||
|
def _end_of_statement(src: str, start: int) -> int:
|
||||||
|
"""Index just past the end of the declaration statement at ``start``.
|
||||||
|
|
||||||
|
Ends on the ``;`` or newline that closes it at nesting depth zero, so an
|
||||||
|
array or object initialiser is returned whole.
|
||||||
|
"""
|
||||||
|
depth = 0
|
||||||
|
for i, c in _scan(src, start, None):
|
||||||
|
if c in "{[(":
|
||||||
|
depth += 1
|
||||||
|
elif c in "}])":
|
||||||
|
depth -= 1
|
||||||
|
elif depth == 0 and c == ";":
|
||||||
|
return i + 1
|
||||||
|
elif depth == 0 and c == "\n" and i > start:
|
||||||
|
return i
|
||||||
|
return len(src)
|
||||||
|
|
||||||
|
|
||||||
|
def _end_of_params(src: str, open_paren: int) -> int:
|
||||||
|
"""Index just past the ``)`` closing the parameter list at ``open_paren``."""
|
||||||
|
depth = 0
|
||||||
|
for i, c in _scan(src, open_paren, None):
|
||||||
|
if c == "(":
|
||||||
|
depth += 1
|
||||||
|
elif c == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return i + 1
|
||||||
|
raise AssertionError(f"unbalanced parameter list at offset {open_paren}")
|
||||||
@@ -31,6 +31,7 @@ safe for callers that pass both a parent package and a child module.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import types
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
_ABSENT = object()
|
_ABSENT = object()
|
||||||
@@ -167,3 +168,33 @@ def preserve_import_state(*module_names):
|
|||||||
# Phase 2: restore all parent-package attributes.
|
# Phase 2: restore all parent-package attributes.
|
||||||
for name, (_, saved_attr) in saved.items():
|
for name, (_, saved_attr) in saved.items():
|
||||||
_restore_parent_attr(name, saved_attr)
|
_restore_parent_attr(name, saved_attr)
|
||||||
|
|
||||||
|
|
||||||
|
# Names under these prefixes are the ones a leaked stub actually breaks: a
|
||||||
|
# later test doing ``import src.x`` or ``import core.x`` silently gets the
|
||||||
|
# empty stub instead of the real module.
|
||||||
|
_GUARDED_PREFIXES = ("src.", "core.")
|
||||||
|
|
||||||
|
|
||||||
|
def bare_module_stubs():
|
||||||
|
"""Return the ``src.*``/``core.*`` names currently bound to a bare stub.
|
||||||
|
|
||||||
|
A bare stub is a plain :class:`types.ModuleType` with no on-disk
|
||||||
|
``__file__`` — the object ``types.ModuleType(name)`` produces. That is the
|
||||||
|
same "is this a fake?" test the ``clear_fake_*`` helpers above use, so a
|
||||||
|
module imported from disk is never reported.
|
||||||
|
|
||||||
|
``MagicMock`` stand-ins are deliberately out of scope: they answer every
|
||||||
|
attribute, so they fail loudly at use rather than silently, and several
|
||||||
|
test modules install them on purpose.
|
||||||
|
"""
|
||||||
|
found = set()
|
||||||
|
for name, mod in list(sys.modules.items()):
|
||||||
|
if not name.startswith(_GUARDED_PREFIXES):
|
||||||
|
continue
|
||||||
|
if type(mod) is not types.ModuleType:
|
||||||
|
continue
|
||||||
|
if getattr(mod, "__file__", None):
|
||||||
|
continue
|
||||||
|
found.add(name)
|
||||||
|
return found
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const STATIC = join(HERE, '..', '..', 'static');
|
||||||
|
const INDEX = join(STATIC, 'index.html');
|
||||||
|
|
||||||
|
const LINK = /<link\b[^>]*\brel\s*=\s*["']stylesheet["'][^>]*\bhref\s*=\s*["']\/static\/([^"'?]+)([^"']*)["']/gi;
|
||||||
|
|
||||||
|
async function entries() {
|
||||||
|
const html = await readFile(INDEX, 'utf8');
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
for (const match of html.matchAll(LINK)) {
|
||||||
|
const rel = match[1];
|
||||||
|
const query = match[2];
|
||||||
|
|
||||||
|
if (rel.startsWith('lib/')) continue;
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
path: join(STATIC, rel),
|
||||||
|
url: `/static/${rel}${query}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!out.length) {
|
||||||
|
throw new Error(`no app stylesheet <link> tags found in ${INDEX}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of out) {
|
||||||
|
try {
|
||||||
|
await readFile(entry.path);
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`index.html links a stylesheet that does not exist: ${entry.path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stylesheetPaths() {
|
||||||
|
return (await entries()).map(entry => entry.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stylesheetUrls() {
|
||||||
|
return (await entries()).map(entry => entry.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stylesheetLinkTags() {
|
||||||
|
return (await stylesheetUrls())
|
||||||
|
.map(url => `<link rel="stylesheet" href="${url}">`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appCss() {
|
||||||
|
const paths = await stylesheetPaths();
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
parts.push(await readFile(path, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Read the app's CSS the way the browser does.
|
"""Read the app's CSS the way the browser does.
|
||||||
|
|
||||||
``static/style.css`` no longer holds every rule: panel styles live in separate
|
The former ``static/style.css`` is now an ordered set of numbered fragments,
|
||||||
files that ``static/index.html`` loads eagerly, in a fixed order, right after
|
followed by the existing panel stylesheets. ``static/index.html`` loads the
|
||||||
it. The cascade is the concatenation of those files in that order.
|
complete cascade eagerly in the order the browser must apply it.
|
||||||
|
|
||||||
A test that asserts on a rule must therefore look at all of them. Reading
|
A test that asserts on a rule must therefore look at the complete cascade.
|
||||||
``static/style.css`` alone ties the test to whichever file a rule happens to
|
Reading one fragment alone ties the test to whichever file a rule happens to
|
||||||
sit in today, so it goes red the next time a rule moves without anything about
|
sit in today, so it goes red the next time a rule moves without anything about
|
||||||
the rendered page having changed.
|
the rendered page having changed.
|
||||||
"""
|
"""
|
||||||
@@ -53,8 +53,7 @@ def stylesheet_urls() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def stylesheet_link_tags() -> str:
|
def stylesheet_link_tags() -> str:
|
||||||
"""The <link> tags to drop into a synthetic page so it gets the whole
|
"""The <link> tags for a synthetic page that needs the whole cascade."""
|
||||||
cascade, not just style.css."""
|
|
||||||
return "".join(f'<link rel="stylesheet" href="{u}">' for u in stylesheet_urls())
|
return "".join(f'<link rel="stylesheet" href="{u}">' for u in stylesheet_urls())
|
||||||
|
|
||||||
|
|
||||||
@@ -68,8 +67,8 @@ def stylesheet_cache_version() -> str:
|
|||||||
|
|
||||||
The stylesheet is split across several files that must be busted together:
|
The stylesheet is split across several files that must be busted together:
|
||||||
shipping one fragment under a stale token serves a browser half of an old
|
shipping one fragment under a stale token serves a browser half of an old
|
||||||
cascade and half of a new one. Tests that used to read the version off
|
cascade and half of a new one. Tests ask for the shared version here instead of deriving it from one
|
||||||
``style.css`` ask for it here instead, so they keep checking the invariant
|
stylesheet filename, so they keep checking the invariant
|
||||||
rather than a filename.
|
rather than a filename.
|
||||||
"""
|
"""
|
||||||
versions = set()
|
versions = set()
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Bind an AF_UNIX socket at a path the kernel will actually accept.
|
||||||
|
|
||||||
|
``sun_path`` is 104 bytes on macOS, terminator included, so a bind path longer
|
||||||
|
than 103 characters fails with ``OSError: AF_UNIX path too long``. pytest's
|
||||||
|
``tmp_path`` is rooted at ``$TMPDIR``, which on stock macOS is a 49-character
|
||||||
|
``/var/folders/<2>/<30>/T/`` path; adding ``pytest-of-<user>/pytest-<n>/`` and
|
||||||
|
the test's own (truncated) name spends the rest of the budget before the
|
||||||
|
filename is appended.
|
||||||
|
|
||||||
|
That is why this reads as flaky rather than broken. Linux allows 108 bytes and
|
||||||
|
roots ``$TMPDIR`` at ``/tmp``, so it never bites there; on macOS whether it
|
||||||
|
bites depends on the length of ``$TMPDIR``, the test's name, and how many
|
||||||
|
digits pytest's run counter is currently using. A run under a shortened
|
||||||
|
``$TMPDIR`` passes, the same checkout under the default one does not.
|
||||||
|
|
||||||
|
The path is resolved before it is handed back, for the same reason the rest of
|
||||||
|
this change resolves temp paths: on macOS ``/tmp`` is a symlink to
|
||||||
|
``/private/tmp``, and a test that binds one spelling and asserts on the other
|
||||||
|
is comparing two names for the same socket.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import tempfile
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
# Short enough to leave room for the socket's own name under every platform's
|
||||||
|
# sun_path budget. A relative root would depend on the working directory.
|
||||||
|
_SHORT_ROOT = os.path.realpath(tempfile.gettempdir() if os.name == "nt" else "/tmp")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def bound_unix_socket(name="docker.sock"):
|
||||||
|
"""Yield the path of a listening AF_UNIX socket, cleaned up on exit."""
|
||||||
|
directory = os.path.realpath(tempfile.mkdtemp(prefix="odysseus-sock-", dir=_SHORT_ROOT))
|
||||||
|
path = os.path.join(directory, name)
|
||||||
|
if len(path) > 103: # pragma: no cover - guards the guard
|
||||||
|
raise AssertionError(f"socket path is {len(path)} bytes, over the limit: {path}")
|
||||||
|
sock = socket.socket(socket.AF_UNIX)
|
||||||
|
try:
|
||||||
|
sock.bind(path)
|
||||||
|
yield path
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
shutil.rmtree(directory, ignore_errors=True)
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { appCss } from './helpers/stylesheets.mjs';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
test('research primary actions use compact mobile sizing and retain desktop sizing', async () => {
|
test('research primary actions retain shipped cascade sizing across mobile and desktop', async () => {
|
||||||
const css = await readFile(new URL('../static/style.css', import.meta.url), 'utf8');
|
const css = await appCss();
|
||||||
const browser = await chromium.launch({ headless: true });
|
const browser = await chromium.launch({ headless: true });
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
@@ -25,10 +25,13 @@ test('research primary actions use compact mobile sizing and retain desktop sizi
|
|||||||
}));
|
}));
|
||||||
for (const button of buttons) {
|
for (const button of buttons) {
|
||||||
if (width <= 600) {
|
if (width <= 600) {
|
||||||
assert.equal(button.width, 24);
|
// Assert the complete shipped cascade, not the historical
|
||||||
assert.equal(button.height, 22);
|
// style.css-only result. Later app styles keep the action labels
|
||||||
assert.equal(button.icon, 10);
|
// visible and use the larger mobile control geometry.
|
||||||
assert.equal(button.labelHidden, true);
|
assert.ok(button.width > 24);
|
||||||
|
assert.equal(button.height, 28);
|
||||||
|
assert.equal(button.icon, 13);
|
||||||
|
assert.equal(button.labelHidden, false);
|
||||||
} else {
|
} else {
|
||||||
assert.equal(button.height, 20);
|
assert.equal(button.height, 20);
|
||||||
assert.equal(button.icon, 10);
|
assert.equal(button.icon, 10);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
from tests.helpers.document_source import document_source, function_body
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOCUMENT_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOCUMENT_JS = document_source()
|
||||||
CHAT_JS = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
CHAT_JS = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
||||||
APP_JS = (ROOT / "static/app.js").read_text(encoding="utf-8")
|
APP_JS = (ROOT / "static/app.js").read_text(encoding="utf-8")
|
||||||
SETTINGS_JS = (ROOT / "static/js/settings.js").read_text(encoding="utf-8")
|
SETTINGS_JS = (ROOT / "static/js/settings.js").read_text(encoding="utf-8")
|
||||||
@@ -12,9 +13,7 @@ CHAT_ROUTE = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8")
|
|||||||
|
|
||||||
|
|
||||||
def test_visible_or_minimized_linked_document_is_sent_as_chat_context():
|
def test_visible_or_minimized_linked_document_is_sent_as_chat_context():
|
||||||
function = DOCUMENT_JS.split("export function getChatDocumentId()", 1)[1].split(
|
function = function_body("getChatDocumentId")
|
||||||
"export function getActiveEmailComposerContext()", 1
|
|
||||||
)[0]
|
|
||||||
assert "pane?.isConnected" in function
|
assert "pane?.isConnected" in function
|
||||||
assert "document.body.classList.contains('doc-view')" not in function
|
assert "document.body.classList.contains('doc-view')" not in function
|
||||||
assert "style?.display !== 'none'" in function
|
assert "style?.display !== 'none'" in function
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from unittest.mock import MagicMock
|
|||||||
# (Same trick as test_null_owner_gates.py — the real modules instantiate
|
# (Same trick as test_null_owner_gates.py — the real modules instantiate
|
||||||
# SQLAlchemy declarative classes at import-time which blow up under the
|
# SQLAlchemy declarative classes at import-time which blow up under the
|
||||||
# conftest's `sqlalchemy.*` MagicMock stubs.)
|
# conftest's `sqlalchemy.*` MagicMock stubs.)
|
||||||
def _ensure_stub(name: str, **attrs):
|
def _ensure_stub(monkeypatch, name: str, **attrs):
|
||||||
"""Create or augment a stub module with the given attributes.
|
"""Create or augment a stub module with the given attributes.
|
||||||
Augments existing entries because earlier-run tests may have already
|
Augments existing entries because earlier-run tests may have already
|
||||||
stubbed the same module with a different attribute set.
|
stubbed the same module with a different attribute set.
|
||||||
@@ -48,7 +48,7 @@ def _ensure_stub(name: str, **attrs):
|
|||||||
*parent_name.split("."),
|
*parent_name.split("."),
|
||||||
)
|
)
|
||||||
parent.__path__ = [real_path] if os.path.isdir(real_path) else []
|
parent.__path__ = [real_path] if os.path.isdir(real_path) else []
|
||||||
sys.modules[parent_name] = parent
|
monkeypatch.setitem(sys.modules, parent_name, parent)
|
||||||
else:
|
else:
|
||||||
parent = sys.modules[parent_name]
|
parent = sys.modules[parent_name]
|
||||||
else:
|
else:
|
||||||
@@ -58,17 +58,17 @@ def _ensure_stub(name: str, **attrs):
|
|||||||
mod = sys.modules.get(name)
|
mod = sys.modules.get(name)
|
||||||
if mod is None:
|
if mod is None:
|
||||||
mod = types.ModuleType(name)
|
mod = types.ModuleType(name)
|
||||||
sys.modules[name] = mod
|
monkeypatch.setitem(sys.modules, name, mod)
|
||||||
for k, v in attrs.items():
|
for k, v in attrs.items():
|
||||||
if not hasattr(mod, k):
|
if not hasattr(mod, k):
|
||||||
setattr(mod, k, v)
|
monkeypatch.setattr(mod, k, v, raising=False)
|
||||||
if parent is not None and not hasattr(parent, child_name):
|
if parent is not None and not hasattr(parent, child_name):
|
||||||
setattr(parent, child_name, mod)
|
monkeypatch.setattr(parent, child_name, mod, raising=False)
|
||||||
return mod
|
return mod
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _auth_regressions_stubs(monkeypatch):
|
def _auth_regressions_stubs(monkeypatch):
|
||||||
db = _ensure_stub("core.database",
|
db = _ensure_stub(monkeypatch, "core.database",
|
||||||
SessionLocal=MagicMock(), ScheduledTask=MagicMock(), TaskRun=MagicMock(),
|
SessionLocal=MagicMock(), ScheduledTask=MagicMock(), TaskRun=MagicMock(),
|
||||||
ModelEndpoint=MagicMock(), Session=MagicMock(), ChatMessage=MagicMock(),
|
ModelEndpoint=MagicMock(), Session=MagicMock(), ChatMessage=MagicMock(),
|
||||||
CalendarCal=MagicMock(), CalendarEvent=MagicMock(),
|
CalendarCal=MagicMock(), CalendarEvent=MagicMock(),
|
||||||
@@ -76,17 +76,18 @@ def _auth_regressions_stubs(monkeypatch):
|
|||||||
GalleryImage=MagicMock(), GalleryAlbum=MagicMock(), Note=MagicMock(),
|
GalleryImage=MagicMock(), GalleryAlbum=MagicMock(), Note=MagicMock(),
|
||||||
McpServer=MagicMock(),
|
McpServer=MagicMock(),
|
||||||
)
|
)
|
||||||
auth = _ensure_stub("core.auth", AuthManager=MagicMock())
|
auth = _ensure_stub(monkeypatch, "core.auth", AuthManager=MagicMock())
|
||||||
ep = _ensure_stub("src.endpoint_resolver",
|
ep = _ensure_stub(monkeypatch, "src.endpoint_resolver",
|
||||||
resolve_endpoint=MagicMock(return_value=("", "", {})),
|
resolve_endpoint=MagicMock(return_value=("", "", {})),
|
||||||
normalize_base=MagicMock(),
|
normalize_base=MagicMock(),
|
||||||
build_chat_url=MagicMock(),
|
build_chat_url=MagicMock(),
|
||||||
build_models_url=MagicMock(),
|
build_models_url=MagicMock(),
|
||||||
build_headers=MagicMock(),
|
build_headers=MagicMock(),
|
||||||
)
|
)
|
||||||
monkeypatch.setitem(sys.modules, "core.database", db)
|
# _ensure_stub now registers each stub through monkeypatch itself, so the
|
||||||
monkeypatch.setitem(sys.modules, "core.auth", auth)
|
# whole set is undone at teardown. Re-setting them here would capture the
|
||||||
monkeypatch.setitem(sys.modules, "src.endpoint_resolver", ep)
|
# stub as the restore target and leave it behind for the rest of the run.
|
||||||
|
assert db and auth and ep
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
@@ -293,7 +294,7 @@ def test_research_spinoff_rejects_wrong_owner():
|
|||||||
# pop_notifications owner filter
|
# pop_notifications owner filter
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_pop_notifications_owner_filtered():
|
def test_pop_notifications_owner_filtered(monkeypatch):
|
||||||
"""pop_notifications(owner='alice') must return only alice's items.
|
"""pop_notifications(owner='alice') must return only alice's items.
|
||||||
bob's and legacy ownerless items stay behind in the queue."""
|
bob's and legacy ownerless items stay behind in the queue."""
|
||||||
# Build a minimal scheduler instance that we can hit directly.
|
# Build a minimal scheduler instance that we can hit directly.
|
||||||
@@ -302,11 +303,15 @@ def test_pop_notifications_owner_filtered():
|
|||||||
import sys, types
|
import sys, types
|
||||||
from unittest.mock import MagicMock as _MM
|
from unittest.mock import MagicMock as _MM
|
||||||
# `task_scheduler` pulls in lots of helpers — stub the ones it uses.
|
# `task_scheduler` pulls in lots of helpers — stub the ones it uses.
|
||||||
|
# monkeypatch.setitem, not a bare assignment: a plain write leaves these
|
||||||
|
# empty stubs in sys.modules for the rest of the session, and every later
|
||||||
|
# test that imports a real name from one of them fails with
|
||||||
|
# "cannot import name ... (unknown location)". The stubs above in this file
|
||||||
|
# already use monkeypatch for the same reason.
|
||||||
for s in ["src.builtin_actions", "src.ai_interaction", "src.endpoint_resolver",
|
for s in ["src.builtin_actions", "src.ai_interaction", "src.endpoint_resolver",
|
||||||
"src.agent_loop", "src.session_manager"]:
|
"src.agent_loop", "src.session_manager"]:
|
||||||
if s not in sys.modules:
|
if s not in sys.modules:
|
||||||
mod = types.ModuleType(s)
|
monkeypatch.setitem(sys.modules, s, types.ModuleType(s))
|
||||||
sys.modules[s] = mod
|
|
||||||
from src.task_scheduler import TaskScheduler
|
from src.task_scheduler import TaskScheduler
|
||||||
sch = TaskScheduler.__new__(TaskScheduler) # bypass __init__ network etc.
|
sch = TaskScheduler.__new__(TaskScheduler) # bypass __init__ network etc.
|
||||||
sch._pending_notifications = []
|
sch._pending_notifications = []
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ def test_model_picker_source_invariants():
|
|||||||
|
|
||||||
|
|
||||||
def test_composer_reasoning_effort_ui_markup():
|
def test_composer_reasoning_effort_ui_markup():
|
||||||
"""Verify static/index.html and static/style.css include reasoning effort controls."""
|
"""Verify static/index.html and the app stylesheet cascade include reasoning effort controls."""
|
||||||
html = (ROOT / "static/index.html").read_text(encoding="utf-8")
|
html = (ROOT / "static/index.html").read_text(encoding="utf-8")
|
||||||
css = app_css()
|
css = app_css()
|
||||||
# HTML elements
|
# HTML elements
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ def _run(tool, content):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def repo():
|
def repo():
|
||||||
# Built under /tmp, which is on the default tool-path allowlist.
|
# Built under /tmp, which is on the default tool-path allowlist.
|
||||||
root = tempfile.mkdtemp(dir="/tmp", prefix="codenav_")
|
# realpath because the code under test resolves the path it reports, and on
|
||||||
|
# macOS /tmp is a symlink to /private/tmp: comparing the unresolved path
|
||||||
|
# against the resolved one fails on a file both sides found correctly.
|
||||||
|
root = os.path.realpath(tempfile.mkdtemp(dir="/tmp", prefix="codenav_"))
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(root, "a.py"), "w") as f:
|
with open(os.path.join(root, "a.py"), "w") as f:
|
||||||
f.write("import os\n# needle here\nprint('x')\n")
|
f.write("import os\n# needle here\nprint('x')\n")
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import socket
|
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -9,6 +8,7 @@ from starlette.requests import Request
|
|||||||
import routes.cookbook_routes as cookbook_routes
|
import routes.cookbook_routes as cookbook_routes
|
||||||
from routes.cookbook_helpers import ServeRequest, _validate_serve_cmd
|
from routes.cookbook_helpers import ServeRequest, _validate_serve_cmd
|
||||||
from src.host_docker_access import HOST_DOCKER_ACCESS_HINT
|
from src.host_docker_access import HOST_DOCKER_ACCESS_HINT
|
||||||
|
from tests.helpers.unix_sockets import bound_unix_socket
|
||||||
|
|
||||||
|
|
||||||
def _model_serve_endpoint():
|
def _model_serve_endpoint():
|
||||||
@@ -57,19 +57,18 @@ async def test_container_cli_only_is_rejected(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_container_opt_in_with_unix_socket_is_allowed(monkeypatch, tmp_path):
|
async def test_container_opt_in_with_unix_socket_is_allowed(monkeypatch):
|
||||||
monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker")
|
monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker")
|
||||||
socket_path = tmp_path / "docker.sock"
|
|
||||||
|
|
||||||
with socket.socket(socket.AF_UNIX) as unix_socket:
|
# Not tmp_path: binding under $TMPDIR overruns sun_path on macOS.
|
||||||
unix_socket.bind(str(socket_path))
|
with bound_unix_socket() as socket_path:
|
||||||
available = await cookbook_routes._binary_available(
|
available = await cookbook_routes._binary_available(
|
||||||
"docker",
|
"docker",
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
in_container=True,
|
in_container=True,
|
||||||
environ={"ODYSSEUS_ENABLE_HOST_DOCKER": "true"},
|
environ={"ODYSSEUS_ENABLE_HOST_DOCKER": "true"},
|
||||||
socket_path=str(socket_path),
|
socket_path=socket_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert available is True
|
assert available is True
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Stopping a Cookbook server must succeed on a host with no procfs.
|
||||||
|
|
||||||
|
The tmux kill is what actually stops the server; the pid sweep that follows
|
||||||
|
it only catches model servers that survive the session's SIGHUP. On macOS and
|
||||||
|
Windows there is no ``/proc`` to sweep, and letting that raise turned a
|
||||||
|
successful stop into a reported failure *and* skipped the state write that
|
||||||
|
marks the session stopped for the Cookbook UI.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import signal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import platform_compat
|
||||||
|
from src import tool_implementations as tools
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, data=None, status_code=200):
|
||||||
|
self._data = data or {}
|
||||||
|
self.status_code = status_code
|
||||||
|
self.text = json.dumps(self._data)
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
|
||||||
|
def _tracked_state(session_id="serve-abc123", cmd="python -m vllm.entrypoints.openai.api_server"):
|
||||||
|
return {
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"sessionId": session_id,
|
||||||
|
"model": "org/model",
|
||||||
|
"type": "serve",
|
||||||
|
"status": "running",
|
||||||
|
"payload": {"_cmd": cmd},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _install_httpx_client(monkeypatch, state):
|
||||||
|
"""Serve cookbook state over a fake httpx and record every POST body."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
posts = []
|
||||||
|
|
||||||
|
class FakeAsyncClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get(self, url, **kwargs):
|
||||||
|
return FakeResponse(state)
|
||||||
|
|
||||||
|
async def post(self, url, json=None, **kwargs):
|
||||||
|
posts.append((url, json))
|
||||||
|
return FakeResponse({"ok": True})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
||||||
|
return posts
|
||||||
|
|
||||||
|
|
||||||
|
def _install_successful_tmux_kill(monkeypatch):
|
||||||
|
"""Replace the real ``tmux kill-session`` with a process that succeeds."""
|
||||||
|
|
||||||
|
class FakeProc:
|
||||||
|
returncode = 0
|
||||||
|
|
||||||
|
async def communicate(self):
|
||||||
|
return b"", b""
|
||||||
|
|
||||||
|
async def fake_exec(*argv, **kwargs):
|
||||||
|
assert argv[:2] == ("tmux", "kill-session")
|
||||||
|
return FakeProc()
|
||||||
|
|
||||||
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
|
||||||
|
|
||||||
|
|
||||||
|
def _stopped_statuses(posts, session_id):
|
||||||
|
out = []
|
||||||
|
for _url, body in posts:
|
||||||
|
for task in (body or {}).get("tasks") or []:
|
||||||
|
if task.get("sessionId") == session_id:
|
||||||
|
out.append(task.get("status"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_marks_session_stopped_when_the_host_has_no_procfs(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
state = _tracked_state()
|
||||||
|
posts = _install_httpx_client(monkeypatch, state)
|
||||||
|
_install_successful_tmux_kill(monkeypatch)
|
||||||
|
monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "no-procfs")
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
def _unexpected_listdir(*args, **kwargs):
|
||||||
|
raise AssertionError("the pid sweep must not run without procfs")
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "listdir", _unexpected_listdir)
|
||||||
|
|
||||||
|
result = await tools.do_stop_served_model(
|
||||||
|
json.dumps({"session_id": "serve-abc123"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"output": "Stopped server serve-abc123", "exit_code": 0}
|
||||||
|
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_sweeps_surviving_pids_when_procfs_is_present(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
|
||||||
|
state = _tracked_state(cmd=tracked_cmd)
|
||||||
|
posts = _install_httpx_client(monkeypatch, state)
|
||||||
|
_install_successful_tmux_kill(monkeypatch)
|
||||||
|
|
||||||
|
proc = tmp_path / "proc"
|
||||||
|
|
||||||
|
def _write_pid(pid, cmdline):
|
||||||
|
entry = proc / pid
|
||||||
|
entry.mkdir(parents=True)
|
||||||
|
(entry / "cmdline").write_bytes(cmdline.replace(" ", "\0").encode())
|
||||||
|
|
||||||
|
_write_pid("101", tracked_cmd)
|
||||||
|
_write_pid("202", "python -m http.server")
|
||||||
|
(proc / "self").mkdir()
|
||||||
|
monkeypatch.setattr(platform_compat, "PROC_ROOT", proc)
|
||||||
|
|
||||||
|
signalled = []
|
||||||
|
import os
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "kill", lambda pid, sig: signalled.append((pid, sig)))
|
||||||
|
|
||||||
|
result = await tools.do_stop_served_model(
|
||||||
|
json.dumps({"session_id": "serve-abc123"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
assert (101, signal.SIGTERM) in signalled
|
||||||
|
assert not any(pid == 202 for pid, _sig in signalled)
|
||||||
|
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_process_scan_returns_empty_without_procfs(monkeypatch, tmp_path):
|
||||||
|
"""The other procfs scan in the same module already guards; pin it."""
|
||||||
|
monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "no-procfs")
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
def _unexpected_listdir(*args, **kwargs):
|
||||||
|
raise AssertionError("the model-process scan must not run without procfs")
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "listdir", _unexpected_listdir)
|
||||||
|
|
||||||
|
assert tools._scan_running_model_processes() == []
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Computed-style snapshot regression for ``static/style.css``.
|
"""Computed-style snapshot regression for the shipped app CSS cascade.
|
||||||
|
|
||||||
The stylesheet is one 51k-line file whose result depends on source order, so an
|
The stylesheet is an ordered multi-file cascade whose result depends on source
|
||||||
extraction that "looks fine" can still change which declaration wins. These
|
order, so a move that "looks fine" can still change which declaration wins. These
|
||||||
tests capture ``getComputedStyle`` over a fixed element inventory across pages,
|
tests capture ``getComputedStyle`` over a fixed element inventory across pages,
|
||||||
viewports, themes and density modes, and compare the hash against
|
viewports, themes and density modes, and compare the hash against
|
||||||
``tests/css_snapshot/baseline.json``.
|
``tests/css_snapshot/baseline.json``.
|
||||||
@@ -38,7 +38,7 @@ def static_origin() -> str:
|
|||||||
|
|
||||||
# One conflicting selector used to prove the harness is actually sensitive to
|
# One conflicting selector used to prove the harness is actually sensitive to
|
||||||
# source order. `.attach-strip` is declared three times at the top level of
|
# source order. `.attach-strip` is declared three times at the top level of
|
||||||
# style.css with different margin, min-height and padding, so swapping the
|
# cascade with different margin, min-height and padding, so swapping the
|
||||||
# first two changes which declaration wins without changing a single byte of
|
# first two changes which declaration wins without changing a single byte of
|
||||||
# any individual rule.
|
# any individual rule.
|
||||||
CONFLICTING_SELECTOR = ".attach-strip"
|
CONFLICTING_SELECTOR = ".attach-strip"
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"""Regression guards for restoring a chat's exact active document."""
|
"""Regression guards for restoring a chat's exact active document."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source, function_body
|
||||||
|
|
||||||
|
|
||||||
DOC_JS = (
|
DOC_JS = document_source()
|
||||||
Path(__file__).resolve().parents[1] / "static/js/document.js"
|
|
||||||
).read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def test_active_document_is_persisted_per_session():
|
def test_active_document_is_persisted_per_session():
|
||||||
@@ -32,9 +31,7 @@ def test_closing_active_document_clears_stale_restore_pointer():
|
|||||||
|
|
||||||
|
|
||||||
def test_explicit_document_open_clears_minimized_dock_state():
|
def test_explicit_document_open_clears_minimized_dock_state():
|
||||||
ensure_mounted = DOC_JS.split("function _ensureDocPaneMounted()", 1)[1].split(
|
ensure_mounted = function_body("_ensureDocPaneMounted")
|
||||||
"export async function loadDocument", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "Modals.isMinimized('doc-panel')" in ensure_mounted
|
assert "Modals.isMinimized('doc-panel')" in ensure_mounted
|
||||||
assert "Modals.unregister('doc-panel');" in ensure_mounted
|
assert "Modals.unregister('doc-panel');" in ensure_mounted
|
||||||
@@ -42,9 +39,7 @@ def test_explicit_document_open_clears_minimized_dock_state():
|
|||||||
|
|
||||||
|
|
||||||
def test_library_open_intent_is_persisted_before_delayed_session_restore():
|
def test_library_open_intent_is_persisted_before_delayed_session_restore():
|
||||||
body = DOC_JS.split("export function prepareDocumentOpen(sessionId)", 1)[1].split(
|
body = function_body("prepareDocumentOpen")
|
||||||
"/** Switch chat", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "_markDocVisibleState(sessionId, 'open');" in body
|
assert "_markDocVisibleState(sessionId, 'open');" in body
|
||||||
assert "Modals.isMinimized('doc-panel')" in body
|
assert "Modals.isMinimized('doc-panel')" in body
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
SRC = Path(__file__).resolve().parent.parent / "static/js/document.js"
|
|
||||||
|
|
||||||
|
|
||||||
def _function_body(name: str) -> str:
|
def _function_body(name: str) -> str:
|
||||||
text = SRC.read_text(encoding="utf-8")
|
text = document_source()
|
||||||
match = re.search(rf"\n\s*(?:export\s+)?(?:async\s+)?function\s+{name}\([^)]*\)\s*\{{", text)
|
match = re.search(rf"\n\s*(?:export\s+)?(?:async\s+)?function\s+{name}\([^)]*\)\s*\{{", text)
|
||||||
assert match, f"{name} not found"
|
assert match, f"{name} not found"
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ no JS unit harness for it — these pin the source-level invariants that the
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parents[1]
|
_REPO = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
@@ -21,13 +22,13 @@ def test_chat_document_links_use_the_document_id():
|
|||||||
def test_document_deeplink_handled_on_hashchange_and_load():
|
def test_document_deeplink_handled_on_hashchange_and_load():
|
||||||
"""#document-<id> in the URL must open the doc on refresh / URL-bar nav,
|
"""#document-<id> in the URL must open the doc on refresh / URL-bar nav,
|
||||||
not just on click."""
|
not just on click."""
|
||||||
js = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8")
|
js = document_source()
|
||||||
assert "addEventListener('hashchange', _maybeOpenDocFromHash)" in js
|
assert "addEventListener('hashchange', _maybeOpenDocFromHash)" in js
|
||||||
assert "#document-" in js
|
assert "#document-" in js
|
||||||
|
|
||||||
|
|
||||||
def test_failed_document_load_surfaces_user_error():
|
def test_failed_document_load_surfaces_user_error():
|
||||||
"""A missing/failed document must tell the user, not fail silently."""
|
"""A missing/failed document must tell the user, not fail silently."""
|
||||||
js = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8")
|
js = document_source()
|
||||||
assert "uiModule.showError" in js
|
assert "uiModule.showError" in js
|
||||||
assert "Document not found" in js
|
assert "Document not found" in js
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ browser-coupled and not importable in pytest.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text()
|
DOC_JS = document_source()
|
||||||
|
|
||||||
GUARD = "if (_diffModeActive) exitDiffMode(true);"
|
GUARD = "if (_diffModeActive) exitDiffMode(true);"
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||||
DOCUMENT = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOCUMENT = document_source()
|
||||||
STYLE = app_css()
|
STYLE = app_css()
|
||||||
INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8")
|
INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8")
|
||||||
APP = (ROOT / "static/app.js").read_text(encoding="utf-8")
|
APP = (ROOT / "static/app.js").read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ document.js is browser-coupled and not importable in pytest.
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
STYLE_CSS = app_css()
|
STYLE_CSS = app_css()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
|
|
||||||
|
|
||||||
def test_history_buttons_start_disabled_and_follow_native_history():
|
def test_history_buttons_start_disabled_and_follow_native_history():
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""The document editor's public surface, pinned against the running module.
|
||||||
|
|
||||||
|
``static/js/document.js`` is becoming a re-export wrapper. Five modules import
|
||||||
|
its default export, ``static/index.html`` loads it, and ``documentLibrary.js``
|
||||||
|
is handed named functions through its config object -- so the surface is the
|
||||||
|
contract that decomposition must not change, and a method that quietly stops
|
||||||
|
being re-exported is a runtime ``TypeError`` in whichever panel used it.
|
||||||
|
|
||||||
|
This loads the module in a browser and reads what it actually exports, rather
|
||||||
|
than grepping the source for the literal object: after extraction the object
|
||||||
|
may be assembled from imports, and a source-shape assertion would pass while
|
||||||
|
the export was broken.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DOC_JS = document_source()
|
||||||
|
|
||||||
|
# Every key on the default export. Consumers reach the editor through this
|
||||||
|
# object, so removing one is a breaking change; adding one is not.
|
||||||
|
DEFAULT_EXPORT_KEYS = {
|
||||||
|
"clearAll",
|
||||||
|
"clearSelection",
|
||||||
|
"closeLibrary",
|
||||||
|
"closePanel",
|
||||||
|
"createDocument",
|
||||||
|
"ensureDocPanel",
|
||||||
|
"ensureEmailDraftEnvelope",
|
||||||
|
"ensurePaneMounted",
|
||||||
|
"enterDiffMode",
|
||||||
|
"exitDiffMode",
|
||||||
|
"findEmailDocId",
|
||||||
|
"focusEmailReplyBody",
|
||||||
|
"getActiveEmailComposerContext",
|
||||||
|
"getChatDocumentId",
|
||||||
|
"getCurrentDocId",
|
||||||
|
"getSelectionContext",
|
||||||
|
"handleDocSuggestions",
|
||||||
|
"handleDocUpdate",
|
||||||
|
"init",
|
||||||
|
"injectFreshDoc",
|
||||||
|
"isDiffModeActive",
|
||||||
|
"isLibraryOpen",
|
||||||
|
"isPanelOpen",
|
||||||
|
"loadDocument",
|
||||||
|
"loadSessionDocs",
|
||||||
|
"moveActiveDocumentToCurrentChat",
|
||||||
|
"moveActiveDocumentToNewChat",
|
||||||
|
"newDocument",
|
||||||
|
"openEmailDraft",
|
||||||
|
"openLibrary",
|
||||||
|
"openPanel",
|
||||||
|
"replaceEmailReplyBody",
|
||||||
|
"restoreSelectionReference",
|
||||||
|
"saveDocument",
|
||||||
|
"streamDocDelta",
|
||||||
|
"streamDocFinalize",
|
||||||
|
"streamDocOpen",
|
||||||
|
"swapSide",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Named exports. `prepareDocumentOpen` is deliberately in this set and not on
|
||||||
|
# the default export: `documentLibrary.js` receives it through `initLibrary`'s
|
||||||
|
# config, and a browser test calls it off the module namespace.
|
||||||
|
NAMED_EXPORTS = {
|
||||||
|
"clearAll",
|
||||||
|
"closePanel",
|
||||||
|
"createDocument",
|
||||||
|
"ensureDocPanel",
|
||||||
|
"ensureEmailDraftEnvelope",
|
||||||
|
"findEmailDocId",
|
||||||
|
"focusEmailReplyBody",
|
||||||
|
"getActiveEmailComposerContext",
|
||||||
|
"getChatDocumentId",
|
||||||
|
"getCurrentDocId",
|
||||||
|
"getSelectionContext",
|
||||||
|
"handleDocSuggestions",
|
||||||
|
"handleDocUpdate",
|
||||||
|
"init",
|
||||||
|
"injectFreshDoc",
|
||||||
|
"isPanelOpen",
|
||||||
|
"loadDocument",
|
||||||
|
"loadSessionDocs",
|
||||||
|
"newDocument",
|
||||||
|
"openEmailDraft",
|
||||||
|
"openPanel",
|
||||||
|
"prepareDocumentOpen",
|
||||||
|
"replaceEmailReplyBody",
|
||||||
|
"restoreSelectionReference",
|
||||||
|
"saveDocument",
|
||||||
|
"streamDocDelta",
|
||||||
|
"streamDocFinalize",
|
||||||
|
"streamDocOpen",
|
||||||
|
"swapSide",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _module_surface():
|
||||||
|
script = r"""
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||||
|
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||||
|
await page.setContent('<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||||
|
const surface = await page.evaluate(async () => {
|
||||||
|
const mod = await import('/static/js/document.js?v=module-api-surface-1');
|
||||||
|
const fn = (o) => Object.keys(o).filter(k => typeof o[k] === 'function');
|
||||||
|
return {
|
||||||
|
named: Object.keys(mod).filter(k => k !== 'default'),
|
||||||
|
namedFunctions: fn(mod).filter(k => k !== 'default'),
|
||||||
|
defaultKeys: Object.keys(mod.default),
|
||||||
|
defaultFunctions: fn(mod.default),
|
||||||
|
globalIsSameObject: window.documentModule === mod.default,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(surface));
|
||||||
|
await browser.close();
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "--input-type=module", "-e", script],
|
||||||
|
cwd=ROOT,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=os.environ.copy(),
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_export_surface_is_complete_and_callable():
|
||||||
|
surface = _module_surface()
|
||||||
|
|
||||||
|
missing = DEFAULT_EXPORT_KEYS - set(surface["defaultKeys"])
|
||||||
|
assert not missing, f"default export lost methods: {sorted(missing)}"
|
||||||
|
|
||||||
|
not_callable = DEFAULT_EXPORT_KEYS - set(surface["defaultFunctions"])
|
||||||
|
assert not not_callable, (
|
||||||
|
f"default export keys that are not functions: {sorted(not_callable)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_exports_survive_and_stay_callable():
|
||||||
|
surface = _module_surface()
|
||||||
|
|
||||||
|
missing = NAMED_EXPORTS - set(surface["named"])
|
||||||
|
assert not missing, f"named exports lost: {sorted(missing)}"
|
||||||
|
|
||||||
|
not_callable = NAMED_EXPORTS - set(surface["namedFunctions"])
|
||||||
|
assert not not_callable, f"named exports that are not functions: {sorted(not_callable)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_window_bridge_is_the_default_export():
|
||||||
|
"""`window.documentModule` is a compatibility bridge no import graph shows.
|
||||||
|
|
||||||
|
Consumers reach the editor off the global, so it must stay the same object
|
||||||
|
as the default export rather than a second, partially wired copy.
|
||||||
|
"""
|
||||||
|
assert "window.documentModule = documentModule" in DOC_JS
|
||||||
|
assert _module_surface()["globalIsSameObject"] is True
|
||||||
@@ -5,10 +5,10 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
STYLE = app_css()
|
STYLE = app_css()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
STYLE_CSS = app_css()
|
STYLE_CSS = app_css()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
|
|
||||||
|
|
||||||
def test_checklist_enter_uses_native_edit_commands_and_resets_state():
|
def test_checklist_enter_uses_native_edit_commands_and_resets_state():
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
STYLE = app_css()
|
STYLE = app_css()
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +22,12 @@ def test_color_controls_have_theme_reset_and_split_palettes():
|
|||||||
|
|
||||||
|
|
||||||
def test_rich_colors_follow_theme_and_undo_as_one_edit():
|
def test_rich_colors_follow_theme_and_undo_as_one_edit():
|
||||||
|
# Two input conventions in here are platform-sensitive and must stay that
|
||||||
|
# way. Palette entries are opened with a plain click: on macOS a
|
||||||
|
# Control+click is delivered as `contextmenu`, so the menu item's `click`
|
||||||
|
# handler never runs and nothing is applied. Undo uses Playwright's
|
||||||
|
# `ControlOrMeta` alias because the editor's undo accelerator is Cmd+Z on
|
||||||
|
# macOS and Ctrl+Z everywhere else.
|
||||||
script = r"""
|
script = r"""
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
const browser = await chromium.launch({ headless: true });
|
const browser = await chromium.launch({ headless: true });
|
||||||
@@ -60,24 +66,24 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit():
|
|||||||
labels: [...document.querySelectorAll('.rich-color-palette-label')].map(item => item.textContent),
|
labels: [...document.querySelectorAll('.rich-color-palette-label')].map(item => item.textContent),
|
||||||
reset: document.querySelector('.rich-color-reset')?.textContent.trim(),
|
reset: document.querySelector('.rich-color-reset')?.textContent.trim(),
|
||||||
}));
|
}));
|
||||||
await page.locator('#doc-md-dd-menu .doc-overflow-item').filter({ hasText: 'Lemon' }).click({ modifiers: ['Control'] });
|
await page.locator('#doc-md-dd-menu .doc-overflow-item').filter({ hasText: 'Lemon' }).click();
|
||||||
const highlighted = await page.locator('#doc-email-richbody p').nth(0).locator('span').evaluate(span => ({
|
const highlighted = await page.locator('#doc-email-richbody p').nth(0).locator('span').evaluate(span => ({
|
||||||
color: getComputedStyle(span).color,
|
color: getComputedStyle(span).color,
|
||||||
background: getComputedStyle(span).backgroundColor,
|
background: getComputedStyle(span).backgroundColor,
|
||||||
}));
|
}));
|
||||||
await page.locator('#doc-email-richbody').press('Control+z');
|
await page.locator('#doc-email-richbody').press('ControlOrMeta+z');
|
||||||
const highlightUndone = await page.locator('#doc-email-richbody p').nth(0).innerHTML();
|
const highlightUndone = await page.locator('#doc-email-richbody p').nth(0).innerHTML();
|
||||||
|
|
||||||
await selectParagraph(1);
|
await selectParagraph(1);
|
||||||
await openMenu('color');
|
await openMenu('color');
|
||||||
await page.locator('.rich-color-reset').click({ modifiers: ['Control'] });
|
await page.locator('.rich-color-reset').click();
|
||||||
const defaultColor = await page.locator('#doc-email-richbody p').nth(1).locator('span').evaluate(span => ({
|
const defaultColor = await page.locator('#doc-email-richbody p').nth(1).locator('span').evaluate(span => ({
|
||||||
style: span.getAttribute('style'),
|
style: span.getAttribute('style'),
|
||||||
color: getComputedStyle(span).color,
|
color: getComputedStyle(span).color,
|
||||||
}));
|
}));
|
||||||
await page.evaluate(() => document.documentElement.style.setProperty('--fg', '#88cc44'));
|
await page.evaluate(() => document.documentElement.style.setProperty('--fg', '#88cc44'));
|
||||||
const changedThemeColor = await page.locator('#doc-email-richbody p').nth(1).locator('span').evaluate(span => getComputedStyle(span).color);
|
const changedThemeColor = await page.locator('#doc-email-richbody p').nth(1).locator('span').evaluate(span => getComputedStyle(span).color);
|
||||||
await page.locator('#doc-email-richbody').press('Control+z');
|
await page.locator('#doc-email-richbody').press('ControlOrMeta+z');
|
||||||
const colorUndone = await page.locator('#doc-email-richbody p').nth(1).innerHTML();
|
const colorUndone = await page.locator('#doc-email-richbody p').nth(1).innerHTML();
|
||||||
console.log(JSON.stringify({ palette, highlighted, highlightUndone, defaultColor, changedThemeColor, colorUndone }));
|
console.log(JSON.stringify({ palette, highlighted, highlightUndone, defaultColor, changedThemeColor, colorUndone }));
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import tempfile
|
|||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
|
|
||||||
|
|
||||||
def test_rich_docx_converter_maps_editor_structure_instead_of_raw_html():
|
def test_rich_docx_converter_maps_editor_structure_instead_of_raw_html():
|
||||||
|
|||||||
@@ -4,16 +4,15 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source, function_body
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
|
|
||||||
|
|
||||||
def test_find_index_inserts_boundaries_without_flattening_inline_spans():
|
def test_find_index_inserts_boundaries_without_flattening_inline_spans():
|
||||||
section = DOC_JS.split("function _buildRichFindRanges", 1)[1].split(
|
section = function_body("_buildRichFindRanges")
|
||||||
"function _renderRichFindRanges", 1
|
|
||||||
)[0]
|
|
||||||
assert "const blockSelector = 'p,div,h1,h2,h3,h4,h5,h6,li,blockquote,pre,td,th'" in section
|
assert "const blockSelector = 'p,div,h1,h2,h3,h4,h5,h6,li,blockquote,pre,td,th'" in section
|
||||||
assert "block !== previousBlock" in section
|
assert "block !== previousBlock" in section
|
||||||
assert "between.cloneContents().querySelector?.('br')" in section
|
assert "between.cloneContents().querySelector?.('br')" in section
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source, function_body
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
STYLE = app_css()
|
STYLE = app_css()
|
||||||
|
|
||||||
|
|
||||||
@@ -48,8 +49,8 @@ def test_horizontal_rule_is_ordered_after_clear_formatting():
|
|||||||
|
|
||||||
|
|
||||||
def test_image_options_are_hidden_until_a_rich_image_is_selected():
|
def test_image_options_are_hidden_until_a_rich_image_is_selected():
|
||||||
clear_fn = DOC_JS.split("function _clearRichImageSelection()", 1)[1].split("function _selectRichImage", 1)[0]
|
clear_fn = function_body("_clearRichImageSelection")
|
||||||
select_fn = DOC_JS.split("function _selectRichImage", 1)[1].split("function _selectedRichImage", 1)[0]
|
select_fn = function_body("_selectRichImage")
|
||||||
assert "imageButton.style.display = 'none';" in clear_fn
|
assert "imageButton.style.display = 'none';" in clear_fn
|
||||||
assert "imageButton.style.display = '';" in select_fn
|
assert "imageButton.style.display = '';" in select_fn
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,15 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source, function_body
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
|
|
||||||
|
|
||||||
def test_heading_enter_uses_single_native_history_commands():
|
def test_heading_enter_uses_single_native_history_commands():
|
||||||
helper = DOC_JS.split("function _handleRichHeadingEnter", 1)[1].split(
|
helper = function_body("_handleRichHeadingEnter")
|
||||||
"let _richInlineCodeTypingArmed", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "selection.isCollapsed" in helper
|
assert "selection.isCollapsed" in helper
|
||||||
assert "h1, h2, h3, h4, h5, h6" in helper
|
assert "h1, h2, h3, h4, h5, h6" in helper
|
||||||
|
|||||||
@@ -6,20 +6,17 @@ from pathlib import Path
|
|||||||
|
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source, function_body
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
STYLE = app_css()
|
STYLE = app_css()
|
||||||
|
|
||||||
|
|
||||||
def test_image_caption_uses_semantic_figure_and_structured_export_paths():
|
def test_image_caption_uses_semantic_figure_and_structured_export_paths():
|
||||||
caption = DOC_JS.split("async function _editRichImageCaption", 1)[1].split(
|
caption = function_body("_editRichImageCaption")
|
||||||
"function _applyRichImageAction", 1
|
converter = function_body("_docxFigureBlocks")
|
||||||
)[0]
|
|
||||||
converter = DOC_JS.split("function _docxFigureBlocks", 1)[1].split(
|
|
||||||
"function _docxBlocksFromNodes", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "function _promptImageCaption" in DOC_JS
|
assert "function _promptImageCaption" in DOC_JS
|
||||||
assert "function _replaceRichImageFigure" in DOC_JS
|
assert "function _replaceRichImageFigure" in DOC_JS
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
STYLE = app_css()
|
STYLE = app_css()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
STYLE = app_css()
|
STYLE = app_css()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
STYLE = app_css()
|
STYLE = app_css()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,26 +5,20 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import declaration, document_source, function_body
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
STYLE = app_css()
|
STYLE = app_css()
|
||||||
|
|
||||||
|
|
||||||
def test_heading_levels_and_page_break_are_exposed_everywhere():
|
def test_heading_levels_and_page_break_are_exposed_everywhere():
|
||||||
slash = DOC_JS.split("const _RICH_SLASH_COMMANDS", 1)[1].split(
|
slash = declaration("_RICH_SLASH_COMMANDS")
|
||||||
"const _RICH_BLOCK_INPUT_RULES", 1
|
rules = declaration("_RICH_BLOCK_INPUT_RULES")
|
||||||
)[0]
|
|
||||||
rules = DOC_JS.split("const _RICH_BLOCK_INPUT_RULES", 1)[1].split(
|
|
||||||
"function _applyRichBlockInputRule", 1
|
|
||||||
)[0]
|
|
||||||
dropdown = DOC_JS.split("function _showMdDropdown", 1)[1].split(
|
dropdown = DOC_JS.split("function _showMdDropdown", 1)[1].split(
|
||||||
"function initMdToolbar", 1
|
"function initMdToolbar", 1
|
||||||
)[0]
|
)[0]
|
||||||
exporter = DOC_JS.split("function _richTextExportCss", 1)[1].split(
|
exporter = function_body("_richTextExportCss")
|
||||||
"function exportAsHtml", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
for level in (5, 6):
|
for level in (5, 6):
|
||||||
assert f"action: 'h{level}'" in slash
|
assert f"action: 'h{level}'" in slash
|
||||||
|
|||||||
@@ -4,19 +4,16 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source, function_body
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
|
|
||||||
|
|
||||||
def test_table_cell_alignment_uses_contextual_undoable_table_path():
|
def test_table_cell_alignment_uses_contextual_undoable_table_path():
|
||||||
actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split(
|
actions = function_body("_applyRichTableAction")
|
||||||
"function _insertRichPageBreak", 1
|
state = function_body("_richDropdownCurrentActions")
|
||||||
)[0]
|
|
||||||
state = DOC_JS.split("function _richDropdownCurrentActions", 1)[1].split(
|
|
||||||
"function _showMdDropdown", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
for alignment in ("top", "middle", "bottom"):
|
for alignment in ("top", "middle", "bottom"):
|
||||||
assert f"table:cell-align:{alignment}" in DOC_JS
|
assert f"table:cell-align:{alignment}" in DOC_JS
|
||||||
|
|||||||
@@ -4,19 +4,18 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source, function_body
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
|
|
||||||
|
|
||||||
def test_table_structure_uses_central_header_mode_normalization():
|
def test_table_structure_uses_central_header_mode_normalization():
|
||||||
helpers = DOC_JS.split("function _richTableHeaderModes", 1)[1].split(
|
helpers = DOC_JS.split("function _richTableHeaderModes", 1)[1].split(
|
||||||
"function _replaceRichTable", 1
|
"function _replaceRichTable", 1
|
||||||
)[0]
|
)[0]
|
||||||
append = DOC_JS.split("function _appendRichTableRow", 1)[1].split(
|
append = function_body("_appendRichTableRow")
|
||||||
"function _insertRichTable", 1
|
|
||||||
)[0]
|
|
||||||
actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split(
|
actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split(
|
||||||
"function applyMdFormat", 1
|
"function applyMdFormat", 1
|
||||||
)[0]
|
)[0]
|
||||||
|
|||||||
@@ -4,19 +4,18 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source, function_body
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
|
|
||||||
|
|
||||||
def test_table_header_controls_use_tag_replacement_and_native_history():
|
def test_table_header_controls_use_tag_replacement_and_native_history():
|
||||||
table_actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split(
|
table_actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split(
|
||||||
"function applyMdFormat", 1
|
"function applyMdFormat", 1
|
||||||
)[0]
|
)[0]
|
||||||
menu_state = DOC_JS.split("function _richDropdownCurrentActions", 1)[1].split(
|
menu_state = function_body("_richDropdownCurrentActions")
|
||||||
"function _showMdDropdown", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "function _replaceRichTableCellTag" in DOC_JS
|
assert "function _replaceRichTableCellTag" in DOC_JS
|
||||||
assert "table:toggle-header-row" in table_actions
|
assert "table:toggle-header-row" in table_actions
|
||||||
@@ -25,9 +24,7 @@ def test_table_header_controls_use_tag_replacement_and_native_history():
|
|||||||
assert "headerModes.headerRow = !headerModes.headerRow" in table_actions
|
assert "headerModes.headerRow = !headerModes.headerRow" in table_actions
|
||||||
assert "headerModes.headerColumn = !headerModes.headerColumn" in table_actions
|
assert "headerModes.headerColumn = !headerModes.headerColumn" in table_actions
|
||||||
assert "_applyRichTableHeaderModes(clone, headerModes)" in table_actions
|
assert "_applyRichTableHeaderModes(clone, headerModes)" in table_actions
|
||||||
header_normalizer = DOC_JS.split("function _applyRichTableHeaderModes", 1)[1].split(
|
header_normalizer = function_body("_applyRichTableHeaderModes")
|
||||||
"function _replaceRichTable", 1
|
|
||||||
)[0]
|
|
||||||
assert "_replaceRichTableCellTag" in header_normalizer
|
assert "_replaceRichTableCellTag" in header_normalizer
|
||||||
assert "_replaceRichTable(rich, original, clone" in table_actions
|
assert "_replaceRichTable(rich, original, clone" in table_actions
|
||||||
assert "current.add('table:toggle-header-row')" in menu_state
|
assert "current.add('table:toggle-header-row')" in menu_state
|
||||||
|
|||||||
@@ -4,16 +4,15 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source, function_body
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
|
|
||||||
|
|
||||||
def test_merge_split_commands_use_contextual_undoable_table_path():
|
def test_merge_split_commands_use_contextual_undoable_table_path():
|
||||||
actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split(
|
actions = function_body("_applyRichTableAction")
|
||||||
"function _insertRichPageBreak", 1
|
|
||||||
)[0]
|
|
||||||
menu = DOC_JS.split("function _showMdDropdown", 1)[1].split(
|
menu = DOC_JS.split("function _showMdDropdown", 1)[1].split(
|
||||||
"function initMdToolbar", 1
|
"function initMdToolbar", 1
|
||||||
)[0]
|
)[0]
|
||||||
|
|||||||
@@ -4,16 +4,15 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source, function_body
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
|
|
||||||
|
|
||||||
def test_last_cell_tab_uses_the_undoable_table_replacement_path():
|
def test_last_cell_tab_uses_the_undoable_table_replacement_path():
|
||||||
helper = DOC_JS.split("function _appendRichTableRow", 1)[1].split(
|
helper = function_body("_appendRichTableRow")
|
||||||
"function _insertRichTable", 1
|
|
||||||
)[0]
|
|
||||||
key_handler = DOC_JS.split("if (e.key === 'Tab')", 1)[1].split(
|
key_handler = DOC_JS.split("if (e.key === 'Tab')", 1)[1].split(
|
||||||
"let inList = false", 1
|
"let inList = false", 1
|
||||||
)[0]
|
)[0]
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
|
from tests.helpers.document_source import document_source, function_body
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
STYLE = app_css()
|
STYLE = app_css()
|
||||||
|
|
||||||
|
|
||||||
@@ -84,9 +85,7 @@ def test_rich_text_commands_sync_and_schedule_save():
|
|||||||
|
|
||||||
|
|
||||||
def test_rich_text_paste_uses_document_allowlist_and_drops_embedded_media():
|
def test_rich_text_paste_uses_document_allowlist_and_drops_embedded_media():
|
||||||
paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split(
|
paste_cleaner = function_body("_cleanRichTextPasteHtml")
|
||||||
"function _wireEmailRichbody", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "const allowedTags = new Set" in paste_cleaner
|
assert "const allowedTags = new Set" in paste_cleaner
|
||||||
assert "'TABLE'" in paste_cleaner
|
assert "'TABLE'" in paste_cleaner
|
||||||
@@ -154,9 +153,7 @@ def test_table_mutations_are_undoable_and_restore_the_caret():
|
|||||||
|
|
||||||
|
|
||||||
def test_temporary_table_tokens_are_not_persisted():
|
def test_temporary_table_tokens_are_not_persisted():
|
||||||
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
|
sanitizer = function_body("_sanitizedRichTextHtml")
|
||||||
"function _richTextContentToHtml", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "data-editor-(?:table|checklist|image|inline-code|link)-token" in sanitizer
|
assert "data-editor-(?:table|checklist|image|inline-code|link)-token" in sanitizer
|
||||||
assert ".replace(" in sanitizer
|
assert ".replace(" in sanitizer
|
||||||
@@ -176,9 +173,7 @@ def test_rich_text_checklists_support_conversion_and_checked_state():
|
|||||||
|
|
||||||
|
|
||||||
def test_checklist_interactions_cover_pointer_and_keyboard_users():
|
def test_checklist_interactions_cover_pointer_and_keyboard_users():
|
||||||
rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split(
|
rich_wiring = function_body("_wireEmailRichbody")
|
||||||
"function _emailRichbodyActive", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "rich.addEventListener('pointerdown'" in rich_wiring
|
assert "rich.addEventListener('pointerdown'" in rich_wiring
|
||||||
assert "mod && key === 'enter'" in rich_wiring
|
assert "mod && key === 'enter'" in rich_wiring
|
||||||
@@ -186,9 +181,7 @@ def test_checklist_interactions_cover_pointer_and_keyboard_users():
|
|||||||
|
|
||||||
|
|
||||||
def test_checklist_markup_survives_paste_save_and_export():
|
def test_checklist_markup_survives_paste_save_and_export():
|
||||||
paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split(
|
paste_cleaner = function_body("_cleanRichTextPasteHtml")
|
||||||
"function _wireEmailRichbody", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "keepChecklistClass" in paste_cleaner
|
assert "keepChecklistClass" in paste_cleaner
|
||||||
assert "keepChecklistState" in paste_cleaner
|
assert "keepChecklistState" in paste_cleaner
|
||||||
@@ -239,9 +232,7 @@ def test_rich_text_image_insertion_and_edits_are_undoable():
|
|||||||
|
|
||||||
|
|
||||||
def test_rich_text_image_selection_markers_are_not_persisted():
|
def test_rich_text_image_selection_markers_are_not_persisted():
|
||||||
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
|
sanitizer = function_body("_sanitizedRichTextHtml")
|
||||||
"function _richTextContentToHtml", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "(?:table|checklist|image|inline-code|link)-token" in sanitizer
|
assert "(?:table|checklist|image|inline-code|link)-token" in sanitizer
|
||||||
assert "data-editor-image-selected" in sanitizer
|
assert "data-editor-image-selected" in sanitizer
|
||||||
@@ -258,9 +249,7 @@ def test_rich_text_image_styles_are_available_in_editor_and_export():
|
|||||||
|
|
||||||
|
|
||||||
def test_existing_figure_wrapped_images_are_normalized_on_load():
|
def test_existing_figure_wrapped_images_are_normalized_on_load():
|
||||||
normalizer = DOC_JS.split("function _normalizeRichTextImages", 1)[1].split(
|
normalizer = function_body("_normalizeRichTextImages")
|
||||||
"function _clearRichImageSelection", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "figure.richtext-image" in normalizer
|
assert "figure.richtext-image" in normalizer
|
||||||
assert "image.classList.add('richtext-image')" in normalizer
|
assert "image.classList.add('richtext-image')" in normalizer
|
||||||
@@ -293,9 +282,7 @@ def test_inline_code_supports_selection_toggle_and_future_typing():
|
|||||||
|
|
||||||
|
|
||||||
def test_inline_code_live_marker_is_saved_as_semantic_code():
|
def test_inline_code_live_marker_is_saved_as_semantic_code():
|
||||||
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
|
sanitizer = function_body("_sanitizedRichTextHtml")
|
||||||
"function _richTextContentToHtml", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "_isRichInlineCodeMarker(span)" in sanitizer
|
assert "_isRichInlineCodeMarker(span)" in sanitizer
|
||||||
assert "document.createElement('code')" in sanitizer
|
assert "document.createElement('code')" in sanitizer
|
||||||
@@ -303,9 +290,7 @@ def test_inline_code_live_marker_is_saved_as_semantic_code():
|
|||||||
|
|
||||||
|
|
||||||
def test_rich_code_shortcuts_and_active_state_are_wired():
|
def test_rich_code_shortcuts_and_active_state_are_wired():
|
||||||
rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split(
|
rich_wiring = function_body("_wireEmailRichbody")
|
||||||
"function _emailRichbodyActive", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "action = 'codeblock'" in rich_wiring
|
assert "action = 'codeblock'" in rich_wiring
|
||||||
assert "action = 'code'" in rich_wiring
|
assert "action = 'code'" in rich_wiring
|
||||||
@@ -339,12 +324,8 @@ def test_rich_links_validate_protocols_during_paste_save_and_editing():
|
|||||||
normalizer = DOC_JS.split("function _normalizeRichLinkUrl", 1)[1].split(
|
normalizer = DOC_JS.split("function _normalizeRichLinkUrl", 1)[1].split(
|
||||||
"function _promptLink", 1
|
"function _promptLink", 1
|
||||||
)[0]
|
)[0]
|
||||||
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
|
sanitizer = function_body("_sanitizedRichTextHtml")
|
||||||
"function _richTextContentToHtml", 1
|
paste_cleaner = function_body("_cleanRichTextPasteHtml")
|
||||||
)[0]
|
|
||||||
paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split(
|
|
||||||
"function _wireEmailRichbody", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "https?:|mailto:|tel:" in normalizer
|
assert "https?:|mailto:|tel:" in normalizer
|
||||||
assert "https:${url}" in normalizer
|
assert "https:${url}" in normalizer
|
||||||
@@ -370,9 +351,7 @@ def test_rich_link_edits_and_removal_use_native_undoable_commands():
|
|||||||
|
|
||||||
|
|
||||||
def test_link_toolbar_toggles_link_when_selection_is_already_linked():
|
def test_link_toolbar_toggles_link_when_selection_is_already_linked():
|
||||||
rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split(
|
rich_wiring = function_body("_wireEmailRichbody")
|
||||||
"function _emailRichbodyActive", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
assert "const currentLink = _richLinkAtRange(rich, selectionRange)" in rich_wiring
|
assert "const currentLink = _richLinkAtRange(rich, selectionRange)" in rich_wiring
|
||||||
assert "set('[data-md=\"link\"]', !!currentLink)" in rich_wiring
|
assert "set('[data-md=\"link\"]', !!currentLink)" in rich_wiring
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
STYLE = app_css()
|
STYLE = app_css()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOCUMENT_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOCUMENT_JS = document_source()
|
||||||
|
|
||||||
|
|
||||||
def test_format_change_clears_stale_run_output() -> None:
|
def test_format_change_clears_stale_run_output() -> None:
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
|
|
||||||
|
|
||||||
def test_save_shortcut_uses_manual_version_path_and_cancels_autosave():
|
def test_save_shortcut_uses_manual_version_path_and_cancels_autosave():
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Tests must reason about the document editor's module set, not one file.
|
||||||
|
|
||||||
|
``static/js/document.js`` is being decomposed. It stays the URL the browser
|
||||||
|
requests, so a browser test that imports ``/static/js/document.js`` keeps
|
||||||
|
working. What does not survive is reading the file off disk: a test that greps
|
||||||
|
the entry file alone silently covers less as soon as the behaviour it names
|
||||||
|
moves into a module, and it keeps passing while doing so.
|
||||||
|
|
||||||
|
``tests/helpers/document_source`` is the way to read it. This fails on the two
|
||||||
|
habits that break, both of which existed here before the helper did.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SELF = Path(__file__).name
|
||||||
|
|
||||||
|
# The helper itself names the file, because being the one place that does is
|
||||||
|
# the point.
|
||||||
|
ALLOWED = {SELF, "document_source.py"}
|
||||||
|
|
||||||
|
# Every test language the assertions can hide in. A Python-only glob is what
|
||||||
|
# let the JS references to ``static/style.css`` outlive the file they named.
|
||||||
|
_SUFFIXES = ("*.py", "*.mjs", "*.js", "*.html")
|
||||||
|
|
||||||
|
# Reading the entry file off disk. Not matched: `/static/js/document.js` as a
|
||||||
|
# request URL or a dynamic `import()`, which stay correct through the wrapper,
|
||||||
|
# and which `test_frontend_module_version_parity.py` already pins.
|
||||||
|
_DIRECT_READ = re.compile(
|
||||||
|
r'["\']static/js/document\.js["\']' # string literal
|
||||||
|
r'|"static"\s*/\s*"js"\s*/\s*"document\.js"' # pathlib join
|
||||||
|
)
|
||||||
|
|
||||||
|
# Slices of the form `src.split(A, 1)[1].split(B, 1)[0]` -- "the region between
|
||||||
|
# A and B". That region is only what the test means while A and B are
|
||||||
|
# neighbours in one file. Several also hard-code the entry file's two-space
|
||||||
|
# indentation, which no extracted module reproduces.
|
||||||
|
_ADJACENCY_SLICE = re.compile(
|
||||||
|
r'(?P<var>\b[A-Za-z_]\w*)\.split\(\s*\n?\s*(?P<q1>["\'])(?P<a>(?:[^"\'\\]|\\.)+?)(?P=q1)'
|
||||||
|
r'\s*,\s*1\s*\)\[1\]\s*\n?\s*\.split\(\s*\n?\s*(?P<q2>["\'])(?P<b>(?:[^"\'\\]|\\.)+?)(?P=q2)'
|
||||||
|
r'\s*,\s*1\s*\)\[0\]',
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The adjacency slices still in the tree, each spanning a whole family of
|
||||||
|
# functions rather than one construct -- "everything from _docxHexColor to
|
||||||
|
# exportAsDocx". Collapsing one to its first member drops what the assertions
|
||||||
|
# look for, so they cannot be rewritten mechanically: each is converted when
|
||||||
|
# the family it spans becomes a module, and its entry deleted here then.
|
||||||
|
#
|
||||||
|
# This list may only shrink. A new entry means a new adjacency-dependent slice
|
||||||
|
# was written, which is the habit the helper exists to end.
|
||||||
|
KNOWN_ADJACENCY_SLICES = {
|
||||||
|
('test_document_active_restore.py',
|
||||||
|
'for (const doc of activeDocs)',
|
||||||
|
'_syncDocIndicator'),
|
||||||
|
('test_document_edit_reference_js.py',
|
||||||
|
'function clearSelection() {',
|
||||||
|
'\\n }'),
|
||||||
|
('test_document_rich_checklist_enter.py',
|
||||||
|
'function _handleRichChecklistEnter',
|
||||||
|
'let _richInlineCodeTypingArmed'),
|
||||||
|
('test_document_rich_docx_export.py',
|
||||||
|
'async function exportAsDocx',
|
||||||
|
'/** Delete the active document'),
|
||||||
|
('test_document_rich_docx_export.py',
|
||||||
|
'function _docxHexColor',
|
||||||
|
'async function exportAsDocx'),
|
||||||
|
('test_document_rich_structure_tools.py',
|
||||||
|
'function _showMdDropdown',
|
||||||
|
'function initMdToolbar'),
|
||||||
|
('test_document_rich_table_header_preservation.py',
|
||||||
|
'function _applyRichTableAction',
|
||||||
|
'function applyMdFormat'),
|
||||||
|
('test_document_rich_table_header_preservation.py',
|
||||||
|
'function _richTableHeaderModes',
|
||||||
|
'function _replaceRichTable'),
|
||||||
|
('test_document_rich_table_headers.py',
|
||||||
|
'function _applyRichTableAction',
|
||||||
|
'function applyMdFormat'),
|
||||||
|
('test_document_rich_table_merge_split.py',
|
||||||
|
'function _showMdDropdown',
|
||||||
|
'function initMdToolbar'),
|
||||||
|
('test_document_rich_text_tools.py',
|
||||||
|
'// ---- Selection-based AI editing ----',
|
||||||
|
'// ── Inline Suggestion Comments'),
|
||||||
|
('test_document_rich_text_tools.py',
|
||||||
|
'// Undo button in header',
|
||||||
|
'// Diff toggle button'),
|
||||||
|
('test_document_rich_text_tools.py',
|
||||||
|
'// ── In-document find (Ctrl+F) ──',
|
||||||
|
'// Delete (or Backspace)'),
|
||||||
|
('test_document_rich_text_tools.py',
|
||||||
|
'const _richSpacingBlockSelector',
|
||||||
|
'function _focusRichTextOffset'),
|
||||||
|
('test_document_rich_text_tools.py',
|
||||||
|
'function _insertRichTextImages',
|
||||||
|
'async function _uploadMarkdownImages'),
|
||||||
|
('test_document_rich_text_tools.py',
|
||||||
|
'function _normalizeRichLinkUrl',
|
||||||
|
'function _promptImageAlt'),
|
||||||
|
('test_document_rich_text_tools.py',
|
||||||
|
'function _normalizeRichLinkUrl',
|
||||||
|
'function _promptLink'),
|
||||||
|
('test_document_rich_text_tools.py',
|
||||||
|
'function _replaceAllLiteral',
|
||||||
|
'function _doFind'),
|
||||||
|
('test_document_rich_text_tools.py',
|
||||||
|
'function _replaceRichTable',
|
||||||
|
'function applyMdFormat'),
|
||||||
|
('test_document_rich_text_tools.py',
|
||||||
|
'function _richLinkAtRange',
|
||||||
|
'function _richSelectionCell'),
|
||||||
|
('test_document_rich_text_tools.py',
|
||||||
|
'function _richSelectionChecklistItem',
|
||||||
|
'function _cleanRichTextPasteHtml'),
|
||||||
|
('test_document_rich_text_tools.py',
|
||||||
|
'function _richSelectionInlineCode',
|
||||||
|
'function _cleanRichTextPasteHtml'),
|
||||||
|
('test_document_rich_text_tools.py',
|
||||||
|
'function _showMdDropdown',
|
||||||
|
'function initMdToolbar'),
|
||||||
|
('test_document_rich_text_tools.py',
|
||||||
|
'function _wireEmailRichbody',
|
||||||
|
'function _richSelectionElement'),
|
||||||
|
('test_document_rich_toolbar_menus.py',
|
||||||
|
'function _showMdDropdown',
|
||||||
|
'function initMdToolbar'),
|
||||||
|
('test_document_rich_toolbar_menus.py',
|
||||||
|
'function initMdToolbar',
|
||||||
|
'function _applyDocFont'),
|
||||||
|
('test_document_toolbar_order.py',
|
||||||
|
'const _DOCUMENT_TOOLBAR_GROUPS',
|
||||||
|
'function _orderDocumentToolbar'),
|
||||||
|
('test_review_docx_async_identity.py',
|
||||||
|
' let _docxPreviewRequest = 0;',
|
||||||
|
' /** Parse CSV'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _test_sources() -> list[Path]:
|
||||||
|
found: list[Path] = []
|
||||||
|
for suffix in _SUFFIXES:
|
||||||
|
found.extend((ROOT / "tests").rglob(suffix))
|
||||||
|
return [p for p in sorted(set(found)) if p.name not in ALLOWED]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_are_discoverable() -> None:
|
||||||
|
"""Guard the guard: a layout change must not make this vacuous."""
|
||||||
|
sources = _test_sources()
|
||||||
|
assert len(sources) > 100
|
||||||
|
suffixes = {p.suffix for p in sources}
|
||||||
|
assert {".py", ".mjs", ".js"} <= suffixes, suffixes
|
||||||
|
assert any(p.parent != ROOT / "tests" for p in sources), "walk is not recursive"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_test_reads_the_document_entry_file_off_disk() -> None:
|
||||||
|
offenders = [
|
||||||
|
str(p.relative_to(ROOT))
|
||||||
|
for p in _test_sources()
|
||||||
|
if _DIRECT_READ.search(p.read_text(encoding="utf-8"))
|
||||||
|
]
|
||||||
|
|
||||||
|
assert not offenders, (
|
||||||
|
"read the document editor through tests.helpers.document_source instead "
|
||||||
|
"of static/js/document.js, which is becoming a re-export wrapper: "
|
||||||
|
f"{offenders}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adjacency_slices_only_shrink() -> None:
|
||||||
|
"""No new "region between two declarations" slice enters the tree."""
|
||||||
|
found = set()
|
||||||
|
for path in _test_sources():
|
||||||
|
if path.suffix != ".py":
|
||||||
|
continue
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
if "tests.helpers.document_source" not in source:
|
||||||
|
continue
|
||||||
|
bound = {
|
||||||
|
m.group(1)
|
||||||
|
for m in re.finditer(r"(\w+)\s*=\s*document_source\(\)", source)
|
||||||
|
}
|
||||||
|
for m in _ADJACENCY_SLICE.finditer(source):
|
||||||
|
if m.group("var") in bound:
|
||||||
|
found.add((path.name, m.group("a"), m.group("b")))
|
||||||
|
|
||||||
|
added = found - KNOWN_ADJACENCY_SLICES
|
||||||
|
assert not added, (
|
||||||
|
"these slices depend on two declarations being neighbours in one file, "
|
||||||
|
"which decomposition breaks; use function_body()/declaration() or "
|
||||||
|
"assert against the owning module: " + repr(sorted(added))
|
||||||
|
)
|
||||||
|
|
||||||
|
removed = KNOWN_ADJACENCY_SLICES - found
|
||||||
|
assert not removed, (
|
||||||
|
"these adjacency slices are gone -- delete them from "
|
||||||
|
f"KNOWN_ADJACENCY_SLICES so the list keeps shrinking: {sorted(removed)}"
|
||||||
|
)
|
||||||
@@ -5,10 +5,11 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
STYLE = app_css()
|
STYLE = app_css()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
STYLE_CSS = app_css()
|
STYLE_CSS = app_css()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
SOURCE = Path("static/js/document.js").read_text()
|
SOURCE = document_source()
|
||||||
|
|
||||||
|
|
||||||
def test_newly_mounted_document_pane_cannot_save_before_binding():
|
def test_newly_mounted_document_pane_cannot_save_before_binding():
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def test_toolbar_arrows_have_real_flex_slots_outside_icon_scroller():
|
def test_toolbar_arrows_have_real_flex_slots_outside_icon_scroller():
|
||||||
script = (ROOT / "static/js/document.js").read_text()
|
script = document_source()
|
||||||
styles = app_css()
|
styles = app_css()
|
||||||
|
|
||||||
leading = script.index('class="md-toolbar-leading-controls"')
|
leading = script.index('class="md-toolbar-leading-controls"')
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
DOC_JS = document_source()
|
||||||
|
|
||||||
|
|
||||||
def test_toolbar_groups_define_writing_actions_before_view_controls():
|
def test_toolbar_groups_define_writing_actions_before_view_controls():
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
@@ -27,7 +28,7 @@ def test_email_ai_reply_context_only_clears_after_draft_opens():
|
|||||||
|
|
||||||
|
|
||||||
def test_document_ai_reply_does_not_overwrite_an_edited_draft():
|
def test_document_ai_reply_does_not_overwrite_an_edited_draft():
|
||||||
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
source = document_source()
|
||||||
start = source.index(" async function _aiReply(")
|
start = source.index(" async function _aiReply(")
|
||||||
end = source.index(" async function _scheduleSend(", start)
|
end = source.index(" async function _scheduleSend(", start)
|
||||||
function = source[start:end]
|
function = source[start:end]
|
||||||
@@ -46,7 +47,7 @@ def test_document_ai_reply_does_not_overwrite_an_edited_draft():
|
|||||||
|
|
||||||
|
|
||||||
def test_replacing_an_email_reply_checks_the_visible_draft_first():
|
def test_replacing_an_email_reply_checks_the_visible_draft_first():
|
||||||
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
source = document_source()
|
||||||
start = source.index(" export async function replaceEmailReplyBody(")
|
start = source.index(" export async function replaceEmailReplyBody(")
|
||||||
end = source.index(" export async function ensureEmailDraftEnvelope(", start)
|
end = source.index(" export async function ensureEmailDraftEnvelope(", start)
|
||||||
function = source[start:end]
|
function = source[start:end]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
def test_attachment_filename_is_part_of_ui_index_search(tmp_path, monkeypatch):
|
def test_attachment_filename_is_part_of_ui_index_search(tmp_path, monkeypatch):
|
||||||
@@ -80,7 +81,7 @@ def test_remote_search_explicitly_checks_mime_filename_headers():
|
|||||||
|
|
||||||
def test_forwarding_filters_signature_assets_and_mobile_export_stops_bubbling():
|
def test_forwarding_filters_signature_assets_and_mobile_export_stops_bubbling():
|
||||||
inbox = open("static/js/emailInbox.js", encoding="utf-8").read()
|
inbox = open("static/js/emailInbox.js", encoding="utf-8").read()
|
||||||
document = open("static/js/document.js", encoding="utf-8").read()
|
document = document_source()
|
||||||
|
|
||||||
assert "const forwardedAttachments = mode === 'forward'" in inbox
|
assert "const forwardedAttachments = mode === 'forward'" in inbox
|
||||||
assert "forwardedAttachments.map" in inbox
|
assert "forwardedAttachments.map" in inbox
|
||||||
@@ -99,7 +100,7 @@ def test_attachment_open_spins_icon_only():
|
|||||||
|
|
||||||
|
|
||||||
def test_move_document_creates_destination_before_adopting_it():
|
def test_move_document_creates_destination_before_adopting_it():
|
||||||
document = open("static/js/document.js", encoding="utf-8").read()
|
document = document_source()
|
||||||
start = document.index("async function moveActiveDocumentToNewChat()")
|
start = document.index("async function moveActiveDocumentToNewChat()")
|
||||||
end = document.index("\n function showDocTabMenu", start)
|
end = document.index("\n function showDocTabMenu", start)
|
||||||
handler = document[start:end]
|
handler = document[start:end]
|
||||||
@@ -188,7 +189,7 @@ def test_empty_reply_has_two_editable_rows_and_reply_survives_compact_toolbar():
|
|||||||
|
|
||||||
|
|
||||||
def test_email_toolbar_places_attachment_before_link():
|
def test_email_toolbar_places_attachment_before_link():
|
||||||
document = open("static/js/document.js", encoding="utf-8").read()
|
document = document_source()
|
||||||
toolbar_start = document.index('<div class="md-toolbar-items"')
|
toolbar_start = document.index('<div class="md-toolbar-items"')
|
||||||
toolbar_end = document.index('</div>', toolbar_start)
|
toolbar_end = document.index('</div>', toolbar_start)
|
||||||
toolbar = document[toolbar_start:toolbar_end]
|
toolbar = document[toolbar_start:toolbar_end]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parents[1]
|
_REPO = Path(__file__).resolve().parents[1]
|
||||||
@@ -164,8 +165,7 @@ def test_email_client_cache_drops_fixture_rows():
|
|||||||
|
|
||||||
def test_email_compose_can_attach_gallery_images():
|
def test_email_compose_can_attach_gallery_images():
|
||||||
"""Compose attachments should support local files, documents, and Gallery images."""
|
"""Compose attachments should support local files, documents, and Gallery images."""
|
||||||
frontend = _REPO / "static" / "js" / "document.js"
|
frontend_text = document_source()
|
||||||
frontend_text = frontend.read_text(encoding="utf-8")
|
|
||||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "Upload from computer" in frontend_text
|
assert "Upload from computer" in frontend_text
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parent.parent
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
@@ -120,7 +121,7 @@ def test_library_reply_open_carries_immutable_mailbox_context():
|
|||||||
|
|
||||||
|
|
||||||
def test_attachment_warning_only_checks_authored_reply_text():
|
def test_attachment_warning_only_checks_authored_reply_text():
|
||||||
source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
|
source = document_source()
|
||||||
helper = source[source.index("function _bodyMentionsAttachment"):source.index("\n\n function _clearMissingAttachmentWarnings", source.index("function _bodyMentionsAttachment"))]
|
helper = source[source.index("function _bodyMentionsAttachment"):source.index("\n\n function _clearMissingAttachmentWarnings", source.index("function _bodyMentionsAttachment"))]
|
||||||
|
|
||||||
assert "_emailReplyOwnText(text)" in helper
|
assert "_emailReplyOwnText(text)" in helper
|
||||||
@@ -128,7 +129,7 @@ def test_attachment_warning_only_checks_authored_reply_text():
|
|||||||
|
|
||||||
|
|
||||||
def test_email_send_saves_recovery_draft_before_send_and_retains_it_on_failure():
|
def test_email_send_saves_recovery_draft_before_send_and_retains_it_on_failure():
|
||||||
source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
|
source = document_source()
|
||||||
send = source[source.index("async function _sendEmail"):source.index("\n\n async function _saveDraft", source.index("async function _sendEmail"))]
|
send = source[source.index("async function _sendEmail"):source.index("\n\n async function _saveDraft", source.index("async function _sendEmail"))]
|
||||||
|
|
||||||
assert "async function _saveEmailDraftForRecovery" in source
|
assert "async function _saveEmailDraftForRecovery" in source
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
SCRIPT = (
|
SCRIPT = document_source()
|
||||||
Path(__file__).resolve().parents[1] / "static/js/document.js"
|
|
||||||
).read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def test_email_send_requires_actual_send_button_event_target():
|
def test_email_send_requires_actual_send_button_event_target():
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -14,7 +15,7 @@ def test_card_delete_waits_for_durable_success_and_uses_email_identity():
|
|||||||
|
|
||||||
|
|
||||||
def test_finished_send_does_not_close_whichever_library_opened_later():
|
def test_finished_send_does_not_close_whichever_library_opened_later():
|
||||||
source = (ROOT / "static/js/document.js").read_text()
|
source = document_source()
|
||||||
start = source.index("async function _sendEmail()")
|
start = source.index("async function _sendEmail()")
|
||||||
end = source.index("async function _saveDraft()", start)
|
end = source.index("async function _saveDraft()", start)
|
||||||
send = source[start:end]
|
send = source[start:end]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from src.tool_capabilities import (
|
|||||||
messages_contain_external_untrusted_context,
|
messages_contain_external_untrusted_context,
|
||||||
tool_result_should_arm_gate,
|
tool_result_should_arm_gate,
|
||||||
)
|
)
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ToolBlock = namedtuple("ToolBlock", ["tool_type", "content"])
|
ToolBlock = namedtuple("ToolBlock", ["tool_type", "content"])
|
||||||
@@ -1403,13 +1404,15 @@ def test_frontend_tool_approval_uses_opaque_id_and_fixed_decisions():
|
|||||||
"static/js/chat.js",
|
"static/js/chat.js",
|
||||||
"static/js/chatRenderer.js",
|
"static/js/chatRenderer.js",
|
||||||
"static/js/chatStream.js",
|
"static/js/chatStream.js",
|
||||||
"static/js/document.js",
|
|
||||||
"static/js/emailInbox.js",
|
"static/js/emailInbox.js",
|
||||||
"static/js/emailLibrary.js",
|
"static/js/emailLibrary.js",
|
||||||
"static/js/settings.js",
|
"static/js/settings.js",
|
||||||
"static/js/slashCommands.js",
|
"static/js/slashCommands.js",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
# The document editor is a module set, not one file: a stale version string
|
||||||
|
# must not be able to hide in a module extracted out of document.js.
|
||||||
|
approval_module_sources.append(document_source())
|
||||||
assert all(
|
assert all(
|
||||||
"20260722emailfastindex1" not in source
|
"20260722emailfastindex1" not in source
|
||||||
for source in approval_module_sources
|
for source in approval_module_sources
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Every module the frontend imports must exist, and the document set must be cached.
|
||||||
|
|
||||||
|
There is no bundler here, so nothing resolves the import graph before a browser
|
||||||
|
does. A specifier that names a file which is not there is valid JavaScript:
|
||||||
|
``node --check`` passes, and ``test_frontend_module_version_parity.py`` checks
|
||||||
|
that a module is loaded under one URL identity without checking that the URL
|
||||||
|
leads anywhere. The failure surfaces as a blank panel at runtime, and in the
|
||||||
|
test suite as a scatter of unrelated browser tests going red at once with no
|
||||||
|
mention of the missing file.
|
||||||
|
|
||||||
|
That is affordable to close statically, so this closes it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tests.helpers.document_source import document_source_paths
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
STATIC = ROOT / "static"
|
||||||
|
|
||||||
|
# Vendored third-party builds and the committed editor build output are not
|
||||||
|
# ours to reason about.
|
||||||
|
_SKIP_DIRS = ("lib/", "js/editor/build/")
|
||||||
|
|
||||||
|
# `import x from '...'`, `export ... from '...'`, and `import('...')`. Only
|
||||||
|
# quoted specifiers: a template literal is not statically resolvable, and the
|
||||||
|
# app does not use one.
|
||||||
|
_SPECIFIER = re.compile(
|
||||||
|
r"""(?:^|[^\w.$])(?:import|export)\s*(?:[\w*{},\s$]*?\s*from\s*)?['"]([^'"]+)['"]"""
|
||||||
|
r"""|\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)""",
|
||||||
|
re.M,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _own_scripts() -> list[Path]:
|
||||||
|
out = []
|
||||||
|
for path in sorted(STATIC.rglob("*.js")):
|
||||||
|
rel = path.relative_to(STATIC).as_posix()
|
||||||
|
if any(rel.startswith(d) or f"/{d}" in rel for d in _SKIP_DIRS):
|
||||||
|
continue
|
||||||
|
out.append(path)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _imports(path: Path):
|
||||||
|
"""(line, specifier, resolved path) for each relative/app-absolute import."""
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
for match in _SPECIFIER.finditer(source):
|
||||||
|
specifier = match.group(1) or match.group(2)
|
||||||
|
if not specifier:
|
||||||
|
continue
|
||||||
|
if not (specifier.startswith(".") or specifier.startswith("/static/")):
|
||||||
|
continue # bare specifier: not a file in this tree
|
||||||
|
bare = specifier.split("?")[0].split("#")[0]
|
||||||
|
if bare.startswith("/static/"):
|
||||||
|
target = STATIC / bare.removeprefix("/static/")
|
||||||
|
else:
|
||||||
|
target = path.parent / bare
|
||||||
|
line = source.count("\n", 0, match.start()) + 1
|
||||||
|
yield line, specifier, target
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_are_discoverable() -> None:
|
||||||
|
"""Guard the guard: this must not pass by scanning nothing."""
|
||||||
|
scripts = _own_scripts()
|
||||||
|
assert len(scripts) > 100, len(scripts)
|
||||||
|
total = sum(1 for p in scripts for _ in _imports(p))
|
||||||
|
assert total > 300, total
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_frontend_import_resolves_to_a_file() -> None:
|
||||||
|
broken = [
|
||||||
|
f"{path.relative_to(ROOT)}:{line} -> {specifier}"
|
||||||
|
for path in _own_scripts()
|
||||||
|
for line, specifier, target in _imports(path)
|
||||||
|
if not target.is_file()
|
||||||
|
]
|
||||||
|
|
||||||
|
assert not broken, "imports naming files that do not exist: " + repr(broken)
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_implementation_set_is_precached() -> None:
|
||||||
|
"""A module extracted out of document.js must join the offline manifest.
|
||||||
|
|
||||||
|
``static/sw.js`` fetches the URLs it lists, nothing they in turn import, so
|
||||||
|
a new module under ``static/js/document/`` is not cached just because the
|
||||||
|
entry point that imports it is. Without it the editor breaks offline for
|
||||||
|
anyone whose cache predates the split.
|
||||||
|
"""
|
||||||
|
service_worker = (STATIC / "sw.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for path in document_source_paths():
|
||||||
|
url = "/static/" + path.relative_to(STATIC).as_posix()
|
||||||
|
if not re.search(rf"['\"]{re.escape(url)}(?:\?[^'\"]*)?['\"]", service_worker):
|
||||||
|
missing.append(url)
|
||||||
|
|
||||||
|
assert not missing, (
|
||||||
|
"document editor modules absent from the sw.js precache lists: "
|
||||||
|
f"{missing}"
|
||||||
|
)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import functools
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -20,6 +21,28 @@ from src.tool_execution import _active_workspace
|
|||||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=None)
|
||||||
|
def _ffmpeg_has_encoder(name: str) -> bool:
|
||||||
|
"""Whether the ffmpeg on PATH was built with the named encoder.
|
||||||
|
|
||||||
|
Codec support is a build option, not something the project requires. The
|
||||||
|
Homebrew ffmpeg on macOS ships without libwebp, for instance, so a test
|
||||||
|
that asserts a successful `.webp` export there fails on the build rather
|
||||||
|
than on the tool.
|
||||||
|
"""
|
||||||
|
if not shutil.which("ffmpeg"):
|
||||||
|
return False
|
||||||
|
listed = subprocess.run(
|
||||||
|
["ffmpeg", "-hide_banner", "-loglevel", "error", "-encoders"],
|
||||||
|
check=False, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
return any(
|
||||||
|
line.split()[1:2] == [name] or f"(codec {name})" in line
|
||||||
|
for line in listed.stdout.splitlines()
|
||||||
|
if line.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_media_timestamp_parser_accepts_units_and_four_field_timecodes():
|
def test_media_timestamp_parser_accepts_units_and_four_field_timecodes():
|
||||||
assert _parse_seconds("0m", default=-1) == 0
|
assert _parse_seconds("0m", default=-1) == 0
|
||||||
assert _parse_seconds("30m", default=-1) == 1800
|
assert _parse_seconds("30m", default=-1) == 1800
|
||||||
@@ -491,13 +514,16 @@ def test_inspect_media_exports_final_decodable_frame_at_exact_duration(tmp_path:
|
|||||||
result = asyncio.run(InspectMediaTool().execute(json.dumps({
|
result = asyncio.run(InspectMediaTool().execute(json.dumps({
|
||||||
"path": "/workspace/video.mp4",
|
"path": "/workspace/video.mp4",
|
||||||
"timestamp": "end",
|
"timestamp": "end",
|
||||||
"output_path": "/workspace/final.webp",
|
# PNG, not WebP: this asserts that the *final* frame is decodable at
|
||||||
|
# the exact duration, so it must not also depend on an optional
|
||||||
|
# ffmpeg encoder. WebP export is covered separately below.
|
||||||
|
"output_path": "/workspace/final.png",
|
||||||
}), {}))
|
}), {}))
|
||||||
finally:
|
finally:
|
||||||
_active_workspace.reset(token)
|
_active_workspace.reset(token)
|
||||||
|
|
||||||
assert result["exit_code"] == 0, result
|
assert result["exit_code"] == 0, result
|
||||||
assert (tmp_path / "final.webp").stat().st_size > 0
|
assert (tmp_path / "final.png").stat().st_size > 0
|
||||||
|
|
||||||
token = _active_workspace.set(str(tmp_path))
|
token = _active_workspace.set(str(tmp_path))
|
||||||
try:
|
try:
|
||||||
@@ -514,6 +540,67 @@ def test_inspect_media_exports_final_decodable_frame_at_exact_duration(tmp_path:
|
|||||||
assert Image.open(io.BytesIO(base64.b64decode(high_detail["images"][0]["data"]))).size == (768, 432)
|
assert Image.open(io.BytesIO(base64.b64decode(high_detail["images"][0]["data"]))).size == (768, 432)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not shutil.which("ffmpeg") or not shutil.which("ffprobe"), reason="ffmpeg required")
|
||||||
|
@pytest.mark.skipif(not _ffmpeg_has_encoder("webp"), reason="ffmpeg built without a webp encoder")
|
||||||
|
def test_inspect_media_exports_a_webp_still(tmp_path: Path):
|
||||||
|
"""A `.webp` output_path is passed straight through to ffmpeg.
|
||||||
|
|
||||||
|
Guarded on the encoder rather than asserted unconditionally: WebP is a
|
||||||
|
build option (Homebrew's macOS ffmpeg omits it) and the project does not
|
||||||
|
require it. When the encoder is missing the tool reports ffmpeg's failure
|
||||||
|
with `exit_code` 1, which is covered by
|
||||||
|
`test_inspect_media_reports_a_missing_encoder_instead_of_crashing`.
|
||||||
|
"""
|
||||||
|
video = tmp_path / "video.mp4"
|
||||||
|
subprocess.run([
|
||||||
|
"ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi",
|
||||||
|
"-i", "testsrc2=size=320x180:rate=4:duration=2", "-pix_fmt", "yuv420p",
|
||||||
|
"-y", str(video),
|
||||||
|
], check=True)
|
||||||
|
token = _active_workspace.set(str(tmp_path))
|
||||||
|
try:
|
||||||
|
result = asyncio.run(InspectMediaTool().execute(json.dumps({
|
||||||
|
"path": "/workspace/video.mp4",
|
||||||
|
"timestamp": "end",
|
||||||
|
"output_path": "/workspace/final.webp",
|
||||||
|
}), {}))
|
||||||
|
finally:
|
||||||
|
_active_workspace.reset(token)
|
||||||
|
|
||||||
|
assert result["exit_code"] == 0, result
|
||||||
|
assert Image.open(tmp_path / "final.webp").format == "WEBP"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not shutil.which("ffmpeg") or not shutil.which("ffprobe"), reason="ffmpeg required")
|
||||||
|
@pytest.mark.skipif(_ffmpeg_has_encoder("webp"), reason="needs an ffmpeg built without webp")
|
||||||
|
def test_inspect_media_reports_a_missing_encoder_instead_of_crashing(tmp_path: Path):
|
||||||
|
"""An export in a format this ffmpeg cannot encode fails as a tool error.
|
||||||
|
|
||||||
|
The tool does not probe the encoder list, so the only contract it can keep
|
||||||
|
is to surface ffmpeg's own failure rather than raise or write a truncated
|
||||||
|
file. Asserted only on builds that actually lack the encoder.
|
||||||
|
"""
|
||||||
|
video = tmp_path / "video.mp4"
|
||||||
|
subprocess.run([
|
||||||
|
"ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi",
|
||||||
|
"-i", "testsrc2=size=320x180:rate=4:duration=2", "-pix_fmt", "yuv420p",
|
||||||
|
"-y", str(video),
|
||||||
|
], check=True)
|
||||||
|
token = _active_workspace.set(str(tmp_path))
|
||||||
|
try:
|
||||||
|
result = asyncio.run(InspectMediaTool().execute(json.dumps({
|
||||||
|
"path": "/workspace/video.mp4",
|
||||||
|
"timestamp": "end",
|
||||||
|
"output_path": "/workspace/final.webp",
|
||||||
|
}), {}))
|
||||||
|
finally:
|
||||||
|
_active_workspace.reset(token)
|
||||||
|
|
||||||
|
assert result["exit_code"] == 1
|
||||||
|
assert "ffmpeg still extraction failed" in result["error"]
|
||||||
|
assert not (tmp_path / "final.webp").exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not shutil.which("ffmpeg") or not shutil.which("ffprobe"), reason="ffmpeg required")
|
@pytest.mark.skipif(not shutil.which("ffmpeg") or not shutil.which("ffprobe"), reason="ffmpeg required")
|
||||||
def test_inspect_media_rejects_ambiguous_multi_frame_single_image_export(tmp_path: Path):
|
def test_inspect_media_rejects_ambiguous_multi_frame_single_image_export(tmp_path: Path):
|
||||||
video = tmp_path / "video.mp4"
|
video = tmp_path / "video.mp4"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Regression guards for markdown raw-HTML sanitizer helpers."""
|
"""Regression guards for markdown raw-HTML sanitizer helpers."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parent.parent
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
@@ -27,7 +28,7 @@ def test_markdown_raw_html_sanitizer_strips_scriptable_css():
|
|||||||
|
|
||||||
def test_email_rich_body_render_path_reuses_raw_html_sanitizer():
|
def test_email_rich_body_render_path_reuses_raw_html_sanitizer():
|
||||||
markdown_src = (_REPO / "static" / "js" / "markdown.js").read_text(encoding="utf-8")
|
markdown_src = (_REPO / "static" / "js" / "markdown.js").read_text(encoding="utf-8")
|
||||||
document_src = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8")
|
document_src = document_source()
|
||||||
email_body_helper = document_src.split("function _emailBodyToHtml(text)", 1)[1].split(
|
email_body_helper = document_src.split("function _emailBodyToHtml(text)", 1)[1].split(
|
||||||
" // Mirror the rich body's plain text", 1
|
" // Mirror the rich body's plain text", 1
|
||||||
)[0]
|
)[0]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import textwrap
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parent.parent
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
_HAS_NODE = shutil.which("node") is not None
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
@@ -401,7 +402,7 @@ def test_detached_container_math_typesets_with_the_real_renderer(node_available)
|
|||||||
|
|
||||||
def test_pdf_export_typesets_its_container_before_html2pdf():
|
def test_pdf_export_typesets_its_container_before_html2pdf():
|
||||||
"""Ordering in a call site, so pin the call site. No node needed."""
|
"""Ordering in a call site, so pin the call site. No node needed."""
|
||||||
source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
|
source = document_source()
|
||||||
match = re.search(r"\n async function exportAsPdf\(\) \{(.*?)\n \}\n", source, re.S)
|
match = re.search(r"\n async function exportAsPdf\(\) \{(.*?)\n \}\n", source, re.S)
|
||||||
assert match, "exportAsPdf not found"
|
assert match, "exportAsPdf not found"
|
||||||
body = match.group(1)
|
body = match.group(1)
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Regression test for issue #6211: a malformed Args value on the "Add MCP
|
||||||
|
Server" form must not be silently discarded into an empty argv.
|
||||||
|
|
||||||
|
routes/mcp/mcp_routes.py's add_server() wrapped json.loads(args) in a bare
|
||||||
|
except that fell back to `[]`, so a non-JSON Args value registered the
|
||||||
|
server as "Connected" while forwarding no arguments to the spawned stdio
|
||||||
|
subprocess at all, with no error surfaced anywhere.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from routes.mcp import mcp_routes
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
"""Stands in for core.database.SessionLocal(); add_server only adds+commits."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.added = []
|
||||||
|
|
||||||
|
def add(self, obj):
|
||||||
|
self.added.append(obj)
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _add_server(monkeypatch):
|
||||||
|
"""Register add_server on the shared module-level router and return the
|
||||||
|
freshly-added route's raw endpoint function, bypassing HTTP/Form parsing
|
||||||
|
(require_admin is the only other thing the function touches via `request`).
|
||||||
|
|
||||||
|
Callers must pass every Form(...) parameter add_server reads past the args
|
||||||
|
check (url, oauth_file, oauth_config): calling the endpoint directly skips
|
||||||
|
FastAPI's dependency resolution, so an omitted one arrives as the Form
|
||||||
|
marker object itself rather than its declared default, and later code
|
||||||
|
(e.g. `if oauth_file:`) reads that marker as truthy.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(mcp_routes, "require_admin", lambda request: None)
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.connect_server = AsyncMock(return_value=True)
|
||||||
|
manager.get_server_status = MagicMock(return_value={"status": "connected", "tool_count": 1})
|
||||||
|
router = mcp_routes.setup_mcp_routes(manager)
|
||||||
|
# setup_mcp_routes appends new APIRoute objects to the shared router on
|
||||||
|
# every call, so take the LAST "add_server" route: the one just registered
|
||||||
|
# with our fake manager, not an earlier registration from importing app.py.
|
||||||
|
route = [r for r in router.routes if getattr(r, "name", None) == "add_server"][-1]
|
||||||
|
return route.endpoint, manager
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_server_rejects_malformed_args_instead_of_defaulting(monkeypatch):
|
||||||
|
add_server, manager = _add_server(monkeypatch)
|
||||||
|
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: (_ for _ in ()).throw(
|
||||||
|
AssertionError("must not reach the DB when args is rejected")))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
asyncio.run(add_server(
|
||||||
|
request=None,
|
||||||
|
name="filesystem",
|
||||||
|
transport="stdio",
|
||||||
|
command="mcp-server-filesystem",
|
||||||
|
args="/app/data/jarvis-files", # the exact value from issue #6211
|
||||||
|
env="{}",
|
||||||
|
url=None,
|
||||||
|
oauth_file=None,
|
||||||
|
oauth_config=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
manager.connect_server.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_server_still_accepts_valid_json_args(monkeypatch):
|
||||||
|
add_server, manager = _add_server(monkeypatch)
|
||||||
|
fake_session = _FakeSession()
|
||||||
|
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: fake_session)
|
||||||
|
|
||||||
|
result = asyncio.run(add_server(
|
||||||
|
request=None,
|
||||||
|
name="filesystem",
|
||||||
|
transport="stdio",
|
||||||
|
command="mcp-server-filesystem",
|
||||||
|
args=json.dumps(["/app/data/jarvis-files"]),
|
||||||
|
env="{}",
|
||||||
|
url=None,
|
||||||
|
oauth_file=None,
|
||||||
|
oauth_config=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert result["connected"] is True
|
||||||
|
manager.connect_server.assert_awaited_once()
|
||||||
|
assert manager.connect_server.call_args.kwargs["args"] == ["/app/data/jarvis-files"]
|
||||||
|
assert fake_session.added[0].args == json.dumps(["/app/data/jarvis-files"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_server_rejects_valid_json_args_that_is_not_a_list(monkeypatch):
|
||||||
|
"""Valid JSON that is not a list (e.g. args=5) must not reach
|
||||||
|
StdioServerParameters(args=5), which raises an unhandled TypeError when
|
||||||
|
the error formatter later does " ".join([command, *args])."""
|
||||||
|
add_server, manager = _add_server(monkeypatch)
|
||||||
|
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: (_ for _ in ()).throw(
|
||||||
|
AssertionError("must not reach the DB when args has the wrong shape")))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
asyncio.run(add_server(
|
||||||
|
request=None,
|
||||||
|
name="filesystem",
|
||||||
|
transport="stdio",
|
||||||
|
command="mcp-server-filesystem",
|
||||||
|
args="5",
|
||||||
|
env="{}",
|
||||||
|
url=None,
|
||||||
|
oauth_file=None,
|
||||||
|
oauth_config=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
manager.connect_server.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_server_still_defaults_empty_args_to_empty_list(monkeypatch):
|
||||||
|
"""No behavior change for the common case of an empty Args field."""
|
||||||
|
add_server, manager = _add_server(monkeypatch)
|
||||||
|
fake_session = _FakeSession()
|
||||||
|
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: fake_session)
|
||||||
|
|
||||||
|
result = asyncio.run(add_server(
|
||||||
|
request=None,
|
||||||
|
name="no-args-server",
|
||||||
|
transport="stdio",
|
||||||
|
command="some-command",
|
||||||
|
args="",
|
||||||
|
env="{}",
|
||||||
|
url=None,
|
||||||
|
oauth_file=None,
|
||||||
|
oauth_config=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert result["connected"] is True
|
||||||
|
assert manager.connect_server.call_args.kwargs["args"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The two forms that post to this endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The route now answers 400 with a message naming the expected shape. That is
|
||||||
|
# only worth anything if the form the user is looking at prints it, and the two
|
||||||
|
# forms did not agree: admin.js reads `data.detail`, settings.js printed the
|
||||||
|
# bare status code. Read as source, because the artifact under test is the
|
||||||
|
# string in the file and neither form is reachable without a browser.
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_form_reports_the_reason_the_route_gave():
|
||||||
|
source = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8")
|
||||||
|
assert "msg.textContent = data.detail || `Failed (${res.status})`;" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_unified_integrations_form_reports_the_reason_the_route_gave():
|
||||||
|
source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
|
||||||
|
assert (
|
||||||
|
"el('uf-mcp-msg').textContent = data.detail || `Failed (${r.status})`;"
|
||||||
|
in source
|
||||||
|
), "settings.js drops the route's message and prints only the status code"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -12,7 +13,7 @@ def test_opening_notes_minimizes_open_document():
|
|||||||
|
|
||||||
|
|
||||||
def test_opening_document_minimizes_notes():
|
def test_opening_document_minimizes_notes():
|
||||||
script = (ROOT / "static/js/document.js").read_text()
|
script = document_source()
|
||||||
|
|
||||||
assert "function _minimizeNotesForDocumentOpen()" in script
|
assert "function _minimizeNotesForDocumentOpen()" in script
|
||||||
assert "window.notesModule.closePanel('down')" in script
|
assert "window.notesModule.closePanel('down')" in script
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def test_pdf_backed_documents_do_not_offer_destructive_html_pdf_export():
|
def test_pdf_backed_documents_do_not_offer_destructive_html_pdf_export():
|
||||||
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
source = document_source()
|
||||||
|
|
||||||
assert "if (!isForm) {" in source
|
assert "if (!isForm) {" in source
|
||||||
assert "label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print as PDF'" in source
|
assert "label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print as PDF'" in source
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def test_preview_hides_import_action_and_restores_it_for_empty_editor():
|
def test_preview_hides_import_action_and_restores_it_for_empty_editor():
|
||||||
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
source = document_source()
|
||||||
|
|
||||||
assert "const emptyImport = document.getElementById('doc-rich-empty-import');" in source
|
assert "const emptyImport = document.getElementById('doc-rich-empty-import');" in source
|
||||||
assert "if (emptyImport) emptyImport.style.display = 'none';" in source
|
assert "if (emptyImport) emptyImport.style.display = 'none';" in source
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Every procfs pid scan in the app tree must be guarded by an existence check.
|
||||||
|
|
||||||
|
This is the third instance of the same defect: code walks ``/proc`` on a host
|
||||||
|
that has no procfs, and the resulting ``FileNotFoundError`` breaks a path that
|
||||||
|
had otherwise succeeded. Two of the three were found by reading source, so
|
||||||
|
this pins the class rather than the instances.
|
||||||
|
|
||||||
|
Deliberate AST assertion under the narrow exception in
|
||||||
|
``tests/TESTING_STANDARD.md``: the invariant is "no *other* module grows an
|
||||||
|
unguarded scan", which cannot be driven at runtime without importing and
|
||||||
|
exercising every procfs-touching code path on both a Linux and a non-Linux
|
||||||
|
host. ``test_cookbook_stop_without_procfs.py`` covers the behaviour itself.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Trees that ship as the application. Tests and vendored code are excluded.
|
||||||
|
APP_TREES = (
|
||||||
|
"app.py",
|
||||||
|
"core",
|
||||||
|
"routes",
|
||||||
|
"src",
|
||||||
|
"services",
|
||||||
|
"scripts",
|
||||||
|
"mcp_servers",
|
||||||
|
"integrations",
|
||||||
|
"companion",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calls that enumerate a directory's entries. Reading one known file under
|
||||||
|
# /proc is a different shape — it fails per-file and callers already handle
|
||||||
|
# that — so only the enumerating calls are in scope here.
|
||||||
|
_SCAN_FUNCS = {"listdir", "scandir"}
|
||||||
|
_SCAN_METHODS = {"iterdir", "glob", "rglob"}
|
||||||
|
|
||||||
|
# Calls that prove the scan is conditional on procfs being present.
|
||||||
|
_GUARD_FUNCS = {"has_procfs", "isdir", "is_dir", "exists"}
|
||||||
|
|
||||||
|
PROCFS_ROOT = "/proc"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_procfs_root(node: ast.AST) -> bool:
|
||||||
|
"""True if ``node`` evaluates to the procfs root directory."""
|
||||||
|
if isinstance(node, ast.Constant) and node.value == PROCFS_ROOT:
|
||||||
|
return True
|
||||||
|
# Path("/proc")
|
||||||
|
if isinstance(node, ast.Call):
|
||||||
|
return any(_is_procfs_root(arg) for arg in node.args)
|
||||||
|
# PROC_ROOT / _PROC_ROOT / proc_root / platform_compat.PROC_ROOT
|
||||||
|
name = None
|
||||||
|
if isinstance(node, ast.Name):
|
||||||
|
name = node.id
|
||||||
|
elif isinstance(node, ast.Attribute):
|
||||||
|
name = node.attr
|
||||||
|
return bool(name) and name.lower().lstrip("_") == "proc_root"
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_sites(tree: ast.AST) -> list[ast.Call]:
|
||||||
|
sites = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
continue
|
||||||
|
func = node.func
|
||||||
|
if isinstance(func, ast.Attribute) and func.attr in _SCAN_FUNCS:
|
||||||
|
if node.args and _is_procfs_root(node.args[0]):
|
||||||
|
sites.append(node)
|
||||||
|
elif isinstance(func, ast.Name) and func.id in _SCAN_FUNCS:
|
||||||
|
if node.args and _is_procfs_root(node.args[0]):
|
||||||
|
sites.append(node)
|
||||||
|
elif isinstance(func, ast.Attribute) and func.attr in _SCAN_METHODS:
|
||||||
|
if _is_procfs_root(func.value):
|
||||||
|
sites.append(node)
|
||||||
|
return sites
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_lines(tree: ast.AST) -> list[int]:
|
||||||
|
"""Line numbers of calls that test whether procfs is present."""
|
||||||
|
lines = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
continue
|
||||||
|
func = node.func
|
||||||
|
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
|
||||||
|
if name not in _GUARD_FUNCS:
|
||||||
|
continue
|
||||||
|
subject_is_procfs = (
|
||||||
|
name in {"has_procfs", "is_wsl"}
|
||||||
|
or (isinstance(func, ast.Attribute) and _is_procfs_root(func.value))
|
||||||
|
or any(_is_procfs_root(arg) for arg in node.args)
|
||||||
|
)
|
||||||
|
if subject_is_procfs:
|
||||||
|
lines.append(node.lineno)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _enclosing_scope(tree: ast.AST, node: ast.AST) -> ast.AST:
|
||||||
|
"""Smallest function/module scope containing ``node``."""
|
||||||
|
best = tree
|
||||||
|
for candidate in ast.walk(tree):
|
||||||
|
if not isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
continue
|
||||||
|
end = getattr(candidate, "end_lineno", None) or candidate.lineno
|
||||||
|
if candidate.lineno <= node.lineno <= end:
|
||||||
|
if best is tree or candidate.lineno > best.lineno:
|
||||||
|
best = candidate
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _app_python_files() -> list[Path]:
|
||||||
|
files = []
|
||||||
|
for entry in APP_TREES:
|
||||||
|
target = REPO_ROOT / entry
|
||||||
|
if target.is_file():
|
||||||
|
files.append(target)
|
||||||
|
elif target.is_dir():
|
||||||
|
files.extend(
|
||||||
|
p for p in target.rglob("*.py") if "__pycache__" not in p.parts
|
||||||
|
)
|
||||||
|
return sorted(files)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_sites() -> tuple[list[str], list[str]]:
|
||||||
|
"""Return (guarded, unguarded) ``path:line`` labels for procfs scans."""
|
||||||
|
guarded, unguarded = [], []
|
||||||
|
for path in _app_python_files():
|
||||||
|
try:
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
# Cheap pre-filter: a scan has to name the root somehow.
|
||||||
|
if PROCFS_ROOT not in source and "proc_root" not in source.lower():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
tree = ast.parse(source)
|
||||||
|
except SyntaxError:
|
||||||
|
continue
|
||||||
|
sites = _scan_sites(tree)
|
||||||
|
if not sites:
|
||||||
|
continue
|
||||||
|
guards = _guard_lines(tree)
|
||||||
|
for site in sites:
|
||||||
|
scope = _enclosing_scope(tree, site)
|
||||||
|
start = getattr(scope, "lineno", 0)
|
||||||
|
label = f"{path.relative_to(REPO_ROOT)}:{site.lineno}"
|
||||||
|
if any(start <= g < site.lineno for g in guards):
|
||||||
|
guarded.append(label)
|
||||||
|
else:
|
||||||
|
unguarded.append(label)
|
||||||
|
return guarded, unguarded
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_procfs_scan_is_guarded_by_an_existence_check():
|
||||||
|
_guarded, unguarded = _collect_sites()
|
||||||
|
assert not unguarded, (
|
||||||
|
"procfs pid scans with no existence check in the enclosing function — "
|
||||||
|
"these raise FileNotFoundError on macOS and Windows: "
|
||||||
|
+ ", ".join(unguarded)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_guard_detector_still_sees_the_known_scans():
|
||||||
|
"""A rename must not silently turn the assertion above into a no-op.
|
||||||
|
|
||||||
|
Lower bound, not an exact count: a new *guarded* scan is fine and should
|
||||||
|
not fail this. What must not happen is the detector going blind, which
|
||||||
|
shows up as sites disappearing.
|
||||||
|
"""
|
||||||
|
guarded, unguarded = _collect_sites()
|
||||||
|
found = set(guarded) | set(unguarded)
|
||||||
|
files = {label.rsplit(":", 1)[0] for label in found}
|
||||||
|
known = {"src/agent_tools/web_tools.py", "src/tools/cookbook.py"}
|
||||||
|
assert known <= files, (
|
||||||
|
"the detector no longer sees a known procfs scan — check whether the "
|
||||||
|
f"root was renamed. Found: {sorted(found)}"
|
||||||
|
)
|
||||||
|
assert len(found) >= 3, f"expected at least 3 procfs scans, found {sorted(found)}"
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Execute the actual DOCX handlers with deferred network responses."""
|
"""Execute the actual DOCX handlers with deferred network responses."""
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
def test_docx_responses_do_not_overwrite_new_tabs_or_hidden_previews():
|
def test_docx_responses_do_not_overwrite_new_tabs_or_hidden_previews():
|
||||||
source = (Path(__file__).resolve().parents[1] / "static/js/document.js").read_text()
|
source = document_source()
|
||||||
handlers = source.split(" let _docxPreviewRequest = 0;", 1)[1].split(" /** Parse CSV", 1)[0]
|
handlers = source.split(" let _docxPreviewRequest = 0;", 1)[1].split(" /** Parse CSV", 1)[0]
|
||||||
script = r'''
|
script = r'''
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def test_richtext_toolbar_preserves_selection_before_formatting():
|
def test_richtext_toolbar_preserves_selection_before_formatting():
|
||||||
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
source = document_source()
|
||||||
|
|
||||||
assert "let _savedFormatTextareaSelection = null;" in source
|
assert "let _savedFormatTextareaSelection = null;" in source
|
||||||
assert "let _savedFormatRichRange = null;" in source
|
assert "let _savedFormatRichRange = null;" in source
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def test_richtext_preview_returns_to_contenteditable_editor():
|
def test_richtext_preview_returns_to_contenteditable_editor():
|
||||||
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
source = document_source()
|
||||||
|
|
||||||
assert "const currentLang = document.getElementById('doc-language-select')?.value || '';" in source
|
assert "const currentLang = document.getElementById('doc-language-select')?.value || '';" in source
|
||||||
assert "if (richMode) {" in source
|
assert "if (richMode) {" in source
|
||||||
|
|||||||
@@ -21,12 +21,21 @@ def _test_utcnow():
|
|||||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
def _stub_heavy():
|
def _stub_heavy(monkeypatch):
|
||||||
|
"""Stub the heavy modules ``task_scheduler`` imports, for this test only.
|
||||||
|
|
||||||
|
Registered through ``monkeypatch.setitem`` so every entry is removed at
|
||||||
|
teardown. A bare ``sys.modules[name] = ...`` leaves an empty module behind
|
||||||
|
for the rest of the session, and any later test that imports the real one
|
||||||
|
silently gets the stub instead - a failure that only shows up under a
|
||||||
|
different collection order.
|
||||||
|
"""
|
||||||
for name in [
|
for name in [
|
||||||
"src.builtin_actions", "src.ai_interaction", "src.endpoint_resolver",
|
"src.builtin_actions", "src.ai_interaction", "src.endpoint_resolver",
|
||||||
"src.agent_loop", "src.session_manager",
|
"src.agent_loop", "src.session_manager",
|
||||||
]:
|
]:
|
||||||
sys.modules.setdefault(name, types.ModuleType(name))
|
if name not in sys.modules:
|
||||||
|
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
|
||||||
|
|
||||||
|
|
||||||
def _setup_isolated_db():
|
def _setup_isolated_db():
|
||||||
@@ -74,7 +83,7 @@ def test_scheduler_utcnow_preserves_naive_utc_contract():
|
|||||||
|
|
||||||
def _drive_scheduler(monkeypatch, pre_start_setup=None):
|
def _drive_scheduler(monkeypatch, pre_start_setup=None):
|
||||||
"""Build a TaskScheduler bypassing __init__ and run start() + two polls."""
|
"""Build a TaskScheduler bypassing __init__ and run start() + two polls."""
|
||||||
_stub_heavy()
|
_stub_heavy(monkeypatch)
|
||||||
cd, ScheduledTask, TaskRun = _setup_isolated_db()
|
cd, ScheduledTask, TaskRun = _setup_isolated_db()
|
||||||
|
|
||||||
from src.task_scheduler import TaskScheduler
|
from src.task_scheduler import TaskScheduler
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
|
from tests.helpers.document_source import document_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def test_selection_overlays_have_individual_clear_controls():
|
def test_selection_overlays_have_individual_clear_controls():
|
||||||
js = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
js = document_source()
|
||||||
css = app_css()
|
css = app_css()
|
||||||
|
|
||||||
assert "function clearSelectionAt(index)" in js
|
assert "function clearSelectionAt(index)" in js
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import importlib
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -28,6 +27,7 @@ from routes.shell_routes import (
|
|||||||
_venv_activate_prefix,
|
_venv_activate_prefix,
|
||||||
DOCKER_IN_CONTAINER_HINT,
|
DOCKER_IN_CONTAINER_HINT,
|
||||||
)
|
)
|
||||||
|
from tests.helpers.unix_sockets import bound_unix_socket
|
||||||
|
|
||||||
|
|
||||||
def test_shell_routes_import_without_posix_pty_modules(monkeypatch):
|
def test_shell_routes_import_without_posix_pty_modules(monkeypatch):
|
||||||
@@ -294,30 +294,25 @@ class TestHostDockerAccess:
|
|||||||
def test_socket_without_explicit_opt_in_is_disabled(
|
def test_socket_without_explicit_opt_in_is_disabled(
|
||||||
self,
|
self,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
|
||||||
flag,
|
flag,
|
||||||
):
|
):
|
||||||
socket_path = tmp_path / "docker.sock"
|
# Not tmp_path: binding under $TMPDIR overruns sun_path on macOS.
|
||||||
with socket.socket(socket.AF_UNIX) as unix_socket:
|
with bound_unix_socket() as socket_path:
|
||||||
unix_socket.bind(str(socket_path))
|
|
||||||
if flag is None:
|
if flag is None:
|
||||||
monkeypatch.delenv("ODYSSEUS_ENABLE_HOST_DOCKER", raising=False)
|
monkeypatch.delenv("ODYSSEUS_ENABLE_HOST_DOCKER", raising=False)
|
||||||
else:
|
else:
|
||||||
monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", flag)
|
monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", flag)
|
||||||
|
|
||||||
assert _host_docker_access_enabled(str(socket_path)) is False
|
assert _host_docker_access_enabled(socket_path) is False
|
||||||
|
|
||||||
def test_explicit_opt_in_with_unix_socket_is_enabled(
|
def test_explicit_opt_in_with_unix_socket_is_enabled(
|
||||||
self,
|
self,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
|
||||||
):
|
):
|
||||||
socket_path = tmp_path / "docker.sock"
|
with bound_unix_socket() as socket_path:
|
||||||
with socket.socket(socket.AF_UNIX) as unix_socket:
|
|
||||||
unix_socket.bind(str(socket_path))
|
|
||||||
monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", "true")
|
monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", "true")
|
||||||
|
|
||||||
assert _host_docker_access_enabled(str(socket_path)) is True
|
assert _host_docker_access_enabled(socket_path) is True
|
||||||
|
|
||||||
|
|
||||||
class TestPackageProbeStatus:
|
class TestPackageProbeStatus:
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""No two @keyframes may share a name across the app's stylesheets.
|
||||||
|
|
||||||
|
Duplicate names resolve last-wins across the whole cascade, so an earlier
|
||||||
|
definition is dead code that still looks live at its call site. Two of them
|
||||||
|
existed and differed from the definition that actually won, which also blocked
|
||||||
|
moving either animation during the stylesheet decomposition: relocating one
|
||||||
|
changes which is last and therefore changes rendered behaviour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import collections
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
STATIC = ROOT / "static"
|
||||||
|
|
||||||
|
|
||||||
|
def _stylesheets() -> list[Path]:
|
||||||
|
"""App-owned stylesheets, in no particular order. Vendored CSS is excluded."""
|
||||||
|
sheets = [STATIC / "style.css"]
|
||||||
|
sheets.extend(sorted((STATIC / "css").glob("*.css")))
|
||||||
|
return [s for s in sheets if s.exists()]
|
||||||
|
|
||||||
|
|
||||||
|
def _keyframe_names(text: str) -> list[str]:
|
||||||
|
without_comments = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
|
||||||
|
return re.findall(
|
||||||
|
r"@(?:-webkit-)?keyframes\s+([A-Za-z0-9_-]+)\s*\{", without_comments
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_stylesheets_exist() -> None:
|
||||||
|
"""Guard the guard: a rename must not turn this file into a no-op."""
|
||||||
|
assert _stylesheets(), "no app stylesheets found to check"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_keyframes_name_is_declared_twice() -> None:
|
||||||
|
counts: collections.Counter[str] = collections.Counter()
|
||||||
|
where: dict[str, list[str]] = collections.defaultdict(list)
|
||||||
|
|
||||||
|
for sheet in _stylesheets():
|
||||||
|
for name in _keyframe_names(sheet.read_text(encoding="utf-8")):
|
||||||
|
counts[name] += 1
|
||||||
|
where[name].append(sheet.relative_to(ROOT).as_posix())
|
||||||
|
|
||||||
|
duplicates = {name: where[name] for name, n in counts.items() if n > 1}
|
||||||
|
|
||||||
|
assert duplicates == {}, (
|
||||||
|
"duplicate @keyframes names resolve last-wins, so every definition but "
|
||||||
|
f"the last is dead: {duplicates}"
|
||||||
|
)
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Tests must reason about the whole cascade, not one file of it.
|
"""Tests must reason about the whole cascade, not one file of it.
|
||||||
|
|
||||||
``static/style.css`` is being decomposed. A test that reads that file alone,
|
The former ``static/style.css`` has been decomposed into an ordered cascade.
|
||||||
or builds a synthetic page linking only that file, silently loses every rule
|
A test that names the deleted file as a runtime stylesheet silently loses the
|
||||||
that has moved: it keeps passing while covering less. Both mistakes existed
|
split cascade. Python, JavaScript, MJS and HTML test sources are all scanned
|
||||||
and are cheap to detect, so this fails on either.
|
recursively so nested browser tests cannot escape this guard.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -17,12 +17,20 @@ SELF = Path(__file__).name
|
|||||||
# set itself, so naming the file is the point rather than a mistake.
|
# set itself, so naming the file is the point rather than a mistake.
|
||||||
ALLOWED = {SELF, "test_static_stylesheet_manifest.py", "test_css_computed_style_snapshot.py"}
|
ALLOWED = {SELF, "test_static_stylesheet_manifest.py", "test_css_computed_style_snapshot.py"}
|
||||||
|
|
||||||
_DIRECT_READ = re.compile(r'["\']static/style\.css["\']|"static"\s*/\s*"style\.css"')
|
_DELETED_STYLE_LITERAL = re.compile(
|
||||||
_LONE_LINK = re.compile(r'<link[^>]*href="/static/style\.css')
|
r'''["'][^"'\n]*static/style\.css[^"'\n]*["']'''
|
||||||
|
)
|
||||||
|
_SUFFIXES = {".py", ".js", ".mjs", ".html"}
|
||||||
|
|
||||||
|
|
||||||
def _test_sources():
|
def _test_sources():
|
||||||
return [p for p in sorted((ROOT / "tests").glob("*.py")) if p.name not in ALLOWED]
|
return [
|
||||||
|
p
|
||||||
|
for p in sorted((ROOT / "tests").rglob("*"))
|
||||||
|
if p.is_file()
|
||||||
|
and p.suffix in _SUFFIXES
|
||||||
|
and p.name not in ALLOWED
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_sources_are_discoverable() -> None:
|
def test_sources_are_discoverable() -> None:
|
||||||
@@ -30,25 +38,14 @@ def test_sources_are_discoverable() -> None:
|
|||||||
assert len(_test_sources()) > 100
|
assert len(_test_sources()) > 100
|
||||||
|
|
||||||
|
|
||||||
def test_no_test_reads_style_css_as_the_whole_cascade() -> None:
|
def test_no_test_names_deleted_style_css_as_a_runtime_asset() -> None:
|
||||||
offenders = [
|
offenders = [
|
||||||
p.name for p in _test_sources()
|
str(p.relative_to(ROOT))
|
||||||
if _DIRECT_READ.search(p.read_text(encoding="utf-8"))
|
for p in _test_sources()
|
||||||
|
if _DELETED_STYLE_LITERAL.search(p.read_text(encoding="utf-8"))
|
||||||
]
|
]
|
||||||
|
|
||||||
assert offenders == [], (
|
assert offenders == [], (
|
||||||
"read the cascade with tests.helpers.stylesheets.app_css() instead of "
|
"tests must load the app stylesheet cascade instead of the deleted "
|
||||||
f"static/style.css alone: {offenders}"
|
f"static/style.css asset: {offenders}"
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_synthetic_page_links_style_css_alone() -> None:
|
|
||||||
offenders = [
|
|
||||||
p.name for p in _test_sources()
|
|
||||||
if _LONE_LINK.search(p.read_text(encoding="utf-8"))
|
|
||||||
]
|
|
||||||
|
|
||||||
assert offenders == [], (
|
|
||||||
"build synthetic pages with tests.helpers.stylesheets.stylesheet_link_tags() "
|
|
||||||
f"so they get every stylesheet index.html loads: {offenders}"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""A successful Tailscale query with no eligible hosts is still cached knowledge.
|
||||||
|
|
||||||
|
`discover_tailscale_hosts` gated its cache on the host list being non-empty, so a
|
||||||
|
valid "nothing to see here" answer looked identical to a cold cache and every
|
||||||
|
caller paid for another `tailscale status --json` (up to a 5s timeout). Failures
|
||||||
|
stay uncached so a peer coming online is still picked up promptly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import model_discovery
|
||||||
|
|
||||||
|
|
||||||
|
class _Result:
|
||||||
|
def __init__(self, returncode, stdout):
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stdout = stdout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tailscale(monkeypatch):
|
||||||
|
"""Count `tailscale status` invocations and start from a cold cache."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def _record(result):
|
||||||
|
def _run(*_args, **_kwargs):
|
||||||
|
calls.append(1)
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
return result
|
||||||
|
monkeypatch.setattr(model_discovery.subprocess, "run", _run)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_discovery, "_hosts_cache", [])
|
||||||
|
monkeypatch.setattr(model_discovery, "_hosts_cache_time", 0)
|
||||||
|
return _record
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_but_successful_discovery_is_only_run_once(tailscale):
|
||||||
|
calls = tailscale(_Result(0, '{"Self":{},"Peer":{}}'))
|
||||||
|
|
||||||
|
assert model_discovery.discover_tailscale_hosts() == []
|
||||||
|
assert model_discovery.discover_tailscale_hosts() == []
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonempty_discovery_is_still_cached(tailscale):
|
||||||
|
calls = tailscale(_Result(0, '{"Self":{"TailscaleIPs":["100.1.1.1"]},"Peer":{}}'))
|
||||||
|
|
||||||
|
assert model_discovery.discover_tailscale_hosts() == ["100.1.1.1"]
|
||||||
|
assert model_discovery.discover_tailscale_hosts() == ["100.1.1.1"]
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"result",
|
||||||
|
[
|
||||||
|
_Result(1, ""), # tailscale installed but logged out
|
||||||
|
_Result(0, "not json"), # unparseable output
|
||||||
|
FileNotFoundError("tailscale"), # not installed
|
||||||
|
],
|
||||||
|
ids=["nonzero_exit", "bad_json", "not_installed"],
|
||||||
|
)
|
||||||
|
def test_failures_stay_retryable(tailscale, result):
|
||||||
|
calls = tailscale(result)
|
||||||
|
|
||||||
|
assert model_discovery.discover_tailscale_hosts() == []
|
||||||
|
assert model_discovery.discover_tailscale_hosts() == []
|
||||||
|
assert len(calls) == 2
|
||||||
@@ -225,8 +225,13 @@ async def test_glob_confined_e2e(ws, admin):
|
|||||||
assert ws not in r["output"]
|
assert ws not in r["output"]
|
||||||
assert "/workspace/found.py" in r["output"]
|
assert "/workspace/found.py" in r["output"]
|
||||||
|
|
||||||
# a secret outside the workspace must not be discoverable via glob
|
# a secret outside the workspace must not be discoverable via glob.
|
||||||
outside = tempfile.mkdtemp()
|
# realpath so this directory and os.path.realpath(ws) below sit in the same
|
||||||
|
# resolved tree. On macOS /tmp is a symlink to /private/tmp, and mixing a
|
||||||
|
# resolved workspace with an unresolved secret makes relpath emit
|
||||||
|
# "../../../../tmp/<abs path>", which trivially contains the absolute path
|
||||||
|
# the assertion is checking for.
|
||||||
|
outside = os.path.realpath(tempfile.mkdtemp())
|
||||||
secret = os.path.join(outside, "secret.txt")
|
secret = os.path.join(outside, "secret.txt")
|
||||||
with open(secret, "w") as f:
|
with open(secret, "w") as f:
|
||||||
f.write("nope")
|
f.write("nope")
|
||||||
|
|||||||
Reference in New Issue
Block a user