mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-29 17:22:25 +01:00
feat(editor): harden photo editor security and interactions
This commit is contained in:
@@ -53,6 +53,8 @@ services/data/
|
||||
|
||||
# Test capture artifacts (browser session dumps may contain personal content)
|
||||
.playwright-mcp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -25,7 +25,7 @@ import re
|
||||
import uuid
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from src import editor_style_guide as style_guide
|
||||
@@ -1173,12 +1173,35 @@ def _normalize_steps(body: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def _user(request: Request) -> Optional[str]:
|
||||
def _is_admin(request: Request) -> bool:
|
||||
try:
|
||||
from src.auth_helpers import get_current_user
|
||||
return get_current_user(request)
|
||||
from core.middleware import require_admin
|
||||
require_admin(request)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _user(request: Request) -> Optional[str]:
|
||||
# Handle API bearer tokens explicitly: require 'chat' scope and attribute to real owner
|
||||
if getattr(request.state, "api_token", False):
|
||||
from src.auth_helpers import require_api_token_scope
|
||||
return require_api_token_scope(request, "chat")
|
||||
|
||||
from src.auth_helpers import get_current_user, _auth_disabled
|
||||
u = get_current_user(request)
|
||||
if u:
|
||||
return u
|
||||
if _auth_disabled():
|
||||
return None
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
client = getattr(request, "client", None)
|
||||
host = (client.host if client else "") or ""
|
||||
if host in ("127.0.0.1", "::1", "localhost", "testclient") or not host:
|
||||
return None
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
|
||||
|
||||
async def _default_editor_planner(messages: List[dict], owner: Optional[str]) -> Optional[str]:
|
||||
@@ -1297,9 +1320,13 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
except Exception:
|
||||
raw = None
|
||||
_used = editor_agent_model.get_last_model()
|
||||
model_used = (_used[0] if _used and _used[0]
|
||||
else (agent_model or editor_agent_model.local_model()))
|
||||
parsed = _parse_plan(raw)
|
||||
if _used and _used[0]:
|
||||
model_used = _used[0]
|
||||
elif parsed:
|
||||
model_used = agent_model or editor_agent_model.local_model()
|
||||
else:
|
||||
model_used = "fallback" if agent_model else editor_agent_model.local_model()
|
||||
if isinstance(parsed, dict):
|
||||
parsed["agent_model"] = model_used
|
||||
if mode != "check":
|
||||
@@ -1494,6 +1521,12 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
overwritten.
|
||||
"""
|
||||
owner = _user(request)
|
||||
from src.settings import get_setting
|
||||
disabled = get_setting("disabled_tools", []) or []
|
||||
if not isinstance(disabled, (list, tuple, set)):
|
||||
disabled = []
|
||||
if "plan_edit" in disabled:
|
||||
raise HTTPException(403, "Editor tool is disabled")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1506,7 +1539,7 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
|
||||
# Optional user-picked PLANNER model (the editor agent/planner model).
|
||||
agent_model = str(body.get("agent_model") or "").strip()
|
||||
tools_offered = _scoped_editor_tools()
|
||||
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
||||
scoped = _scoped(session, owner)
|
||||
trace: List[Dict[str, Any]] = [
|
||||
{"type": "session", "session": scoped, "tools_offered": tools_offered},
|
||||
@@ -1540,9 +1573,14 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
except Exception:
|
||||
raw = None
|
||||
_used = editor_agent_model.get_last_model()
|
||||
model_used = (_used[0] if _used and _used[0]
|
||||
else (agent_model or editor_agent_model.local_model()))
|
||||
plan = _parse_plan(raw) or _fallback_plan(prompt)
|
||||
parsed = _parse_plan(raw)
|
||||
if _used and _used[0]:
|
||||
model_used = _used[0]
|
||||
elif parsed:
|
||||
model_used = agent_model or editor_agent_model.local_model()
|
||||
else:
|
||||
model_used = "fallback" if agent_model else editor_agent_model.local_model()
|
||||
plan = parsed or _fallback_plan(prompt)
|
||||
_strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or []))
|
||||
if _strengthened != plan.get("steps"):
|
||||
plan = {**plan, "steps": _strengthened,
|
||||
@@ -1618,6 +1656,12 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
"results": [],
|
||||
"agent_model": model_used,
|
||||
})
|
||||
needed = {"plan_edit"}
|
||||
for step in steps:
|
||||
op = step.get("op")
|
||||
needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command")
|
||||
if needed.intersection(disabled):
|
||||
raise HTTPException(403, "Editor tool is disabled")
|
||||
results: List[Dict[str, Any]] = []
|
||||
for step in steps:
|
||||
op = step.get("op")
|
||||
@@ -1726,6 +1770,8 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
``edit`` (editing/inpainting models) or ``generate`` (text-to-image).
|
||||
"""
|
||||
owner = _user(request)
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
is_admin = owner_is_admin_or_single_user(owner)
|
||||
models: List[Dict[str, Any]] = []
|
||||
seen: set = set()
|
||||
try:
|
||||
@@ -1748,22 +1794,25 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
# recognisably image-capable — the same test the request
|
||||
# router uses.
|
||||
image_endpoint = getattr(ep, "model_type", None) == "image"
|
||||
provider = getattr(ep, "name", None) or getattr(ep, "base_url", "") or "image"
|
||||
endpoint_id = getattr(ep, "id", None)
|
||||
provider = getattr(ep, "name", None) or (f"Endpoint {endpoint_id}" if endpoint_id else "image")
|
||||
for name in _endpoint_enabled_models(ep):
|
||||
if not image_endpoint and not looks_like_image_generation_model(name):
|
||||
continue
|
||||
key = (provider, name)
|
||||
key = (str(endpoint_id), name)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
models.append({
|
||||
entry = {
|
||||
"id": name,
|
||||
"label": name,
|
||||
"kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate",
|
||||
"provider": provider,
|
||||
"endpoint_id": getattr(ep, "id", None),
|
||||
"base_url": getattr(ep, "base_url", "") or "",
|
||||
})
|
||||
"endpoint_id": endpoint_id,
|
||||
}
|
||||
if is_admin:
|
||||
entry["base_url"] = getattr(ep, "base_url", "") or ""
|
||||
models.append(entry)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
@@ -1774,21 +1823,39 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
async def editor_agent_models(request: Request) -> JSONResponse:
|
||||
"""List the models the editor agent/planner can run on.
|
||||
|
||||
Each entry is ``{id, label, source, vision, base_url}`` where ``id`` is
|
||||
Each entry is ``{id, label, source, vision}`` where ``id`` is
|
||||
passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and
|
||||
``source`` is ``local`` (the local Qwen3.5-9B vision server) or
|
||||
``hosted`` (the configured default/utility chat model that the planner
|
||||
falls back to).
|
||||
For non-admin users, private URLs and internal probe status are omitted.
|
||||
"""
|
||||
owner = _user(request)
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
is_admin = owner_is_admin_or_single_user(owner)
|
||||
try:
|
||||
models = await editor_agent_model.list_editor_agent_models(owner)
|
||||
except Exception:
|
||||
logger.warning("editor agent-models lookup failed", exc_info=True)
|
||||
models = []
|
||||
|
||||
safe_models = []
|
||||
for m in models:
|
||||
entry = dict(m)
|
||||
if not is_admin:
|
||||
entry.pop("base_url", None)
|
||||
safe_models.append(entry)
|
||||
|
||||
probe_info = {}
|
||||
if is_admin:
|
||||
probe_info = editor_agent_model.probe_status()
|
||||
if not probe_info:
|
||||
editor_agent_model.startup_probe()
|
||||
probe_info = editor_agent_model.probe_status()
|
||||
|
||||
return JSONResponse({
|
||||
"models": models,
|
||||
"probe": editor_agent_model.probe_status(),
|
||||
"models": safe_models,
|
||||
"probe": probe_info,
|
||||
})
|
||||
|
||||
@router.post("/api/editor/self-check")
|
||||
|
||||
@@ -683,9 +683,15 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: st
|
||||
configured.update(v for v in values if isinstance(v, str))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if model not in configured or not looks_like_image_generation_model(model):
|
||||
continue
|
||||
if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target:
|
||||
if model:
|
||||
if model not in configured or not looks_like_image_generation_model(model):
|
||||
continue
|
||||
else:
|
||||
if not any(looks_like_image_generation_model(v) for v in configured):
|
||||
continue
|
||||
ep_id = str(getattr(ep, "id", "") or "")
|
||||
ep_base = _normalize_image_endpoint_base(getattr(ep, "base_url", ""))
|
||||
if (ep_id and (base == ep_id or target == ep_id)) or (ep_base and ep_base == target):
|
||||
if owner and getattr(ep, "owner", None) == owner:
|
||||
return ep
|
||||
if fallback is None:
|
||||
@@ -712,7 +718,7 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]:
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
|
||||
async with httpx.AsyncClient(timeout=60) as c2:
|
||||
async with httpx.AsyncClient(timeout=60, follow_redirects=False) as c2:
|
||||
ir = await c2.get(url)
|
||||
if ir.status_code == 200:
|
||||
return base64.b64encode(ir.content).decode()
|
||||
@@ -1727,7 +1733,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
||||
# SSRF hardening: validate a client-supplied endpoint before any
|
||||
# outbound request (mirrors routes/embedding_routes.py).
|
||||
if requested_base:
|
||||
if requested_base and "://" in requested_base:
|
||||
from src.url_safety import check_outbound_url
|
||||
ok, reason = check_outbound_url(
|
||||
requested_base,
|
||||
@@ -2073,7 +2079,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
runs off the event loop (large images).
|
||||
"""
|
||||
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
||||
if requested_base:
|
||||
if requested_base and "://" in requested_base:
|
||||
from src.url_safety import check_outbound_url
|
||||
ok, reason = check_outbound_url(
|
||||
requested_base,
|
||||
@@ -2166,26 +2172,34 @@ def setup_gallery_routes() -> APIRouter:
|
||||
try:
|
||||
ep = None
|
||||
if endpoint_id:
|
||||
ep = owner_filter(
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id),
|
||||
ModelEndpoint, user,
|
||||
).first()
|
||||
if ep is None and requested_base:
|
||||
ep = _visible_image_endpoint_for_base(db, endpoint_id, user)
|
||||
if ep is None:
|
||||
return {"available": False, "reason": "endpoint not found or disabled"}
|
||||
elif requested_base:
|
||||
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
||||
if ep is None:
|
||||
if ep is None:
|
||||
return {"available": False, "reason": "endpoint not found or disabled"}
|
||||
else:
|
||||
ep = _first_visible_image_endpoint(db, user)
|
||||
if ep is None:
|
||||
return {"available": False, "reason": "no image endpoint configured"}
|
||||
base = ep.base_url.rstrip("/")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
from src.url_safety import check_outbound_url
|
||||
block_private = os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true"
|
||||
ok, reason = check_outbound_url(base, block_private=block_private)
|
||||
if not ok:
|
||||
return {"available": False, "reason": f"endpoint URL rejected: {reason}"}
|
||||
|
||||
if not base.endswith("/v1"):
|
||||
base += "/v1"
|
||||
# Dynamic path segment (validated token above), so not routed through
|
||||
# _join_checked_gallery_endpoint's exact-match whitelist.
|
||||
url = f"{base}/images/progress/{request_id}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
|
||||
r = await client.get(url)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
@@ -2223,7 +2237,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# below, so validate it first (mirrors routes/embedding_routes.py).
|
||||
# Local-first means loopback/LAN is allowed by default; the cloud
|
||||
# metadata range and non-HTTP(S) schemes are always rejected.
|
||||
if requested_base:
|
||||
if requested_base and "://" in requested_base:
|
||||
from src.url_safety import check_outbound_url
|
||||
ok, reason = check_outbound_url(
|
||||
requested_base,
|
||||
@@ -2250,7 +2264,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# so the outbound URL never depends directly on request-body input.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
||||
ep = _visible_image_endpoint_for_base(db, requested_base, user, model)
|
||||
if not ep:
|
||||
raise HTTPException(403, "Choose a registered image endpoint")
|
||||
base = ep.base_url.rstrip("/")
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sanitize Playwright trace archives to strip sensitive credentials and tokens.
|
||||
|
||||
Structured, format-aware fail-closed security:
|
||||
Replaces naive regex-based replacement with structured JSON / HAR parsing,
|
||||
recursive key/value redaction, URL credential scrubbing, DOM snapshot sanitization,
|
||||
safe source code masking, and strict fail-closed purge on corruption or error.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
from typing import Any, Dict, List, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BINARY_IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico')
|
||||
|
||||
SENSITIVE_KEY_PATTERN = re.compile(
|
||||
r'(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)'
|
||||
)
|
||||
|
||||
SENSITIVE_PARAM_NAMES = frozenset({
|
||||
"token", "session", "auth", "password", "passwd", "key", "secret", "api_key",
|
||||
"apikey", "access_token", "refresh_token", "jwt", "sid", "code", "sig",
|
||||
"signature", "client_secret", "credential"
|
||||
})
|
||||
|
||||
SENSITIVE_HEADER_NAMES = frozenset({
|
||||
"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key",
|
||||
"api-key", "x-auth-token", "x-access-token"
|
||||
})
|
||||
|
||||
|
||||
def sanitize_url(url: str) -> str:
|
||||
"""Sanitize query parameters and authority credentials in URLs."""
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
return url
|
||||
try:
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
netloc = parts.netloc
|
||||
if "@" in netloc:
|
||||
userinfo, host = netloc.split("@", 1)
|
||||
if ":" in userinfo:
|
||||
user, _ = userinfo.split(":", 1)
|
||||
netloc = f"{user}:[REDACTED]@{host}"
|
||||
else:
|
||||
netloc = f"[REDACTED]@{host}"
|
||||
|
||||
query = parts.query
|
||||
if query:
|
||||
q_pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
|
||||
sanitized_pairs = []
|
||||
for k, v in q_pairs:
|
||||
k_lower = k.lower().replace("-", "_")
|
||||
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
|
||||
sanitized_pairs.append((k, "[REDACTED]"))
|
||||
else:
|
||||
sanitized_pairs.append((k, v))
|
||||
query = urllib.parse.urlencode(sanitized_pairs)
|
||||
|
||||
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment))
|
||||
|
||||
|
||||
def sanitize_body_text(text: str) -> str:
|
||||
"""Sanitize request bodies formatted as JSON or URL-encoded form data."""
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return text
|
||||
|
||||
# 1. Try JSON
|
||||
trimmed = text.strip()
|
||||
if (trimmed.startswith("{") and trimmed.endswith("}")) or (trimmed.startswith("[") and trimmed.endswith("]")):
|
||||
try:
|
||||
parsed = json.loads(trimmed)
|
||||
sanitized = sanitize_structured_obj(parsed)
|
||||
return json.dumps(sanitized, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Try URL-encoded form data
|
||||
if "=" in trimmed and not trimmed.startswith("<"):
|
||||
try:
|
||||
pairs = urllib.parse.parse_qsl(trimmed, keep_blank_values=True)
|
||||
if pairs and any(k.lower() in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k) for k, _ in pairs):
|
||||
out_pairs = []
|
||||
for k, v in pairs:
|
||||
k_lower = k.lower().replace("-", "_")
|
||||
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
|
||||
out_pairs.append((k, "[REDACTED]"))
|
||||
else:
|
||||
out_pairs.append((k, v))
|
||||
return urllib.parse.urlencode(out_pairs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def sanitize_structured_obj(obj: Any) -> Any:
|
||||
"""Recursively sanitize Python objects decoded from JSON or HAR payloads."""
|
||||
if isinstance(obj, dict):
|
||||
new_dict: Dict[str, Any] = {}
|
||||
|
||||
# Check for header entry with inverted or standard keys: {"name": ..., "value": ...}
|
||||
name_val = obj.get("name")
|
||||
val_val = obj.get("value")
|
||||
if isinstance(name_val, str) and isinstance(val_val, str):
|
||||
name_lower = name_val.lower().strip()
|
||||
if name_lower in SENSITIVE_HEADER_NAMES or SENSITIVE_KEY_PATTERN.search(name_lower):
|
||||
for k, v in obj.items():
|
||||
if k == "value":
|
||||
new_dict[k] = "[REDACTED]"
|
||||
else:
|
||||
new_dict[k] = sanitize_structured_obj(v)
|
||||
return new_dict
|
||||
|
||||
# Process standard dictionary keys
|
||||
for k, v in obj.items():
|
||||
k_lower = str(k).lower().replace("-", "_")
|
||||
|
||||
# Action parameters in Playwright traces (e.g. page.fill, page.type)
|
||||
if k == "params" and isinstance(v, dict):
|
||||
sanitized_params = sanitize_structured_obj(v)
|
||||
# If this action involves fill, type, or sensitive inputs, redact value/text
|
||||
if "value" in sanitized_params:
|
||||
sanitized_params["value"] = "[REDACTED]"
|
||||
if "text" in sanitized_params:
|
||||
sanitized_params["text"] = "[REDACTED]"
|
||||
new_dict[k] = sanitized_params
|
||||
continue
|
||||
|
||||
# DOM snapshot nodes
|
||||
if k == "attributes" and isinstance(v, (dict, list)):
|
||||
if isinstance(v, dict):
|
||||
attr_dict = dict(v)
|
||||
type_val = str(attr_dict.get("type") or "").lower()
|
||||
if type_val == "password" or "password" in str(attr_dict.get("name") or "").lower():
|
||||
if "value" in attr_dict:
|
||||
attr_dict["value"] = "[REDACTED]"
|
||||
new_dict[k] = attr_dict
|
||||
continue
|
||||
elif isinstance(v, list):
|
||||
# List of attribute strings or pairs
|
||||
new_list = []
|
||||
is_pw = any("password" in str(x).lower() for x in v)
|
||||
for x in v:
|
||||
if is_pw and isinstance(x, str) and not x.startswith("type="):
|
||||
new_list.append("[REDACTED]")
|
||||
else:
|
||||
new_list.append(sanitize_structured_obj(x))
|
||||
new_dict[k] = new_list
|
||||
continue
|
||||
|
||||
# Request postData / text
|
||||
if k in ("postData", "postDataText") and isinstance(v, str):
|
||||
new_dict[k] = sanitize_body_text(v)
|
||||
continue
|
||||
|
||||
# URL fields
|
||||
if k == "url" and isinstance(v, str):
|
||||
new_dict[k] = sanitize_url(v)
|
||||
continue
|
||||
|
||||
# Cookie arrays
|
||||
if k == "cookies" and isinstance(v, list):
|
||||
sanitized_cookies = []
|
||||
for c in v:
|
||||
if isinstance(c, dict):
|
||||
c_copy = dict(c)
|
||||
if "value" in c_copy:
|
||||
c_copy["value"] = "[REDACTED]"
|
||||
sanitized_cookies.append(c_copy)
|
||||
else:
|
||||
sanitized_cookies.append(sanitize_structured_obj(c))
|
||||
new_dict[k] = sanitized_cookies
|
||||
continue
|
||||
|
||||
# Direct sensitive key matches
|
||||
if SENSITIVE_KEY_PATTERN.search(k_lower) and isinstance(v, str):
|
||||
new_dict[k] = "[REDACTED]"
|
||||
continue
|
||||
|
||||
new_dict[k] = sanitize_structured_obj(v)
|
||||
|
||||
return new_dict
|
||||
|
||||
elif isinstance(obj, list):
|
||||
return [sanitize_structured_obj(item) for item in obj]
|
||||
|
||||
elif isinstance(obj, str):
|
||||
# Check for inline credentials/tokens
|
||||
if obj.startswith("Bearer ") or obj.startswith("bearer "):
|
||||
return "Bearer [REDACTED]"
|
||||
if obj.startswith("Basic ") or obj.startswith("basic "):
|
||||
return "Basic [REDACTED]"
|
||||
return obj
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
def sanitize_jsonl_content(data: bytes) -> bytes:
|
||||
"""Sanitize newline-delimited JSON Lines (.trace, .network, .stacks)."""
|
||||
lines = data.split(b"\n")
|
||||
out_lines = []
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
out_lines.append(line)
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(line.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
# Not valid JSON, keep as-is
|
||||
out_lines.append(line)
|
||||
continue
|
||||
sanitized = sanitize_structured_obj(parsed)
|
||||
out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
|
||||
return b"\n".join(out_lines)
|
||||
|
||||
|
||||
def sanitize_trace_archive(zp: str) -> bool:
|
||||
"""Sanitize a single zip archive in a strict fail-closed manner.
|
||||
|
||||
If the archive is malformed, partial, unreadable, or encounters an exception
|
||||
during processing, the archive is permanently deleted so that raw traces
|
||||
cannot leak.
|
||||
Returns True if successfully sanitized, False if deleted due to error.
|
||||
"""
|
||||
if not os.path.exists(zp):
|
||||
return False
|
||||
|
||||
temp_zp = zp + ".tmp"
|
||||
try:
|
||||
if os.path.getsize(zp) == 0:
|
||||
raise zipfile.BadZipFile("Empty archive")
|
||||
|
||||
with zipfile.ZipFile(zp, "r") as zin:
|
||||
infolist = zin.infolist()
|
||||
if not infolist:
|
||||
raise zipfile.BadZipFile("Archive has no entries")
|
||||
|
||||
corrupt_file = zin.testzip()
|
||||
if corrupt_file is not None:
|
||||
raise zipfile.BadZipFile(f"Corrupt entry detected: {corrupt_file}")
|
||||
|
||||
with zipfile.ZipFile(temp_zp, "w", compression=zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in infolist:
|
||||
filename_lower = item.filename.lower()
|
||||
|
||||
# 1. Binary image screenshots/diagnostics: preserve as-is
|
||||
if filename_lower.endswith(BINARY_IMAGE_EXTENSIONS):
|
||||
content = zin.read(item.filename)
|
||||
zout.writestr(item, content)
|
||||
continue
|
||||
|
||||
# 2. Embedded test source code (e.g. resources/src@... or .js/.ts):
|
||||
# Mask safely without syntax corruption!
|
||||
if "src@" in filename_lower or filename_lower.endswith((".js", ".ts", ".mjs", ".py")):
|
||||
safe_placeholder = b"// [SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]\n"
|
||||
zout.writestr(item, safe_placeholder)
|
||||
continue
|
||||
|
||||
# 3. JSON Lines and structured JSON/HAR traces
|
||||
content = zin.read(item.filename)
|
||||
if filename_lower.endswith((".trace", ".network", ".stacks")):
|
||||
sanitized_content = sanitize_jsonl_content(content)
|
||||
zout.writestr(item, sanitized_content)
|
||||
elif filename_lower.endswith((".json", ".har")):
|
||||
try:
|
||||
parsed = json.loads(content.decode("utf-8"))
|
||||
sanitized = sanitize_structured_obj(parsed)
|
||||
zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
|
||||
except Exception:
|
||||
# If invalid JSON, treat as text line-by-line
|
||||
zout.writestr(item, sanitize_jsonl_content(content))
|
||||
else:
|
||||
# Unknown binary or text member: exclude to fail closed
|
||||
continue
|
||||
|
||||
# Atomic replace
|
||||
os.replace(temp_zp, zp)
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Sanitizing trace %s failed (%s); removing fail-closed", zp, exc)
|
||||
for path_to_remove in (temp_zp, zp):
|
||||
if os.path.exists(path_to_remove):
|
||||
try:
|
||||
os.remove(path_to_remove)
|
||||
except OSError as remove_err:
|
||||
logger.error("Failed to delete unredacted trace %s: %s", path_to_remove, remove_err)
|
||||
return False
|
||||
|
||||
|
||||
def sanitize_directory(results_dir: str) -> int:
|
||||
"""Walk results_dir and sanitize every .zip archive fail-closed.
|
||||
|
||||
Returns the number of archives processed. Exits with 1 if any archive fails.
|
||||
"""
|
||||
if not os.path.exists(results_dir):
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
all_ok = True
|
||||
for root, _, files in os.walk(results_dir):
|
||||
for f in files:
|
||||
if f.endswith(".zip"):
|
||||
zp = os.path.join(root, f)
|
||||
ok = sanitize_trace_archive(zp)
|
||||
if not ok:
|
||||
all_ok = False
|
||||
count += 1
|
||||
|
||||
if not all_ok:
|
||||
sys.exit(1)
|
||||
return count
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <results_directory>")
|
||||
sys.exit(1)
|
||||
|
||||
target_dir = sys.argv[1]
|
||||
sanitize_directory(target_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+233
-6
@@ -339,14 +339,222 @@ async def self_check(
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_override_candidate(override: str, owner: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Resolve an explicit agent_model override under server authority.
|
||||
|
||||
Guarantees:
|
||||
- Client-provided raw URLs never determine arbitrary outbound network destinations.
|
||||
- Registered endpoint IDs resolve only to enabled endpoints visible to the owner.
|
||||
- Shared endpoints (owner IS NULL) remain visible to authorized users.
|
||||
- Disabled, foreign-owned, forged, or unconfigured destinations fail closed (return None).
|
||||
- Admin privileges do not allow arbitrary unconfigured destinations.
|
||||
"""
|
||||
raw = str(override or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
if "::" in raw:
|
||||
target, requested_model = raw.split("::", 1)
|
||||
target = target.strip()
|
||||
requested_model = requested_model.strip()
|
||||
else:
|
||||
target = ""
|
||||
requested_model = raw
|
||||
|
||||
# 1. Explicit local selection or matching a configured local base
|
||||
if target == "local":
|
||||
bases = local_bases()
|
||||
if not bases:
|
||||
return None
|
||||
return {
|
||||
"base": bases[0],
|
||||
"model": requested_model or local_model(),
|
||||
"headers": None,
|
||||
"source": "override",
|
||||
}
|
||||
for lb in local_bases():
|
||||
if target and target.rstrip("/") == lb.rstrip("/"):
|
||||
return {
|
||||
"base": lb,
|
||||
"model": requested_model or local_model(),
|
||||
"headers": None,
|
||||
"source": "override",
|
||||
}
|
||||
|
||||
# 2. Explicit hosted fallback selection
|
||||
if target in ("default", "hosted"):
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
|
||||
for prefix in ("utility", "default"):
|
||||
try:
|
||||
url, model, headers = resolve_endpoint(prefix, owner=owner)
|
||||
except Exception:
|
||||
continue
|
||||
if url and model:
|
||||
return {
|
||||
"base": url,
|
||||
"model": requested_model or model,
|
||||
"headers": headers,
|
||||
"source": "hosted",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# 3. Bare model id without endpoint prefix
|
||||
if not target:
|
||||
if requested_model == "local":
|
||||
bases = local_bases()
|
||||
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} if bases else None
|
||||
if requested_model in ("default", "hosted"):
|
||||
return _resolve_override_candidate("default::", owner)
|
||||
|
||||
# Exact match with local model
|
||||
if requested_model == local_model():
|
||||
bases = local_bases()
|
||||
if bases:
|
||||
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"}
|
||||
|
||||
# Exact match with hosted default/utility
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
|
||||
for prefix in ("utility", "default"):
|
||||
try:
|
||||
url, model, headers = resolve_endpoint(prefix, owner=owner)
|
||||
except Exception:
|
||||
continue
|
||||
if url and model and model == requested_model:
|
||||
return {"base": url, "model": model, "headers": headers, "source": "hosted"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check registered database endpoints for this bare model
|
||||
try:
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from src.auth_helpers import owner_filter, _auth_disabled
|
||||
from src.endpoint_resolver import (
|
||||
resolve_endpoint_runtime,
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
_endpoint_enabled_models,
|
||||
_endpoint_hidden_models,
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if not owner and not _auth_disabled():
|
||||
q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.is_enabled == True,
|
||||
ModelEndpoint.owner == None,
|
||||
)
|
||||
else:
|
||||
q = owner_filter(
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
||||
ModelEndpoint,
|
||||
owner,
|
||||
)
|
||||
matching_eps = []
|
||||
for ep in q.all():
|
||||
enabled_models = _endpoint_enabled_models(ep)
|
||||
hidden_models = _endpoint_hidden_models(ep)
|
||||
if requested_model in enabled_models and requested_model not in hidden_models:
|
||||
matching_eps.append(ep)
|
||||
|
||||
# Avoid model ID collision across providers: only accept if unique
|
||||
if len(matching_eps) == 1:
|
||||
ep = matching_eps[0]
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
return {
|
||||
"base": build_chat_url(base),
|
||||
"model": requested_model,
|
||||
"headers": build_headers(api_key, base),
|
||||
"source": "registered",
|
||||
"endpoint_id": ep.id,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# 4. Target is an endpoint_id or a registered legacy endpoint URL
|
||||
try:
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from src.auth_helpers import owner_filter, _auth_disabled
|
||||
from src.endpoint_resolver import (
|
||||
resolve_endpoint_runtime,
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
_endpoint_enabled_models,
|
||||
_endpoint_hidden_models,
|
||||
_first_chat_model,
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if not owner and not _auth_disabled():
|
||||
q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.is_enabled == True,
|
||||
ModelEndpoint.owner == None,
|
||||
)
|
||||
else:
|
||||
q = owner_filter(
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
||||
ModelEndpoint,
|
||||
owner,
|
||||
)
|
||||
# Lookup by ID
|
||||
ep = q.filter(ModelEndpoint.id == target).first()
|
||||
|
||||
# If not found by ID, match legacy registered base URL (strictly registered rows only)
|
||||
if ep is None and ("://" in target or target.startswith("http")):
|
||||
target_norm = target.rstrip("/")
|
||||
for cand_ep in q.all():
|
||||
cand_base = (cand_ep.base_url or "").rstrip("/")
|
||||
if (
|
||||
cand_base == target_norm
|
||||
or cand_base + "/v1" == target_norm
|
||||
or cand_base == target_norm + "/v1"
|
||||
):
|
||||
ep = cand_ep
|
||||
break
|
||||
|
||||
if ep is None:
|
||||
# Target is disabled, foreign-owned, nonexistent, or arbitrary unconfigured URL -> fail closed!
|
||||
return None
|
||||
|
||||
enabled_models = _endpoint_enabled_models(ep)
|
||||
hidden_models = _endpoint_hidden_models(ep)
|
||||
if requested_model:
|
||||
if requested_model in hidden_models:
|
||||
return None
|
||||
if enabled_models and requested_model not in enabled_models:
|
||||
# Model not served by this endpoint -> fail closed against forged model IDs
|
||||
return None
|
||||
model = requested_model
|
||||
else:
|
||||
model = _first_chat_model(enabled_models)
|
||||
if not model:
|
||||
return None
|
||||
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
return {
|
||||
"base": build_chat_url(base),
|
||||
"model": model,
|
||||
"headers": build_headers(api_key, base),
|
||||
"source": "registered",
|
||||
"endpoint_id": ep.id,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
|
||||
cands: List[Dict[str, Any]] = []
|
||||
if override:
|
||||
base, model = _split_override(override)
|
||||
if model:
|
||||
cands.append(
|
||||
{"base": base or local_bases()[0], "model": model, "headers": None, "source": "override"}
|
||||
)
|
||||
for base in local_bases():
|
||||
cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"})
|
||||
try:
|
||||
@@ -362,6 +570,16 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str,
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if override:
|
||||
cand = _resolve_override_candidate(override, owner)
|
||||
if cand is not None:
|
||||
cands.insert(0, cand)
|
||||
return cands
|
||||
# Unauthorized, invalid, foreign, disabled, or forged override:
|
||||
# Fail closed! Never silently dispatch sensitive user images/state to an unexpected fallback.
|
||||
return []
|
||||
|
||||
return cands
|
||||
|
||||
|
||||
@@ -433,9 +651,11 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
|
||||
"source": "local",
|
||||
"vision": True,
|
||||
"base_url": base,
|
||||
"endpoint_id": "local",
|
||||
})
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.settings import get_user_setting, load_settings
|
||||
|
||||
for prefix in ("utility", "default"):
|
||||
try:
|
||||
@@ -446,12 +666,19 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
|
||||
key = ("hosted", model)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
ep_id = None
|
||||
try:
|
||||
settings = load_settings()
|
||||
ep_id = (get_user_setting(f"{prefix}_endpoint_id", owner or "", settings.get(f"{prefix}_endpoint_id", "")) or "").strip() or None
|
||||
except Exception:
|
||||
pass
|
||||
models.append({
|
||||
"id": model,
|
||||
"label": model,
|
||||
"source": "hosted",
|
||||
"vision": None,
|
||||
"base_url": url,
|
||||
"endpoint_id": ep_id or prefix,
|
||||
})
|
||||
break
|
||||
except Exception:
|
||||
|
||||
@@ -111,7 +111,9 @@
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: nowrap;
|
||||
row-gap: 4px;
|
||||
min-height: 40px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
max-height: 44px;
|
||||
padding: 4px 10px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -206,9 +208,19 @@
|
||||
pushed to the right edge. */
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
/* When transform section is active in controls, suppress any underlying tool section
|
||||
so transform controls (Cancel, Apply, X, Y, W, H) have the full width and are not
|
||||
overlapped or crowded by tool options (e.g. marquee invert/erase buttons). */
|
||||
.ge-controls.ge-transform-active > div:not(#ge-transform-section),
|
||||
.ge-controls:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section),
|
||||
.ge-controls--in-options:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section) {
|
||||
display: none !important;
|
||||
}
|
||||
/* The Text tool's option set is too wide for the thin strip: let the bar grow
|
||||
and wrap onto extra rows instead of clipping the trailing fields. */
|
||||
.ge-options-bar.ge-options-tall {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
overflow-y: visible;
|
||||
@@ -254,7 +266,7 @@
|
||||
right after the caret instead of leaving a wide gap. */
|
||||
.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }
|
||||
.ge-controls--in-options .ge-control-row {
|
||||
margin: 0;
|
||||
margin: 0 !important;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -488,8 +500,12 @@
|
||||
}
|
||||
.ge-opt-empty { font-size: 11px; opacity: 0.5; }
|
||||
.ge-options-empty .ge-options-sep { display: none; }
|
||||
/* An editor opened at phone width keeps its tool controls in their own bottom
|
||||
sheet, so the strip has nothing to show. One opened wider than 700px moves
|
||||
the controls into this strip; hiding it then would strand every tool control
|
||||
(tablet widths of 701-820px, or a window narrowed after opening). */
|
||||
@media (max-width: 820px) {
|
||||
.ge-options-bar { display: none; }
|
||||
.ge-options-bar:not(:has(.ge-controls--in-options)) { display: none; }
|
||||
}
|
||||
|
||||
/* Document tab strip (Photoshop-style) — names the open document and
|
||||
@@ -4198,7 +4214,11 @@ body.ge-editor-open textarea.ge-direct-text-editor {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.ge-save-state-icon { display: none; }
|
||||
.ge-save-state-icon { display: inline-block; vertical-align: middle; }
|
||||
.ge-save-state-icon[hidden] { display: none !important; }
|
||||
.ge-save-state-saved { color: var(--fg); }
|
||||
.ge-save-state-dirty { color: var(--accent, var(--red)); }
|
||||
.ge-save-state-error { color: var(--danger, #ef4444); }
|
||||
.ge-save-label { white-space: nowrap; min-width: 0; }
|
||||
/* Save button: glyph on its own row, then the SAVE action label and the
|
||||
live draft-status chip share the second row. */
|
||||
@@ -6341,6 +6361,10 @@ button .spinner-whirlpool {
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.72);
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
contain: strict;
|
||||
will-change: transform;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
/* Pixel brush: a hard square footprint instead of a feathered circle. */
|
||||
.ge-brush-cursor.ge-brush-cursor--pixel {
|
||||
@@ -6622,6 +6646,7 @@ button .spinner-whirlpool {
|
||||
}
|
||||
.ge-right-panel.expanded {
|
||||
transform: translateY(0);
|
||||
z-index: 100;
|
||||
}
|
||||
/* Minimized: panel slides almost fully off-screen — only a thin
|
||||
handle strip remains visible so the user can swipe back up. */
|
||||
@@ -8134,7 +8159,12 @@ input.ge-ctl-check:focus-visible {
|
||||
}
|
||||
.ge-setup-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||
@media (max-width: 700px) {
|
||||
/* Cover the phone viewport rather than the small, scrollable canvas area;
|
||||
the layers sheet otherwise hides the checklist's lower controls. */
|
||||
.ge-preflight { position: fixed; }
|
||||
.ge-preflight-card { padding: 13px 14px; }
|
||||
.ge-preflight-foot { flex-wrap: wrap; }
|
||||
.ge-preflight-start { white-space: nowrap; }
|
||||
}
|
||||
|
||||
/* ── Quick Edit — premium look ────────────────────────────────────────── */
|
||||
@@ -8296,17 +8326,37 @@ input.ge-ctl-check:focus-visible {
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.ge-editor-footer::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.ge-editor-footer .ge-status-tool,
|
||||
.ge-editor-footer .ge-status-model,
|
||||
.ge-editor-footer .ge-canvas-size {
|
||||
display: none;
|
||||
}
|
||||
.ge-footer-history { margin-right: 2px; }
|
||||
.ge-footer-zoom { gap: 3px; min-width: 0; }
|
||||
.ge-footer-history {
|
||||
margin-right: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ge-footer-zoom {
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
.ge-footer-zoom > .ge-btn,
|
||||
.ge-footer-history > .ge-btn { min-width: 26px; }
|
||||
.ge-setup-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Float the footer above the peek/expanded layers sheet so its zoom/setup
|
||||
controls stay tappable (position/left/right/bottom set in JS). */
|
||||
.ge-editor-footer.ge-footer-floating {
|
||||
@@ -8317,13 +8367,42 @@ input.ge-ctl-check:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
/* Phones: the floating zoom/Setup footer collides with the peek/expanded
|
||||
layers sheet and the Quick Edit card, so remove it entirely on phones.
|
||||
Undo/Redo/History already live in the layers-sheet header, and pinch-zoom
|
||||
replaces the zoom buttons. */
|
||||
/* Mobile layers header layout */
|
||||
@media (max-width: 700px) {
|
||||
.ge-editor-footer {
|
||||
display: none !important;
|
||||
.ge-layers-header {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
gap: 4px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.ge-layers-header::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.ge-layers-header .ge-footer-zoom {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ge-layers-header .ge-footer-zoom > .ge-btn,
|
||||
.ge-layers-header .ge-footer-history > .ge-btn {
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
}
|
||||
.ge-layers-header .ge-setup-btn {
|
||||
flex-shrink: 0;
|
||||
min-height: 28px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--panel) 85%, var(--fg) 15%);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* The Quick Edit card lifts over the layers sheet / keyboard; keep it above
|
||||
every other editor asset (sheet headers, tool rail, popups) while lifted. */
|
||||
@@ -8579,6 +8658,7 @@ body.ge-editor-open .toast.toast-transform-hint {
|
||||
/* Keep the floating editor footer from stacking on the Quick Edit card. The
|
||||
card owns the bottom of the canvas while it is open, so the footer (zoom /
|
||||
Setup / AI settings) hides until it closes. */
|
||||
body.ge-qe-open .ge-editor-footer.ge-footer-floating,
|
||||
body:has(.ge-ai-command:not(.ge-ai-command-collapsed)) .ge-editor-footer.ge-footer-floating {
|
||||
display: none;
|
||||
}
|
||||
@@ -8657,16 +8737,14 @@ body.ge-editor-open .toast.toast-transform-hint {
|
||||
}
|
||||
}
|
||||
|
||||
/* Phone menu bar: tighten the Photoshop menus and keep the Save control
|
||||
icon-only so the twelve menus + Import + Save are not crowded. */
|
||||
/* Phone menu bar: tighten the Photoshop menus. The compact topbar already
|
||||
drops the Save action label; the draft status stays visible because it is
|
||||
the save button's only announced (role="status") save state. */
|
||||
@media (max-width: 700px) {
|
||||
.ge-topbar-left .ge-stacked-btn {
|
||||
font-size: 11px;
|
||||
padding: 0 6px !important;
|
||||
}
|
||||
.ge-topbar.ge-topbar-compact #ge-draft-status {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Phone editor: dock the zoom/Setup footer flush to the Layers sheet. The
|
||||
@@ -9675,12 +9753,15 @@ body.ge-editor-open .tour-hint {
|
||||
}
|
||||
/* The dropdowns clip overflow to draw the custom scroll thumb; release the
|
||||
clip while a flyout is open/hovered so it can extend past the menu edge. */
|
||||
.ge-filter-menu.ge-flyout-open,
|
||||
.ge-filter-menu:has(.ge-menu-parent.open),
|
||||
.ge-filter-menu:has(.ge-menu-parent:hover),
|
||||
.ge-filter-menu:has(.ge-submenu:hover),
|
||||
.ge-image-menu.ge-flyout-open,
|
||||
.ge-image-menu:has(.ge-menu-parent.open),
|
||||
.ge-image-menu:has(.ge-menu-parent:hover),
|
||||
.ge-image-menu:has(.ge-submenu:hover),
|
||||
.ge-selection-menu.ge-flyout-open,
|
||||
.ge-selection-menu:has(.ge-menu-parent.open),
|
||||
.ge-selection-menu:has(.ge-menu-parent:hover),
|
||||
.ge-selection-menu:has(.ge-submenu:hover) {
|
||||
@@ -9689,6 +9770,7 @@ body.ge-editor-open .tour-hint {
|
||||
mask-image: none;
|
||||
}
|
||||
/* The Window menu's Workspace flyout needs the same clip release. */
|
||||
.ge-window-menu.ge-flyout-open,
|
||||
.ge-window-menu:has(.ge-menu-parent.open),
|
||||
.ge-window-menu:has(.ge-menu-parent:hover),
|
||||
.ge-window-menu:has(.ge-submenu:hover) {
|
||||
|
||||
@@ -72,7 +72,12 @@ function hexToHsv(hex) { const { r, g, b } = hexToRgb(hex); return rgbToHsv(r, g
|
||||
|
||||
// ── Storage ───────────────────────────────────────────────────────────
|
||||
function getRecents() {
|
||||
try { return JSON.parse(localStorage.getItem(LS_RECENT) || '[]'); }
|
||||
try {
|
||||
const values = JSON.parse(localStorage.getItem(LS_RECENT) || '[]');
|
||||
return Array.isArray(values)
|
||||
? values.filter(value => typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)).slice(0, MAX_RECENT)
|
||||
: [];
|
||||
}
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
@@ -85,17 +90,26 @@ function addRecent(hex) {
|
||||
}
|
||||
|
||||
// ── Suggestions based on current color (5 harmony swatches) ──────────
|
||||
function computeSuggestions() {
|
||||
function suggestionsForHsv(h, s, v) {
|
||||
// Complement, analogous ±30°, split-complement (+150), tone shift
|
||||
return [
|
||||
{ hex: hsvToHex(_h + 180, _s, _v), label: 'Complement' },
|
||||
{ hex: hsvToHex(_h + 30, _s, _v), label: 'Analogous +30°' },
|
||||
{ hex: hsvToHex(_h - 30, _s, _v), label: 'Analogous -30°' },
|
||||
{ hex: hsvToHex(_h + 150, _s, _v), label: 'Split-complement' },
|
||||
{ hex: hsvToHex(_h, _s, clamp(_v > 50 ? _v - 30 : _v + 30, 10, 95)), label: 'Tone shift' },
|
||||
{ hex: hsvToHex(h + 180, s, v), label: 'Complement' },
|
||||
{ hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' },
|
||||
{ hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' },
|
||||
{ hex: hsvToHex(h + 150, s, v), label: 'Split-complement' },
|
||||
{ hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
|
||||
];
|
||||
}
|
||||
|
||||
function computeSuggestions() { return suggestionsForHsv(_h, _s, _v); }
|
||||
|
||||
export function getRecentColors() { return getRecents(); }
|
||||
export function recordRecentColor(hex) { addRecent(hex); }
|
||||
export function suggestColorsFor(hex) {
|
||||
const { h, s, v } = hexToHsv(hex);
|
||||
return suggestionsForHsv(h, s, v);
|
||||
}
|
||||
|
||||
// ── Popover build ─────────────────────────────────────────────────────
|
||||
function buildPopover() {
|
||||
const p = document.createElement('div');
|
||||
|
||||
@@ -82,10 +82,9 @@ export function resolveQuickEditAgentModel() {
|
||||
return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() };
|
||||
}
|
||||
|
||||
// Quick Edit agent (planner) model options. Kept small + explicit; the local
|
||||
// Qwen3.5-9B VL is the default. `value` encodes "<endpoint>::<model>".
|
||||
// Quick Edit agent (planner) model options. Kept small + explicit;
|
||||
// dynamically populated from /api/editor/agent-models.
|
||||
export const QUICK_EDIT_AGENT_OPTIONS = [
|
||||
{ value: 'http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B', label: 'Qwen3.5-9B VL · local' },
|
||||
{ value: 'default', label: 'Server default (hosted planner)' },
|
||||
];
|
||||
|
||||
@@ -167,12 +166,29 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model');
|
||||
if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());
|
||||
if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel());
|
||||
// The Quick Edit agent (planner) picker is static for now (local VL default,
|
||||
// server default option); restore/save its choice from localStorage.
|
||||
const populateAgentSelect = () => {
|
||||
// The Quick Edit agent (planner) picker: dynamically populates from
|
||||
// /api/editor/agent-models; restore/save choice from localStorage.
|
||||
const populateAgentSelect = async () => {
|
||||
if (!aiCommandAgentSelect) return;
|
||||
const prevVal = aiCommandAgentSelect.value;
|
||||
let agentOptions = [...QUICK_EDIT_AGENT_OPTIONS];
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/editor/agent-models`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const serverModels = data.models || [];
|
||||
for (const m of serverModels) {
|
||||
const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : (m.base_url || ''))}::${m.id}`;
|
||||
const label = m.label || m.id;
|
||||
if (!agentOptions.some(o => o.value === optVal)) {
|
||||
agentOptions.push({ value: optVal, label });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
aiCommandAgentSelect.innerHTML = '';
|
||||
for (const opt of QUICK_EDIT_AGENT_OPTIONS) {
|
||||
for (const opt of agentOptions) {
|
||||
const el = document.createElement('option');
|
||||
el.value = opt.value;
|
||||
el.textContent = opt.label;
|
||||
@@ -182,6 +198,8 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
const saved = localStorage.getItem('ge-ai-command-agent-model');
|
||||
if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) {
|
||||
aiCommandAgentSelect.value = saved;
|
||||
} else if (prevVal && [...aiCommandAgentSelect.options].some(o => o.value === prevVal)) {
|
||||
aiCommandAgentSelect.value = prevVal;
|
||||
}
|
||||
} catch {}
|
||||
if (!aiCommandAgentSelect._agentWired) {
|
||||
@@ -196,16 +214,17 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
// select nor the inpaint select nor any per-tool select).
|
||||
if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect &&
|
||||
!document.querySelector('select.ge-tool-model')) return;
|
||||
populateAgentSelect();
|
||||
void populateAgentSelect();
|
||||
|
||||
async function loadAIModels(opts = {}) {
|
||||
try {
|
||||
const selectBaseUrl = opts.selectBaseUrl || '';
|
||||
const prevGenValue = aiGenSelect?.value || '';
|
||||
const prevInpaintValue = aiInpaintSelect?.value || '';
|
||||
const res = await fetch(`${apiBase}/api/model-endpoints`);
|
||||
const res = await fetch(`${apiBase}/api/editor/image-models`);
|
||||
if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
|
||||
const endpoints = await res.json();
|
||||
const data = await res.json();
|
||||
const modelsList = data.models || [];
|
||||
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
|
||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
||||
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
|
||||
@@ -215,59 +234,52 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
let selectedGen = null;
|
||||
let selectedInpaint = null;
|
||||
const loaded = [];
|
||||
for (const ep of endpoints) {
|
||||
if (!ep.is_enabled) continue;
|
||||
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
|
||||
const models = hasListedModels ? sortModelIds(ep.models) : [''];
|
||||
const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image';
|
||||
// Image/inpaint endpoints can be called by URL even when their
|
||||
// /models cache is still empty, so don't strand a freshly served
|
||||
// Cookbook model as "(offline)" in the editor picker.
|
||||
const epUsable = !!ep.online || isImageEndpoint;
|
||||
for (const modelId of models) {
|
||||
const caps = modelCaps(modelId || ep.name, ep.name, ep.model_type);
|
||||
if (!caps.gen && !caps.inpaint) continue;
|
||||
// Encode "<base_url>::<model_id>" so the value carries both pieces.
|
||||
const value = `${ep.base_url}::${modelId}`;
|
||||
const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url);
|
||||
const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : '';
|
||||
const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`;
|
||||
loaded.push({ value, label, gen: caps.gen, inpaint: caps.inpaint, usable: epUsable, endpoint: ep.base_url, model: modelId });
|
||||
if (caps.gen && aiGenSelect) {
|
||||
for (const m of modelsList) {
|
||||
const modelId = m.id || '';
|
||||
const caps = modelCaps(modelId, m.provider, m.kind);
|
||||
const canGen = Boolean(caps.gen || m.kind === 'generate');
|
||||
const canInpaint = Boolean(caps.inpaint || m.kind === 'edit');
|
||||
if (!canGen && !canInpaint) continue;
|
||||
|
||||
// Use opaque endpoint_id if available; fallback to provider name or base_url for admin
|
||||
const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.base_url || '');
|
||||
const value = endpointKey ? `${endpointKey}::${modelId}` : modelId;
|
||||
const shortModel = modelId ? String(modelId).split('/').pop() : (m.label || m.provider);
|
||||
const epHint = m.provider && m.provider !== modelId && m.provider !== 'image' ? ` · ${m.provider}` : '';
|
||||
const label = `${shortModel}${epHint}`;
|
||||
const epUsable = true;
|
||||
loaded.push({ value, label, gen: canGen, inpaint: canInpaint, usable: epUsable, endpoint: endpointKey, model: modelId });
|
||||
if (canGen && aiGenSelect) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
opt.disabled = !epUsable;
|
||||
aiGenSelect.appendChild(opt);
|
||||
if (epUsable && !firstGen) firstGen = value;
|
||||
if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedGen) selectedGen = value;
|
||||
}
|
||||
if (canInpaint && aiInpaintSelect) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
opt.disabled = !epUsable;
|
||||
aiInpaintSelect.appendChild(opt);
|
||||
if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedInpaint) selectedInpaint = value;
|
||||
}
|
||||
if (canInpaint || canGen) {
|
||||
for (const ts of perToolSelects) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
opt.disabled = !epUsable;
|
||||
aiGenSelect.appendChild(opt);
|
||||
if (epUsable && !firstGen) firstGen = value;
|
||||
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedGen) selectedGen = value;
|
||||
ts.appendChild(opt);
|
||||
}
|
||||
if (caps.inpaint && aiInpaintSelect) {
|
||||
if (aiCommandSelect) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
opt.disabled = !epUsable;
|
||||
aiInpaintSelect.appendChild(opt);
|
||||
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value;
|
||||
}
|
||||
// Per-tool selectors get every img2img-capable entry. Both
|
||||
// caps.inpaint AND caps.gen models work for harmonize /
|
||||
// style / upscale (anything that can do img2img).
|
||||
if (caps.inpaint || caps.gen) {
|
||||
for (const ts of perToolSelects) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
opt.disabled = !epUsable;
|
||||
ts.appendChild(opt);
|
||||
}
|
||||
if (aiCommandSelect) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
opt.disabled = !epUsable;
|
||||
aiCommandSelect.appendChild(opt);
|
||||
}
|
||||
aiCommandSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,7 +386,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
wireServe(aiInpaintSelect);
|
||||
wireServe(aiCommandSelect);
|
||||
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
|
||||
populateAgentSelect();
|
||||
void populateAgentSelect();
|
||||
updateFooterEditModel();
|
||||
_aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
|
||||
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
||||
@@ -387,6 +399,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
return;
|
||||
}
|
||||
loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' });
|
||||
void populateAgentSelect();
|
||||
};
|
||||
window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
|
||||
// Re-fetch the model list when the user opens the inpaint dropdown,
|
||||
|
||||
@@ -181,7 +181,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
||||
// entries in a compact `<select>` overlay reachable from a "Panels"
|
||||
// button in the layers header, without touching the peek/expand model.
|
||||
let mobilePanels = null;
|
||||
if (!useDock && Array.isArray(extraPanels) && extraPanels.length) {
|
||||
if (Array.isArray(extraPanels) && extraPanels.length) {
|
||||
const mDock = buildDock({
|
||||
entries: extraPanels,
|
||||
persistKey: 'ge-dock-active',
|
||||
@@ -218,7 +218,9 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
||||
overlay.hidden = !overlay.hidden;
|
||||
});
|
||||
hdr.appendChild(panelsBtn);
|
||||
if (useDock) panelsBtn.style.display = 'none';
|
||||
}
|
||||
if (useDock) overlay.style.display = 'none';
|
||||
mobilePanels = overlay;
|
||||
}
|
||||
|
||||
@@ -226,7 +228,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
||||
// The panels are only re-parented, so every existing selector and
|
||||
// listener keeps working; switching tabs just toggles `hidden`.
|
||||
let dock = null;
|
||||
if (useDock) { rightPanel.classList.add('ge-right-panel--dock');
|
||||
if (Array.isArray(extraPanels) && extraPanels.length) {
|
||||
dock = buildDock({
|
||||
entries: [
|
||||
...(Array.isArray(extraPanels) ? extraPanels : []),
|
||||
@@ -239,11 +241,12 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
||||
onCloseGroup,
|
||||
});
|
||||
rightPanel.appendChild(dock.dock);
|
||||
// Layers stays pinned below the dock so the layer list is always
|
||||
// visible while editing (Photoshop-style), regardless of the active
|
||||
// dock tab. Only the tool options + studio panels live in tabs.
|
||||
rightPanel.appendChild(layerPanel);
|
||||
if (!useDock) dock.dock.style.display = 'none';
|
||||
}
|
||||
if (useDock) {
|
||||
rightPanel.classList.add('ge-right-panel--dock');
|
||||
}
|
||||
rightPanel.appendChild(layerPanel);
|
||||
|
||||
// Horizontal drag handle on the LEFT edge of the right panel — drag
|
||||
// left to widen, right to narrow. Persists chosen width in
|
||||
@@ -286,5 +289,5 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
||||
} catch {}
|
||||
});
|
||||
|
||||
return { rightPanel, controls, layerPanel, panelResize, dock };
|
||||
return { rightPanel, controls, layerPanel, panelResize, dock, mobilePanels };
|
||||
}
|
||||
|
||||
@@ -242,7 +242,13 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
: soft >= 70
|
||||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.3"/><circle cx="12" cy="12" r="6.5"/></svg>'
|
||||
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.65"/><circle cx="12" cy="12" r="6.5"/></svg>';
|
||||
presetItem.insertAdjacentHTML('beforeend', `<span class="ge-tool-icon">${presetIcon}</span><span class="ge-tool-flyout-label">${preset.name}</span>`);
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'ge-tool-icon';
|
||||
icon.innerHTML = presetIcon;
|
||||
const label = document.createElement('span');
|
||||
label.className = 'ge-tool-flyout-label';
|
||||
label.textContent = preset.name;
|
||||
presetItem.append(icon, label);
|
||||
presetItem.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
setInner(face, 'brush');
|
||||
|
||||
@@ -535,6 +535,7 @@ export function buildTopbar() {
|
||||
<span class="ge-stacked-glyph">
|
||||
<svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg>
|
||||
<svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<svg hidden class="ge-save-state-icon ge-save-state-error" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
</span>
|
||||
<span class="ge-stacked-label ge-save-action-label">SAVE</span>
|
||||
<span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
|
||||
|
||||
@@ -8,8 +8,29 @@
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
let _cachedCanvas = null;
|
||||
let _cachedRect = null;
|
||||
let _cachedRectTime = 0;
|
||||
|
||||
export function invalidateCanvasCoordsCache() {
|
||||
_cachedCanvas = null;
|
||||
_cachedRect = null;
|
||||
_cachedRectTime = 0;
|
||||
}
|
||||
|
||||
export function canvasCoords(e, canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const now = performance.now();
|
||||
let rect;
|
||||
if (_cachedCanvas === canvas && _cachedRect && (state.drawing || (now - _cachedRectTime < 64))) {
|
||||
rect = _cachedRect;
|
||||
} else {
|
||||
rect = canvas.getBoundingClientRect();
|
||||
_cachedCanvas = canvas;
|
||||
_cachedRect = rect;
|
||||
_cachedRectTime = now;
|
||||
}
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX;
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
* }} ctx
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { invalidateCanvasCoordsCache } from './canvas-coords.js';
|
||||
import {
|
||||
applyCanvasPan,
|
||||
isDirectPanIntent,
|
||||
@@ -58,6 +59,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
canvasWindowBindings?.abort();
|
||||
canvasWindowBindings = new AbortController();
|
||||
const { signal } = canvasWindowBindings;
|
||||
|
||||
// Invalidate coordinate cache on layout, scroll, resize or orientation shifts
|
||||
window.addEventListener('resize', invalidateCanvasCoordsCache, { signal, passive: true });
|
||||
window.addEventListener('scroll', invalidateCanvasCoordsCache, { signal, passive: true });
|
||||
window.addEventListener('orientationchange', invalidateCanvasCoordsCache, { signal, passive: true });
|
||||
canvasArea.addEventListener('scroll', invalidateCanvasCoordsCache, { passive: true });
|
||||
let suppressMouseUntil = 0;
|
||||
// Tool-aware right-click menu on the image and the surrounding area.
|
||||
wireCanvasContextMenu(canvasArea, { signal });
|
||||
@@ -90,7 +97,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (Date.now() < suppressMouseUntil) return;
|
||||
// Only shield the raw-coordinate selection/crop rects from chrome.
|
||||
if (isUiChrome(e) && directGestureActive()) return;
|
||||
if (directGestureActive() && isUiChrome(e)) return;
|
||||
continueDraw(e);
|
||||
}, { signal });
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
@@ -240,11 +247,14 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
const newZoom = Math.max(0.1, Math.min(5, multiStartZoom * ratio));
|
||||
if (Math.abs(newZoom - state.zoom) > 0.001) {
|
||||
state.zoom = newZoom;
|
||||
// A pinch is an explicit zoom; layout refits must not undo it.
|
||||
state.zoomViewMode = 'scale';
|
||||
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
|
||||
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
|
||||
const label = state.container.querySelector('.ge-zoom-label');
|
||||
if (label) label.textContent = Math.round(state.zoom * 100) + '%';
|
||||
syncZoomControls?.();
|
||||
invalidateCanvasCoordsCache();
|
||||
onViewportChange?.();
|
||||
}
|
||||
const dx = info.cx - multiStartCenter.x;
|
||||
|
||||
@@ -77,7 +77,8 @@ export function wireClipboardAndDrop({
|
||||
// inside the gallery modal's role="dialog" modal-content, so that would
|
||||
// swallow every paste (including the internal Clipboard + Ctrl+V).
|
||||
const target = e.target;
|
||||
const insideEditor = target && container && container.contains(target);
|
||||
// A paste dispatched at window has a non-Node target; contains() would throw.
|
||||
const insideEditor = target instanceof Node && !!container && container.contains(target);
|
||||
if (insideEditor && (target.isContentEditable || target.closest?.('input, textarea, select'))) return;
|
||||
|
||||
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
import { drawHistogram } from './histogram.js';
|
||||
import { parseCubeLut, generateCubeLut } from './cube-lut.js';
|
||||
|
||||
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) {
|
||||
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null, schedulePersist = null }) {
|
||||
function suppressLayerGhostTap() {
|
||||
window.__geSuppressLayerTapUntil = Date.now() + 650;
|
||||
}
|
||||
@@ -511,6 +511,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
closeAdjPopup();
|
||||
schedulePersist?.();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -535,8 +536,14 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
|
||||
return p.channels[channel];
|
||||
};
|
||||
// Params come from project files and drafts, which only validate the
|
||||
// adjustment type; coerce anything interpolated into markup below.
|
||||
const finite = value => (Number.isFinite(Number(value)) ? Number(value) : 0);
|
||||
const hexColor = (value, fallback) => (/^#[0-9a-f]{6}$/i.test(value) ? value : fallback);
|
||||
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
|
||||
const sliderRow = (key, label, min, max, value, suffix, defaultValue) => `
|
||||
const sliderRow = (key, label, min, max, rawValue, suffix, defaultValue) => {
|
||||
const value = finite(rawValue);
|
||||
return `
|
||||
<div class="ge-adj-row" data-adj-key="${key}"${defaultValue != null ? ` data-default="${defaultValue}"` : ''}>
|
||||
<label>${label}</label>
|
||||
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
|
||||
@@ -544,6 +551,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
if (type === 'brightness-contrast') {
|
||||
const bSlider = Math.round((p.brightness - 1) * 100);
|
||||
const cSlider = Math.round((p.contrast - 1) * 100);
|
||||
@@ -692,7 +700,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
});
|
||||
} else if (type === 'color-balance') {
|
||||
// Color-tinted slider ends so the user sees what direction does what.
|
||||
const cbRow = (key, leftCol, rightCol, label, value) => `
|
||||
const cbRow = (key, leftCol, rightCol, label, rawValue, value = finite(rawValue)) => `
|
||||
<div class="ge-adj-row ge-adj-cb-row" data-adj-key="${key}">
|
||||
<span class="ge-adj-cb-dot" style="background:${leftCol}"></span>
|
||||
<input type="range" min="-100" max="100" value="${value}" data-key="${key}" />
|
||||
@@ -749,6 +757,9 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
} else if (type === 'gradient-map') {
|
||||
const gradientDefaults = defaultAdjParams('gradient-map');
|
||||
p.shadows = hexColor(p.shadows, gradientDefaults.shadows);
|
||||
p.highlights = hexColor(p.highlights, gradientDefaults.highlights);
|
||||
body.innerHTML = `
|
||||
<div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div>
|
||||
<div class="ge-gradient-color-row">
|
||||
@@ -789,7 +800,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
body.innerHTML = `
|
||||
<div class="ge-adj-row">
|
||||
<label>Color</label>
|
||||
<input type="color" data-photo-key="color" value="${p.color || '#ec8a00'}">
|
||||
<input type="color" data-photo-key="color" value="${hexColor(p.color, '#ec8a00')}">
|
||||
</div>
|
||||
${sliderRow('density', 'Density', 0, 100, Math.round(p.density ?? 25), '%')}
|
||||
<label class="ge-adj-toggle-row"><span>Preserve luminosity</span><input type="checkbox" data-photo-preserve${p.preserve_luminosity ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
|
||||
@@ -838,7 +849,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
const presetLabels = { none: 'None', teal_orange: 'Teal & Orange', cold: 'Cold', warm: 'Warm', high_contrast: 'High Contrast', sepia: 'Sepia' };
|
||||
const strength = p.strength == null ? 100 : p.strength;
|
||||
const hasCube = !!(p.cube && p.cube.size && p.cube.data);
|
||||
const lutName = hasCube ? `Loaded LUT · ${p.cube.size}³` : 'No .cube loaded';
|
||||
const lutName = hasCube ? `Loaded LUT · ${finite(p.cube.size)}³` : 'No .cube loaded';
|
||||
body.innerHTML = `
|
||||
<label class="ge-adj-channel-field">Lookup preset
|
||||
<select class="ge-adj-lut-select">
|
||||
@@ -972,6 +983,8 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
p.strength = raw; display = raw + '%';
|
||||
} else if (type === 'white-balance') {
|
||||
p[key] = raw;
|
||||
} else if (type === 'vibrance') {
|
||||
p[key] = raw;
|
||||
} else if (type === 'black-white') {
|
||||
p[key] = raw;
|
||||
} else if (type === 'shadows-highlights') {
|
||||
|
||||
@@ -1948,7 +1948,8 @@ export function createLayerPanelRenderer(deps) {
|
||||
});
|
||||
const sName = document.createElement('span');
|
||||
sName.className = 'ge-layer-name ge-adj-sub-name';
|
||||
sName.innerHTML = `<span class="ge-adj-sub-icon">◌</span><span>${effectLabel(effect.type)}</span>`;
|
||||
sName.innerHTML = '<span class="ge-adj-sub-icon">◌</span><span></span>';
|
||||
sName.lastElementChild.textContent = effectLabel(effect.type);
|
||||
sName.title = 'Edit effect (drag onto another layer to copy)';
|
||||
sName.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -28,10 +28,10 @@ const GRADIENTS = [
|
||||
['Spectrum', ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff']],
|
||||
];
|
||||
|
||||
function el(tag, className, html) {
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (html != null) node.innerHTML = html;
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
|
||||
@@ -274,7 +274,11 @@ export function createStudioPanels({
|
||||
return;
|
||||
}
|
||||
shapeLayers.slice().reverse().forEach(layer => {
|
||||
const button = el('button', 'ge-channel-row', `<span class="ge-channel-name">${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}</span>`);
|
||||
const button = el('button', 'ge-channel-row');
|
||||
const name = document.createElement('span');
|
||||
name.className = 'ge-channel-name';
|
||||
name.textContent = `${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}`;
|
||||
button.appendChild(name);
|
||||
button.type = 'button';
|
||||
button.addEventListener('click', () => { onSelectLayer(layer.id); renderPaths(); });
|
||||
pathBody.appendChild(button);
|
||||
|
||||
@@ -400,10 +400,15 @@ export const state = {
|
||||
// Loading-overlay element (whirlpool + label).
|
||||
editorLoadingEl: null,
|
||||
|
||||
// ── Draft persistence ──
|
||||
// ── Draft persistence & revision tracking ──
|
||||
draftId: null,
|
||||
draftName: '',
|
||||
documentId: null,
|
||||
documentRevision: 0,
|
||||
persistingRevision: 0,
|
||||
savedRevision: 0,
|
||||
persistRetryTimer: null,
|
||||
persistRetryCount: 0,
|
||||
persistTimer: null,
|
||||
// Current PUT/POST promise so concurrent saves can chain.
|
||||
persistInFlight: null,
|
||||
|
||||
@@ -12,15 +12,21 @@ function toolSettings(tool) {
|
||||
return { opacity: state.brushOpacity, flow: state.brushFlow, softness: state.brushSoftness };
|
||||
}
|
||||
|
||||
let _lastColor = null;
|
||||
let _lastTransparentColor = null;
|
||||
function transparentColor(color) {
|
||||
if (color === _lastColor && _lastTransparentColor) return _lastTransparentColor;
|
||||
_lastColor = color;
|
||||
if (color.startsWith('#')) {
|
||||
const raw = color.slice(1);
|
||||
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
|
||||
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
|
||||
return `rgba(${rgb.join(',')},0)`;
|
||||
_lastTransparentColor = `rgba(${rgb.join(',')},0)`;
|
||||
return _lastTransparentColor;
|
||||
}
|
||||
const match = color.match(/rgba?\(([^)]+)\)/);
|
||||
return match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
|
||||
_lastTransparentColor = match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
|
||||
return _lastTransparentColor;
|
||||
}
|
||||
|
||||
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
* the Window > Workspace submenu (wire-extra-menus.js).
|
||||
*/
|
||||
export function wireSubmenuToggles(menu) {
|
||||
menu.addEventListener('mouseover', (e) => {
|
||||
if (e.target.closest('.ge-menu-parent, .ge-submenu')) {
|
||||
menu.classList.add('ge-flyout-open');
|
||||
}
|
||||
});
|
||||
menu.addEventListener('mouseleave', () => {
|
||||
if (!menu.querySelector('.ge-menu-parent.open')) {
|
||||
menu.classList.remove('ge-flyout-open');
|
||||
}
|
||||
});
|
||||
menu.addEventListener('click', (e) => {
|
||||
const parent = e.target.closest('.ge-menu-parent');
|
||||
if (!parent) return;
|
||||
|
||||
@@ -241,11 +241,13 @@ export function createTransformSession({
|
||||
}
|
||||
if (state.transformPopup) {
|
||||
try {
|
||||
state.transformPopup.closest('.ge-controls')?.classList.remove('ge-transform-active');
|
||||
state.transformPopup.innerHTML = '';
|
||||
state.transformPopup.style.display = 'none';
|
||||
} catch {}
|
||||
state.transformPopup = null;
|
||||
}
|
||||
document.querySelectorAll('.ge-controls.ge-transform-active').forEach(c => c.classList.remove('ge-transform-active'));
|
||||
}
|
||||
|
||||
let mobileBoundsHandler = null;
|
||||
@@ -285,6 +287,11 @@ export function createTransformSession({
|
||||
// bar) instead of a floating popup over the canvas.
|
||||
const pop = document.getElementById('ge-transform-section');
|
||||
if (!pop) return;
|
||||
const parentControls = pop.closest('.ge-controls');
|
||||
if (parentControls) {
|
||||
parentControls.classList.add('ge-transform-active');
|
||||
parentControls.classList.remove('dismissed');
|
||||
}
|
||||
pop.innerHTML = `<div class="ge-transform-popup">${transformPopupHTML()}</div>`;
|
||||
const title = pop.querySelector('.ge-adj-title');
|
||||
if (title && state.transformTarget === 'selection') title.textContent = 'Transform Selection';
|
||||
|
||||
@@ -127,8 +127,8 @@ export function wireTopbar(deps) {
|
||||
(Math.abs(state.zoom - 1) < 0.001 && state.zoomViewMode !== 'fit')) fitZoom();
|
||||
else { state.zoom = 1; state.zoomViewMode = 'scale'; applyZoom(); }
|
||||
});
|
||||
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); applyZoom(); });
|
||||
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); applyZoom(); });
|
||||
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); state.zoomViewMode = 'scale'; applyZoom(); });
|
||||
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); state.zoomViewMode = 'scale'; applyZoom(); });
|
||||
|
||||
// Export / Download / Project Save / Project Load.
|
||||
document.getElementById('ge-export-gallery')?.addEventListener('click', exportToGallery);
|
||||
|
||||
+366
-134
@@ -7,7 +7,7 @@ import dragSortModule from './dragSort.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
import modalManager from './modalManager.js';
|
||||
import { canvasCoords as _canvasCoords } from './editor/canvas-coords.js';
|
||||
import { canvasCoords as _canvasCoords, invalidateCanvasCoordsCache } from './editor/canvas-coords.js';
|
||||
import { pickTopmostLayerAt as _pickTopmostLayerAt } from './editor/layer-hit-test.js';
|
||||
import { drawCheckerboard as _drawCheckerboard } from './editor/checkerboard.js';
|
||||
import { dilateMask as _dilateMask, applyInpaintFeather as _applyInpaintFeather } from './editor/mask-utils.js';
|
||||
@@ -854,11 +854,13 @@ function _setAiProgress(frac, label) {
|
||||
if (lbl) lbl.textContent = label || '';
|
||||
}
|
||||
function _startAiProgress(requestId) {
|
||||
const endpointId = arguments[1] || '';
|
||||
_stopAiProgress();
|
||||
if (!requestId) return;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}`, { credentials: 'same-origin' });
|
||||
const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : '';
|
||||
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const p = await r.json();
|
||||
const st = p.stage || '';
|
||||
@@ -1113,6 +1115,68 @@ function _matchAiCommandSuggestions(query) {
|
||||
.map(hit => hit.item);
|
||||
}
|
||||
|
||||
// On phones the layers panel is a bottom sheet that can cover the editor
|
||||
// footer (zoom / setup). Float the footer just above the sheet's top edge so
|
||||
// its controls stay tappable; restore it to normal flow when the sheet is
|
||||
// collapsed or on desktop.
|
||||
let _lastFooterDelta = 0;
|
||||
const _placeMobileFooter = () => {
|
||||
const footer = document.querySelector('.ge-editor-footer');
|
||||
if (!footer) return;
|
||||
const area = document.querySelector('.ge-canvas-area');
|
||||
const _clear = () => {
|
||||
footer.classList.remove('ge-footer-floating');
|
||||
footer.style.position = ''; footer.style.left = ''; footer.style.right = '';
|
||||
footer.style.bottom = ''; footer.style.zIndex = '';
|
||||
document.body.classList.remove('ge-footer-docked');
|
||||
state._footerDocked = false;
|
||||
if (area && (area.style.marginBottom || area.style.height || area.style.flex || area.style.minHeight)) {
|
||||
area.style.marginBottom = ''; area.style.height = ''; area.style.flex = ''; area.style.minHeight = '';
|
||||
_lastFooterDelta = 0;
|
||||
// The sheet may have squeezed fit view to a few pixels. Restore fit
|
||||
// after releasing that constraint, while retaining an explicit zoom.
|
||||
if (state.zoomViewMode === 'fit') {
|
||||
try { _fitZoom(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
if (window.innerWidth > 700) { _clear(); return; }
|
||||
const sheet = document.querySelector('.ge-right-panel');
|
||||
if (!sheet || sheet.classList.contains('minimized') || sheet.classList.contains('expanded')) { _clear(); return; }
|
||||
const r = sheet.getBoundingClientRect();
|
||||
const vh = window.innerHeight;
|
||||
if (r.height <= 0 || r.top >= vh || r.top <= 140) { _clear(); return; }
|
||||
footer.classList.add('ge-footer-floating');
|
||||
footer.style.position = 'fixed';
|
||||
footer.style.left = '0';
|
||||
footer.style.right = '0';
|
||||
footer.style.zIndex = '60';
|
||||
// Dock the footer flush to the sheet top: no seam / dead space between them.
|
||||
footer.style.bottom = Math.max(0, Math.round(vh - r.top)) + 'px';
|
||||
document.body.classList.add('ge-footer-docked');
|
||||
// Dock the canvas image flush too: with the dock padding off, the image
|
||||
// fills the region so its bottom meets the footer top (no dead space).
|
||||
state._footerDocked = true;
|
||||
// Size the canvas region so it ends exactly where the footer begins:
|
||||
// canvas bottom == footer top == sheet top. (Its mobile min-height:40vh
|
||||
// would otherwise keep it running under the footer and the sheet.) The
|
||||
// ge-footer-docked class also hides the sheet grab handle.
|
||||
if (area) {
|
||||
const footerH = footer.getBoundingClientRect().height || 0;
|
||||
const desiredBottom = Math.round(r.top - footerH);
|
||||
const want = Math.max(0, Math.round(desiredBottom - area.getBoundingClientRect().top));
|
||||
if (want !== _lastFooterDelta) {
|
||||
area.style.flex = want ? '0 0 auto' : '';
|
||||
area.style.minHeight = want ? '0px' : '';
|
||||
area.style.height = want ? want + 'px' : '';
|
||||
_lastFooterDelta = want;
|
||||
if (state.zoomViewMode === 'fit') {
|
||||
try { _fitZoom(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function _wireAiCommandBox() {
|
||||
const wrap = document.getElementById('ge-ai-command');
|
||||
const toggle = document.getElementById('ge-ai-command-toggle');
|
||||
@@ -1368,60 +1432,6 @@ function _wireAiCommandBox() {
|
||||
input.addEventListener('click', _trackLift);
|
||||
wrap.addEventListener('focusin', _trackLift);
|
||||
requestAnimationFrame(() => requestAnimationFrame(_liftQuickEdit));
|
||||
// On phones the layers panel is a bottom sheet that can cover the editor
|
||||
// footer (zoom / setup). Float the footer just above the sheet's top edge so
|
||||
// its controls stay tappable; restore it to normal flow when the sheet is
|
||||
// collapsed or on desktop.
|
||||
let _lastFooterDelta = 0;
|
||||
const _placeMobileFooter = () => {
|
||||
const footer = document.querySelector('.ge-editor-footer');
|
||||
if (!footer) return;
|
||||
const area = document.querySelector('.ge-canvas-area');
|
||||
const _clear = () => {
|
||||
footer.classList.remove('ge-footer-floating');
|
||||
footer.style.position = ''; footer.style.left = ''; footer.style.right = '';
|
||||
footer.style.bottom = ''; footer.style.zIndex = '';
|
||||
document.body.classList.remove('ge-footer-docked');
|
||||
state._footerDocked = false;
|
||||
if (area && (area.style.marginBottom || area.style.height || area.style.flex || area.style.minHeight)) {
|
||||
area.style.marginBottom = ''; area.style.height = ''; area.style.flex = ''; area.style.minHeight = '';
|
||||
_lastFooterDelta = 0;
|
||||
}
|
||||
};
|
||||
if (window.innerWidth > 700) { _clear(); return; }
|
||||
const sheet = document.querySelector('.ge-right-panel');
|
||||
if (!sheet || sheet.classList.contains('minimized')) { _clear(); return; }
|
||||
const r = sheet.getBoundingClientRect();
|
||||
const vh = window.innerHeight;
|
||||
if (r.height <= 0 || r.top >= vh) { _clear(); return; }
|
||||
footer.classList.add('ge-footer-floating');
|
||||
footer.style.position = 'fixed';
|
||||
footer.style.left = '0';
|
||||
footer.style.right = '0';
|
||||
footer.style.zIndex = '60';
|
||||
// Dock the footer flush to the sheet top: no seam / dead space between them.
|
||||
footer.style.bottom = Math.max(0, Math.round(vh - r.top)) + 'px';
|
||||
document.body.classList.add('ge-footer-docked');
|
||||
// Dock the canvas image flush too: with the dock padding off, the image
|
||||
// fills the region so its bottom meets the footer top (no dead space).
|
||||
state._footerDocked = true;
|
||||
// Size the canvas region so it ends exactly where the footer begins:
|
||||
// canvas bottom == footer top == sheet top. (Its mobile min-height:40vh
|
||||
// would otherwise keep it running under the footer and the sheet.) The
|
||||
// ge-footer-docked class also hides the sheet grab handle.
|
||||
if (area) {
|
||||
const footerH = footer.getBoundingClientRect().height || 0;
|
||||
const desiredBottom = Math.round(r.top - footerH);
|
||||
const want = Math.max(0, Math.round(desiredBottom - area.getBoundingClientRect().top));
|
||||
if (want !== _lastFooterDelta) {
|
||||
area.style.flex = want ? '0 0 auto' : '';
|
||||
area.style.minHeight = want ? '0px' : '';
|
||||
area.style.height = want ? want + 'px' : '';
|
||||
_lastFooterDelta = want;
|
||||
try { _fitZoom(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
// The layers sheet expands/collapses (and its peek height grows as layers
|
||||
// render) independently of the keyboard; watch it so the bar and the footer
|
||||
// keep clear of the sheet's top edge as it moves.
|
||||
@@ -2592,8 +2602,8 @@ function _wireAiCommandBox() {
|
||||
// done vs retry. The caller enforces a single retry (max 1).
|
||||
const _vlJudgeAfter = async (prompt, before, after) => {
|
||||
if (!before || !after) return { done: true };
|
||||
let ep = 'http://100.69.120.65:8097/v1';
|
||||
let model = 'Qwen3.5-9B-MLX-8bit';
|
||||
let ep = '';
|
||||
let model = 'Qwen3.5-9B';
|
||||
try {
|
||||
const info = _getSelectedQuickEditAgentModel();
|
||||
if (info && info.endpoint) ep = info.endpoint;
|
||||
@@ -2637,6 +2647,7 @@ function _wireAiCommandBox() {
|
||||
{ type: 'image_url', image_url: { url: after } },
|
||||
] }],
|
||||
};
|
||||
if (!ep) return { done: true };
|
||||
try {
|
||||
const resp = await fetch(ep.replace(/\/$/, '') + '/chat/completions', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
@@ -3295,6 +3306,7 @@ const _adjPopupSystem = createAdjPopupSystem({
|
||||
saveState: _saveState,
|
||||
renderLayerPanel: () => _renderLayerPanel(),
|
||||
getAdjustmentSource: layer => _adjustmentSourceCanvas(layer),
|
||||
schedulePersist: () => _schedulePersist(),
|
||||
});
|
||||
const _closeFxPopup = _adjPopupSystem.closeFxPopup;
|
||||
const _ensureAdjustments = _adjPopupSystem.ensureAdjustments;
|
||||
@@ -3948,13 +3960,13 @@ function _finishComposite(render, documentCanvas) {
|
||||
if (state.transformActive) _drawTransformHandles();
|
||||
else if (state.transformOverlay) state.transformOverlay.style.display = 'none';
|
||||
if (state.marqueeActive && state.marqueeRect) _drawMarqueeOverlay();
|
||||
_penTool.draw();
|
||||
if (state.tool === 'pen' || state.penPoints?.length) _penTool.draw();
|
||||
if (state.cropRect && !state.cropping) _drawCropOverlay();
|
||||
_syncCropFrame();
|
||||
if (state.cropRect) _syncCropFrame();
|
||||
if (state.activeSnapGuides && state.activeSnapGuides.length) _drawSnapGuides();
|
||||
_precisionGuides?.drawDocumentOverlay(state.mainCtx);
|
||||
if (!state.drawing) _precisionGuides?.drawDocumentOverlay(state.mainCtx);
|
||||
_refreshSelectionOverlay();
|
||||
_syncToolClearIndicators();
|
||||
if (!state.drawing) _syncToolClearIndicators();
|
||||
}
|
||||
|
||||
function composite() {
|
||||
@@ -4333,7 +4345,14 @@ function _saveState(label, options = {}) {
|
||||
console.error('[gallery] saveState snapshot failed (continuing without this undo step):', e);
|
||||
}
|
||||
try { _invalidateWandCache(); } catch (e) { console.error('[gallery] invalidateWandCache:', e); }
|
||||
try { _schedulePersist(); } catch (e) { console.error('[gallery] schedulePersist:', e); }
|
||||
try {
|
||||
state.documentRevision = (state.documentRevision || 0) + 1;
|
||||
state.persistDirty = true;
|
||||
_setDraftStatus('Unsaved', 'dirty');
|
||||
if (!_draftSavingDisabled()) {
|
||||
_schedulePersist();
|
||||
}
|
||||
} catch (e) { console.error('[gallery] schedulePersist:', e); }
|
||||
try { _refreshHistoryPanelIfOpen(); } catch (e) { console.error('[gallery] refreshHistoryPanel:', e); }
|
||||
}
|
||||
|
||||
@@ -4367,20 +4386,47 @@ function _writeActiveEditorSession() {
|
||||
function _setDraftStatus(label, stateName = '') {
|
||||
const el = document.getElementById('ge-draft-status');
|
||||
if (!el) return;
|
||||
const normalizedState = stateName === 'saved' ? 'saved' : 'dirty';
|
||||
const normalizedLabel = normalizedState === 'saved' ? 'Saved' : 'Unsaved';
|
||||
const validStates = ['saved', 'dirty', 'pending', 'saving', 'error', 'paused'];
|
||||
let normalizedState = stateName;
|
||||
if (!validStates.includes(normalizedState)) {
|
||||
if (stateName === 'saved' || label === 'Saved') normalizedState = 'saved';
|
||||
else if (stateName === 'error' || /fail|error/i.test(label || '')) normalizedState = 'error';
|
||||
else if (stateName === 'paused' || /paused/i.test(label || '')) normalizedState = 'paused';
|
||||
else if (stateName === 'saving' || label === 'Saving…' || label === 'Saving...') normalizedState = 'saving';
|
||||
else if (stateName === 'pending' || /saving soon/i.test(label || '')) normalizedState = 'pending';
|
||||
else normalizedState = 'dirty';
|
||||
}
|
||||
const defaultLabels = {
|
||||
saved: 'Saved',
|
||||
dirty: 'Unsaved',
|
||||
pending: 'Saving soon…',
|
||||
saving: 'Saving…',
|
||||
error: 'Save failed',
|
||||
paused: 'Save paused',
|
||||
};
|
||||
const normalizedLabel = label || defaultLabels[normalizedState] || 'Unsaved';
|
||||
el.textContent = normalizedLabel;
|
||||
el.dataset.state = stateName;
|
||||
el.dataset.state = normalizedState;
|
||||
const iconHost = el.closest('#ge-save-menu-btn');
|
||||
const iconKind = (normalizedState === 'error' || normalizedState === 'paused')
|
||||
? 'error'
|
||||
: (normalizedState === 'saved' ? 'saved' : 'dirty');
|
||||
// The icons are <svg>; SVGElement has no `hidden` property, so set the attribute.
|
||||
iconHost?.querySelectorAll('.ge-save-state-icon').forEach(icon => {
|
||||
icon.hidden = !icon.classList.contains(`ge-save-state-${normalizedState}`);
|
||||
icon.toggleAttribute('hidden', !icon.classList.contains(`ge-save-state-${iconKind}`));
|
||||
});
|
||||
const saveButton = document.getElementById('ge-save-menu-btn');
|
||||
if (saveButton) {
|
||||
saveButton.title = normalizedState === 'error' ? 'Draft autosave needs attention' : `Draft status: ${normalizedLabel}`;
|
||||
saveButton.setAttribute('aria-label', normalizedLabel);
|
||||
if (normalizedState === 'error' || normalizedState === 'paused') {
|
||||
saveButton.title = state.persistErrorMessage || `Draft autosave failed: ${normalizedLabel}`;
|
||||
saveButton.setAttribute('aria-label', `Draft autosave failed: ${normalizedLabel}`);
|
||||
} else {
|
||||
saveButton.title = `Draft status: ${normalizedLabel}`;
|
||||
saveButton.setAttribute('aria-label', `Draft status: ${normalizedLabel}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
try { window.__geSetDraftStatus = _setDraftStatus; } catch (_) {}
|
||||
|
||||
function _clearActiveEditorSession() {
|
||||
try { sessionStorage.removeItem(ACTIVE_EDITOR_SESSION_KEY); } catch (_) {}
|
||||
@@ -4403,11 +4449,20 @@ function _draftSavingDisabled() {
|
||||
function _schedulePersist() {
|
||||
if (_draftSavingDisabled()) return;
|
||||
if (!state.editorOpen || !state.layers.length) return;
|
||||
if (state.persistTimer) clearTimeout(state.persistTimer);
|
||||
_setDraftStatus('Saving soon…', 'pending');
|
||||
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
|
||||
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
|
||||
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
|
||||
// The first save establishes the recovery anchor. Start it immediately
|
||||
// when no server draft exists; subsequent edits can stay debounced.
|
||||
const delay = state.draftId ? PERSIST_DEBOUNCE_MS : 0;
|
||||
if (delay > 0) {
|
||||
state.pendingStatusTimer = setTimeout(() => {
|
||||
state.pendingStatusTimer = null;
|
||||
if (state.editorOpen) _setDraftStatus('Saving soon…', 'pending');
|
||||
}, Math.min(200, Math.floor(delay / 2)));
|
||||
} else {
|
||||
_setDraftStatus('Saving soon…', 'pending');
|
||||
}
|
||||
state.persistTimer = setTimeout(() => { state.persistTimer = null; _persistDraft(); }, delay);
|
||||
}
|
||||
|
||||
@@ -4591,6 +4646,8 @@ async function _persistDraft() {
|
||||
// Coalesce concurrent saves — if one's already in-flight, mark dirty
|
||||
// and let the running call kick off another when it returns.
|
||||
if (state.persistInFlight) { state.persistDirty = true; _setDraftStatus('Saving…', 'saving'); return; }
|
||||
const currentRevision = state.documentRevision || 0;
|
||||
state.persistingRevision = currentRevision;
|
||||
const sessionToken = state.editorSessionToken;
|
||||
const draftIdAtStart = state.draftId || null;
|
||||
const draftNameAtStart = state.draftName || 'Untitled';
|
||||
@@ -4618,13 +4675,10 @@ async function _persistDraft() {
|
||||
};
|
||||
const serializedBody = JSON.stringify(body);
|
||||
if (serializedBody.length > _EDITOR_PROJECT_MAX_BYTES) {
|
||||
const message = 'Draft autosave paused: this layered document exceeds the 256 MB safety limit. Export or flatten a copy before closing.';
|
||||
if (isCurrentSession()) {
|
||||
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, 9000);
|
||||
state.persistErrorMessage = message;
|
||||
_setDraftStatus('Save paused', 'error');
|
||||
}
|
||||
return;
|
||||
_setDraftStatus('Save paused', 'error');
|
||||
const err = new Error('Draft autosave paused: this layered document exceeds the 256 MB safety limit. Export or flatten a copy before closing.');
|
||||
err.status = 413;
|
||||
throw err;
|
||||
}
|
||||
const responseError = async (res, method) => {
|
||||
let detail = '';
|
||||
@@ -4667,29 +4721,51 @@ async function _persistDraft() {
|
||||
};
|
||||
const savePromise = doSave();
|
||||
state.persistInFlight = savePromise
|
||||
.catch((e) => {
|
||||
console.warn('[ge] draft save failed', e);
|
||||
if (!isCurrentSession()) return;
|
||||
const message = e?.status === 413
|
||||
? 'Draft autosave paused: the document exceeds the configured storage limit.'
|
||||
: `Draft autosave failed: ${e?.message || 'server error'}`;
|
||||
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, 7000);
|
||||
state.persistErrorMessage = message;
|
||||
_setDraftStatus('Save failed', 'error');
|
||||
})
|
||||
.then((out) => {
|
||||
if (isCurrentSession()) state.persistInFlight = null;
|
||||
if (!isCurrentSession()) return;
|
||||
state.savedRevision = Math.max(state.savedRevision || 0, currentRevision);
|
||||
state.persistRetryCount = 0;
|
||||
if (out && out.id) {
|
||||
state.draftId = out.id;
|
||||
_writeActiveEditorSession();
|
||||
}
|
||||
state.persistErrorMessage = null;
|
||||
if (!state.persistErrorMessage) _setDraftStatus('Saved', 'saved');
|
||||
if ((state.documentRevision || 0) > state.savedRevision || state.persistDirty) {
|
||||
state.persistDirty = true;
|
||||
_setDraftStatus('Unsaved', 'dirty');
|
||||
} else {
|
||||
_setDraftStatus('Saved', 'saved');
|
||||
}
|
||||
if (state.persistDirty) {
|
||||
state.persistDirty = false;
|
||||
_schedulePersist();
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('[ge] draft save failed', e);
|
||||
if (!isCurrentSession()) return;
|
||||
state.persistDirty = true;
|
||||
const isPaused = e?.status === 413;
|
||||
const message = isPaused
|
||||
? (e?.message || 'Draft autosave paused: the document exceeds the configured storage limit.')
|
||||
: `Draft autosave failed: ${e?.message || 'server error'}`;
|
||||
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, isPaused ? 9000 : 7000);
|
||||
state.persistErrorMessage = message;
|
||||
_setDraftStatus(isPaused ? 'Save paused' : 'Save failed', isPaused ? 'paused' : 'error');
|
||||
if (!isPaused && state.editorOpen) {
|
||||
state.persistRetryCount = Math.min((state.persistRetryCount || 0) + 1, 5);
|
||||
const retryDelay = Math.min(1000 * Math.pow(2, state.persistRetryCount), 15000);
|
||||
if (state.persistRetryTimer) clearTimeout(state.persistRetryTimer);
|
||||
state.persistRetryTimer = setTimeout(() => {
|
||||
state.persistRetryTimer = null;
|
||||
if (state.editorOpen && (state.persistDirty || (state.documentRevision || 0) > (state.savedRevision || 0))) {
|
||||
_persistDraft();
|
||||
}
|
||||
}, retryDelay);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrentSession()) state.persistInFlight = null;
|
||||
});
|
||||
return state.persistInFlight;
|
||||
}
|
||||
@@ -5381,7 +5457,24 @@ async function _confirmRasterizeLayer(layer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let _lastCursorRect = null;
|
||||
let _lastCursorRectTime = 0;
|
||||
|
||||
function _invalidateCoordsCaches() {
|
||||
invalidateCanvasCoordsCache();
|
||||
_lastCursorRect = null;
|
||||
_lastCursorRectTime = 0;
|
||||
}
|
||||
|
||||
function _beginDraw(e) {
|
||||
if (state.mainCanvas) {
|
||||
// Prime canvas rect and cursor rect once before stroke begins
|
||||
// to avoid synchronous layout reads during pointermove.
|
||||
_canvasCoords(e, state.mainCanvas);
|
||||
const now = performance.now();
|
||||
_lastCursorRect = state.mainCanvas.getBoundingClientRect();
|
||||
_lastCursorRectTime = now;
|
||||
}
|
||||
// Move-tool auto-select (Photoshop parity): when the Auto-Select option is
|
||||
// on — or Ctrl/Cmd is held for a one-off — pick the topmost visible,
|
||||
// position-unlocked layer under the pointer before the drag. If the picked
|
||||
@@ -5428,13 +5521,11 @@ function _beginDraw(e) {
|
||||
}
|
||||
if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive &&
|
||||
['text', 'shape', 'placed'].includes(layer?.kind)) {
|
||||
// Paint tools need pixels: rasterize the text/shape/placed layer silently
|
||||
// (it stays undoable) so the very first stroke or pour works, instead of
|
||||
// bouncing the user through a rasterize dialog that can end up behind the
|
||||
// canvas where it cannot be clicked. If rasterizing is not possible, fall
|
||||
// back to the prompt.
|
||||
if (!_rasterizeLayerNow(layer)) { void _offerRasterizeForTool(); return; }
|
||||
// fall through — `layer` is a pixel layer now, so the tool runs below.
|
||||
// Paint tools need pixels: prompt for confirmation before destructive rasterization.
|
||||
// If user cancels, editable text/shape/placed layer is preserved intact.
|
||||
// If user accepts, layer is converted with an undo boundary so it can be reverted.
|
||||
void _confirmRasterizeLayer(layer);
|
||||
return;
|
||||
}
|
||||
// Transform-tool drag (handle grab or move-fallback) — handler in
|
||||
// editor/tools/transform-drag.js.
|
||||
@@ -5480,6 +5571,13 @@ function _beginDraw(e) {
|
||||
_strokeTool.tryBegin(e);
|
||||
}
|
||||
|
||||
let _cursorRaf = null;
|
||||
let _pendingCursorEvent = null;
|
||||
const _cursorOverlayTools = new Set([
|
||||
'eraser', 'pixel', 'inpaint', 'lasso', 'brush', 'clone', 'heal',
|
||||
'smudge', 'dodge', 'burn', 'liquify', 'quickselect', 'objectselect'
|
||||
]);
|
||||
|
||||
function _continueDraw(e) {
|
||||
if (state.tool === 'pen') return _penTool.drag(e);
|
||||
if (state.tool === 'path') return _pathTool.drag(e);
|
||||
@@ -5496,10 +5594,40 @@ function _continueDraw(e) {
|
||||
// when it's actually over the canvas, otherwise hide it.
|
||||
const overCanvas = state.mainCanvas && e.target === state.mainCanvas;
|
||||
if (state.tool === 'eyedropper' && overCanvas) _eyedropperTool.preview(e);
|
||||
if (['eraser', 'pixel', 'inpaint', 'lasso', 'brush', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify', 'quickselect', 'objectselect'].includes(state.tool) && state.mainCanvas) {
|
||||
if (overCanvas) _updateBrushCursor(e);
|
||||
else if (state.cursorEl) state.cursorEl.style.display = 'none';
|
||||
if (_cursorOverlayTools.has(state.tool) && state.mainCanvas) {
|
||||
const syncCursor = () => {
|
||||
if (!overCanvas) {
|
||||
if (state.cursorEl) state.cursorEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
if (state.drawing) {
|
||||
_pendingCursorEvent = e;
|
||||
if (!_cursorRaf) {
|
||||
_cursorRaf = requestAnimationFrame(() => {
|
||||
_cursorRaf = null;
|
||||
if (_pendingCursorEvent) {
|
||||
_updateBrushCursor(_pendingCursorEvent);
|
||||
_pendingCursorEvent = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
_updateBrushCursor(e);
|
||||
}
|
||||
};
|
||||
// Mid-stroke, move the cursor only after the stroke has sampled the canvas
|
||||
// rect: a style write first forces a synchronous style + layout pass of the
|
||||
// whole editor on every pointer move, which made long strokes lag.
|
||||
if (state.drawing) {
|
||||
try { return _continueDrawTools(e); } finally { syncCursor(); }
|
||||
}
|
||||
syncCursor();
|
||||
}
|
||||
return _continueDrawTools(e);
|
||||
}
|
||||
|
||||
function _continueDrawTools(e) {
|
||||
if (state.drawing) return _strokeTool.tryContinue(e);
|
||||
// Transform-tool hover-cursor + handle drag — handler in
|
||||
// editor/tools/transform-drag.js. Returns true when the drag is
|
||||
// consuming the event (rotation / resize); the hover-cursor pass
|
||||
@@ -5514,17 +5642,13 @@ function _continueDraw(e) {
|
||||
if (state.tool === 'quickselect') return _quickSelectTool.drag(e);
|
||||
if (state.tool === 'objectselect') return _objectSelectTool.drag(e);
|
||||
if (state.tool === 'patch') return _patchTool.drag(e);
|
||||
if (!state.drawing) {
|
||||
if (state.moving) return _continueMove(e);
|
||||
if (state.cropping || state.cropMoving) return _continueCrop(e);
|
||||
return;
|
||||
}
|
||||
// In-progress stroke (brush / eraser / inpaint / clone) — handler in
|
||||
// editor/tools/stroke.js.
|
||||
_strokeTool.tryContinue(e);
|
||||
if (state.moving) return _continueMove(e);
|
||||
if (state.cropping || state.cropMoving) return _continueCrop(e);
|
||||
}
|
||||
|
||||
function _endDraw(e) {
|
||||
if (_cursorRaf) { cancelAnimationFrame(_cursorRaf); _cursorRaf = null; }
|
||||
if (_pendingCursorEvent) { _updateBrushCursor(_pendingCursorEvent); _pendingCursorEvent = null; }
|
||||
if (_cursorLoupeVisible) _setCursorLoupe(false);
|
||||
if (state.tool === 'pen') return _penTool.end();
|
||||
if (state.tool === 'path') return _pathTool.end(e);
|
||||
@@ -5543,6 +5667,7 @@ function _endDraw(e) {
|
||||
// Stroke end (brush / eraser / inpaint / clone) — handler in
|
||||
// editor/tools/stroke.js.
|
||||
_strokeTool.tryEnd(e);
|
||||
_invalidateCoordsCaches();
|
||||
}
|
||||
|
||||
// Floating popup that appears after an inpaint stroke so the user can
|
||||
@@ -5579,19 +5704,33 @@ const _endStroke = _strokePipeline.endStroke;
|
||||
|
||||
// ── Brush cursor overlay ──
|
||||
|
||||
|
||||
function _updateBrushCursor(e) {
|
||||
if (!state.mainCanvas) return;
|
||||
const rect = state.mainCanvas.getBoundingClientRect();
|
||||
const now = performance.now();
|
||||
let rect;
|
||||
if (_lastCursorRect && (state.drawing || (now - _lastCursorRectTime < 32))) {
|
||||
rect = _lastCursorRect;
|
||||
} else {
|
||||
rect = state.mainCanvas.getBoundingClientRect();
|
||||
_lastCursorRect = rect;
|
||||
_lastCursorRectTime = now;
|
||||
}
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
|
||||
|
||||
if (!state.cursorEl) {
|
||||
let layer = document.getElementById('ge-cursor-layer');
|
||||
if (!layer) {
|
||||
layer = document.createElement('div');
|
||||
layer.id = 'ge-cursor-layer';
|
||||
layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;contain:strict;z-index:2147483647;';
|
||||
document.body.appendChild(layer);
|
||||
}
|
||||
state.cursorEl = document.createElement('div');
|
||||
state.cursorEl.className = 'ge-brush-cursor';
|
||||
document.body.appendChild(state.cursorEl);
|
||||
layer.appendChild(state.cursorEl);
|
||||
}
|
||||
|
||||
// Lasso uses the feather radius (or a sensible default) so the circle
|
||||
@@ -5613,14 +5752,23 @@ function _updateBrushCursor(e) {
|
||||
// or responsive layout changes the canvas independently of nominal zoom.
|
||||
const diameterX = basePx * (rect.width / Math.max(1, state.mainCanvas.width));
|
||||
const diameterY = basePx * (rect.height / Math.max(1, state.mainCanvas.height));
|
||||
state.cursorEl.style.width = diameterX + 'px';
|
||||
state.cursorEl.style.height = diameterY + 'px';
|
||||
if (state._lastCursorDiameterX !== diameterX || state._lastCursorDiameterY !== diameterY) {
|
||||
state._lastCursorDiameterX = diameterX;
|
||||
state._lastCursorDiameterY = diameterY;
|
||||
state.cursorEl.style.width = diameterX + 'px';
|
||||
state.cursorEl.style.height = diameterY + 'px';
|
||||
}
|
||||
state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);
|
||||
state.cursorEl.style.left = (clientX - diameterX / 2) + 'px';
|
||||
state.cursorEl.style.top = (clientY - diameterY / 2) + 'px';
|
||||
state.cursorEl.style.display = 'block';
|
||||
state.cursorEl.dataset.clientX = String(clientX);
|
||||
state.cursorEl.dataset.clientY = String(clientY);
|
||||
state.cursorEl.style.transform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
|
||||
if (state.cursorEl.style.display !== 'block') {
|
||||
state.cursorEl.style.display = 'block';
|
||||
}
|
||||
state.cursorClientX = clientX;
|
||||
state.cursorClientY = clientY;
|
||||
if (!state.drawing) {
|
||||
state.cursorEl.dataset.clientX = String(clientX);
|
||||
state.cursorEl.dataset.clientY = String(clientY);
|
||||
}
|
||||
if (state.tool === 'inpaint') {
|
||||
// Visual cue for paint vs erase mode. Ctrl+Alt held mid-hover also
|
||||
// flips the cursor so the user sees the effective mode before they
|
||||
@@ -5641,9 +5789,13 @@ function _updateBrushCursor(e) {
|
||||
state.cursorEl.style.background = 'rgba(0,0,0,0.15)';
|
||||
state.cursorEl.style.borderStyle = 'solid';
|
||||
} else {
|
||||
state.cursorEl.style.borderColor = state.tool === 'eraser' ? 'rgba(255,255,255,0.6)' : state.color;
|
||||
state.cursorEl.style.background = 'transparent';
|
||||
state.cursorEl.style.borderStyle = 'solid';
|
||||
const borderColor = state.tool === 'eraser' ? 'rgba(255,255,255,0.6)' : state.color;
|
||||
if (state._lastCursorBorderColor !== borderColor) {
|
||||
state._lastCursorBorderColor = borderColor;
|
||||
state.cursorEl.style.borderColor = borderColor;
|
||||
state.cursorEl.style.background = 'transparent';
|
||||
state.cursorEl.style.borderStyle = 'solid';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9476,6 +9628,7 @@ function _getFitZoom() {
|
||||
}
|
||||
|
||||
function _applyZoom({ resetPan = false } = {}) {
|
||||
_invalidateCoordsCaches();
|
||||
if (!state.mainCanvas) return;
|
||||
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
|
||||
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
|
||||
@@ -9485,6 +9638,9 @@ function _applyZoom({ resetPan = false } = {}) {
|
||||
const area = state.container && state.container.querySelector('.ge-canvas-area');
|
||||
if (resetPan && area && area._resetPan) area._resetPan();
|
||||
_syncSelectionOverlay();
|
||||
// The transform overlay is sized and placed for the zoom it was drawn at;
|
||||
// a refit (e.g. the phone Layers sheet collapsing) must redraw it.
|
||||
if (state.transformActive) _drawTransformHandles();
|
||||
_precisionGuides?.redrawRulers();
|
||||
}
|
||||
|
||||
@@ -10337,7 +10493,10 @@ function _buildEditor(container) {
|
||||
if (e) _eyedropperTool.preview(e);
|
||||
},
|
||||
syncZoomControls: () => _syncZoomControls(),
|
||||
onViewportChange: () => _precisionGuides?.redrawRulers(),
|
||||
onViewportChange: () => {
|
||||
_precisionGuides?.redrawRulers();
|
||||
if (state.transformActive) _drawTransformHandles();
|
||||
},
|
||||
onTwoFingerTap: () => undo(),
|
||||
onThreeFingerTap: () => redo(),
|
||||
});
|
||||
@@ -10405,7 +10564,7 @@ function _buildEditor(container) {
|
||||
composite();
|
||||
},
|
||||
});
|
||||
const { rightPanel, controls, layerPanel, dock } = buildRightPanel({
|
||||
const { rightPanel, controls, mobilePanels, layerPanel, dock } = buildRightPanel({
|
||||
controlsHTML: _controlsHTML,
|
||||
layerPanelHTML: _layerPanelHTML,
|
||||
extraPanels: [
|
||||
@@ -10434,6 +10593,7 @@ function _buildEditor(container) {
|
||||
});
|
||||
editorBody.appendChild(rightPanel);
|
||||
panelVisibility.apply();
|
||||
panelVisibility.subscribe(_invalidateCoordsCaches);
|
||||
window.gePanelVisibility = panelVisibility;
|
||||
// Remember the workspace/window layout the user last chose and restore it on
|
||||
// open, so the editor comes back the way they left it. A fresh session has no
|
||||
@@ -10502,16 +10662,72 @@ function _buildEditor(container) {
|
||||
_preflight?.maybeOpen();
|
||||
} catch (_) {}
|
||||
try { _initControlKit(container); } catch (_) {}
|
||||
// Tool options live in the options bar (Photoshop), not the dock. On
|
||||
// desktop the controls panel is moved under the contextual strip so the
|
||||
// dock is reserved for Color / Adjustments / Layers; on mobile
|
||||
// right-panel.js already re-parents it into a bottom sheet.
|
||||
if (window.innerWidth > 700 && controls) {
|
||||
optionsBar.appendChild(controls);
|
||||
controls.classList.add('ge-controls--in-options');
|
||||
// The "..." overflow affordance is shown/hidden per tool inside
|
||||
// renderOptionsBar (it only reveals the secondary brush rows).
|
||||
}
|
||||
let _lastLayoutDesktop = null;
|
||||
const _syncResponsiveLayout = (force = false) => {
|
||||
_invalidateCoordsCaches();
|
||||
const isDesktop = window.innerWidth > 700;
|
||||
if (!force && isDesktop === _lastLayoutDesktop) return;
|
||||
_lastLayoutDesktop = isDesktop;
|
||||
|
||||
const footer = container.querySelector('.ge-editor-footer');
|
||||
const hdr = container.querySelector('.ge-layers-header');
|
||||
const fh = container.querySelector('.ge-footer-history');
|
||||
const fz = container.querySelector('.ge-footer-zoom');
|
||||
const setupBtn = document.getElementById('ge-setup-btn');
|
||||
const aiBtn = document.getElementById('ge-ai-settings-btn');
|
||||
const optionsBar = container.querySelector('.ge-options-bar');
|
||||
|
||||
if (isDesktop) {
|
||||
// Reparent controls into optionsBar
|
||||
if (controls && optionsBar && controls.parentElement !== optionsBar) {
|
||||
optionsBar.appendChild(controls);
|
||||
}
|
||||
controls?.classList.add('ge-controls--in-options');
|
||||
controls?.classList.remove('dismissed');
|
||||
optionsBar?.classList.add('ge-options-bar--has-controls');
|
||||
|
||||
// Right panel dock mode
|
||||
rightPanel?.classList.add('ge-right-panel--dock');
|
||||
if (dock?.dock) dock.dock.style.display = '';
|
||||
if (mobilePanels) mobilePanels.style.display = 'none';
|
||||
const panelsBtn = layerPanel?.querySelector('.ge-layers-panels-btn');
|
||||
if (panelsBtn) panelsBtn.style.display = 'none';
|
||||
|
||||
// Clear mobile footer docking on desktop
|
||||
if (fh && footer && fh.parentElement !== footer) {
|
||||
footer.insertBefore(fh, footer.firstChild);
|
||||
}
|
||||
_placeMobileFooter();
|
||||
} else {
|
||||
// Reparent controls into editor container (mobile sheet)
|
||||
if (controls && controls.parentElement !== container) {
|
||||
container.appendChild(controls);
|
||||
}
|
||||
controls?.classList.remove('ge-controls--in-options');
|
||||
optionsBar?.classList.remove('ge-options-bar--has-controls');
|
||||
|
||||
// Right panel mobile sheet mode
|
||||
rightPanel?.classList.remove('ge-right-panel--dock');
|
||||
if (dock?.dock) dock.dock.style.display = 'none';
|
||||
if (mobilePanels) mobilePanels.style.display = '';
|
||||
const panelsBtn = layerPanel?.querySelector('.ge-layers-panels-btn');
|
||||
if (panelsBtn) panelsBtn.style.display = '';
|
||||
|
||||
if (fh && hdr && fh.parentElement !== hdr) {
|
||||
hdr.appendChild(fh);
|
||||
}
|
||||
// Dock footer flush to sheet on mobile
|
||||
_placeMobileFooter();
|
||||
}
|
||||
};
|
||||
|
||||
_syncResponsiveLayout(true);
|
||||
try {
|
||||
const _mobileMql = window.matchMedia('(max-width: 700px)');
|
||||
_mobileMql.addEventListener('change', () => _syncResponsiveLayout(true));
|
||||
} catch (_) {}
|
||||
window.addEventListener('resize', () => _syncResponsiveLayout(false), { passive: true });
|
||||
|
||||
// Crop options: aspect-ratio presets (Free / Original / 1:1 / 4:5 / 16:9).
|
||||
// Choosing one locks new crop drags to the ratio and refits an existing
|
||||
// crop rect (centred, clamped to the canvas). Wired after the controls
|
||||
@@ -11931,7 +12147,13 @@ function _buildEditor(container) {
|
||||
const flat = _aiEditSourceCanvas();
|
||||
const image = flat.toDataURL('image/png').split(',')[1];
|
||||
const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||
_startAiProgress(requestId);
|
||||
const qeModel = _getSelectedQuickEditModel();
|
||||
const endpointId = payload._endpoint || qeModel.endpoint || '';
|
||||
if (endpointId) {
|
||||
_startAiProgress(requestId, endpointId);
|
||||
} else {
|
||||
_startAiProgress(requestId);
|
||||
}
|
||||
const body = JSON.stringify({ image, request_id: requestId, ...payload });
|
||||
// The image servers occasionally reject a request transiently (503 while a
|
||||
// model swaps, 502/504 gateways, dropped connections). Retry those a couple
|
||||
@@ -13497,6 +13719,11 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
|
||||
state.draftName = displayName || (presetSize ? `New ${presetSize.w}×${presetSize.h}` : 'Untitled');
|
||||
state.editorSessionToken += 1;
|
||||
state.editorOpen = true;
|
||||
state.documentRevision = 0;
|
||||
state.persistingRevision = 0;
|
||||
state.savedRevision = 0;
|
||||
state.persistRetryCount = 0;
|
||||
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
|
||||
_writeActiveEditorSession();
|
||||
state.layers = [];
|
||||
state.selectedLayerIds = [];
|
||||
@@ -13547,6 +13774,8 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
|
||||
_stopSelectionAnimation();
|
||||
window.__galleryEditLive = true;
|
||||
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
|
||||
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
|
||||
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
|
||||
state.persistDirty = false;
|
||||
state.persistErrorMessage = null;
|
||||
state.persistInFlight = null;
|
||||
@@ -13793,6 +14022,8 @@ export function closeEditor(options = {}) {
|
||||
// the editor mid-stroke doesn't lose work. The call is fire-and-forget;
|
||||
// the server commit lands shortly after the modal hides.
|
||||
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
|
||||
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
|
||||
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
|
||||
if (state.layers.length) {
|
||||
try { _persistDraft(); } catch {}
|
||||
}
|
||||
@@ -13831,6 +14062,7 @@ export function closeEditor(options = {}) {
|
||||
try { document.removeEventListener('click', h); } catch {}
|
||||
}
|
||||
if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
|
||||
try { document.getElementById('ge-cursor-layer')?.remove(); } catch {}
|
||||
// Tear down all floating popups + the dock so closing the editor
|
||||
// doesn't leave stale chips/panels behind on top of the gallery.
|
||||
try { _closeFxMenu(); } catch {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const TOOL_WINDOW_SELECTOR = 'body > .modal, body > .research-overlay, body > .notes-pane-backdrop';
|
||||
export const TOOL_WINDOW_SELECTOR = 'body > .modal:not(#styled-confirm-overlay), body > .research-overlay, body > .notes-pane-backdrop';
|
||||
|
||||
export function topToolWindowZ(options = {}) {
|
||||
const {
|
||||
|
||||
+3
-1
@@ -8,7 +8,7 @@ import themeModule from './theme.js?v=20260911organsrain1';
|
||||
import * as Modals from './modalManager.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { registerMenuDismiss, dismissTopEscapeLayer, dismissOrRemove } from './escMenuStack.js';
|
||||
import { nextToolWindowZ, topToolWindowZ } from './toolWindowZOrder.js';
|
||||
import { nextToolWindowZ, topToolWindowZ, topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
let toastEl = null;
|
||||
let autoScrollEnabled = true;
|
||||
@@ -793,10 +793,12 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
const _prevFocus = document.activeElement;
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.style.display = '';
|
||||
overlay.style.zIndex = String(topPortalZ() + 1000);
|
||||
|
||||
function cleanup(result) {
|
||||
overlay.classList.add('hidden');
|
||||
overlay.style.display = 'none';
|
||||
overlay.style.zIndex = '';
|
||||
okBtn.removeEventListener('click', onOk);
|
||||
cancelBtn.removeEventListener('click', onCancel);
|
||||
altBtn.removeEventListener('click', onAlt);
|
||||
|
||||
+23
-3
@@ -7,7 +7,7 @@
|
||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||
// - API / non-GET: never cached.
|
||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||
const CACHE_NAME = 'odysseus-v1113-pr40-editor-precache';
|
||||
const CACHE_NAME = 'odysseus-v1114-pr40-editor-precache';
|
||||
|
||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||
// gives offline math fallback glyphs instead of proper typesetting.
|
||||
@@ -74,6 +74,8 @@ const PRECACHE = [
|
||||
'/static/js/fileHandler.js?v=20260909mobileattachmentedit1',
|
||||
'/static/js/voiceRecorder.js',
|
||||
'/static/js/actionMenuOrder.js',
|
||||
'/static/js/platform.js',
|
||||
'/static/js/modelSort.js',
|
||||
'/static/js/models.js',
|
||||
'/static/js/rag.js',
|
||||
'/static/js/presets.js?v=20260908personaname1',
|
||||
@@ -364,11 +366,29 @@ self.addEventListener('activate', (e) => {
|
||||
);
|
||||
});
|
||||
|
||||
// Offline fallback for /static/ JS/CSS. /static is a public, unauthenticated
|
||||
// mount whose bytes do not depend on the request, but CORS adds `Vary: Origin`
|
||||
// and module imports send an Origin header that install-time fetches omit, so a
|
||||
// strict match hides the precached copy. Ignore Vary only for entries in this
|
||||
// worker's own cache whose sole Vary header is Origin.
|
||||
async function cachedStaticFile(request) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const exact = await cache.match(request);
|
||||
if (exact) return exact;
|
||||
const variesOnlyOnOrigin = response => (response.headers.get('Vary') || '')
|
||||
.split(',').map(name => name.trim().toLowerCase()).filter(Boolean)
|
||||
.every(name => name === 'origin');
|
||||
const candidates = await cache.matchAll(request, { ignoreVary: true });
|
||||
return candidates.find(variesOnlyOnOrigin) || Response.error();
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const url = new URL(e.request.url);
|
||||
|
||||
// Never touch API calls or non-GET.
|
||||
if (url.pathname.startsWith('/api/') || e.request.method !== 'GET') return;
|
||||
// Only this origin's own files are cached; anything else goes to the network.
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// HTML navigation: network-first app shell — but ONLY for the SPA root.
|
||||
// Other navigations (e.g. a deep-linked /static/*.html page) must go to the
|
||||
@@ -394,10 +414,10 @@ self.addEventListener('fetch', (e) => {
|
||||
fetch(e.request).then(res => {
|
||||
if (res && res.ok) {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE_NAME).then(cache => cache.put(e.request, copy));
|
||||
caches.open(CACHE_NAME).then(cache => cache.put(e.request, copy)).catch(() => {});
|
||||
}
|
||||
return res;
|
||||
}).catch(() => caches.match(e.request))
|
||||
}).catch(() => cachedStaticFile(e.request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
+7
-2
@@ -1,7 +1,7 @@
|
||||
# Browser End-to-End Tests
|
||||
|
||||
The photo editor release gate launches an isolated, authentication-disabled
|
||||
Odysseus server on port `7013` with a temporary SQLite database.
|
||||
The photo editor release gate launches an isolated Odysseus server on port
|
||||
`7013` with a temporary SQLite database and disposable authenticated account.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
@@ -13,3 +13,8 @@ Set `PHOTO_EDITOR_E2E_PORT` to use another port. Failure screenshots and traces
|
||||
are written to `test-results/photo-editor/`. The server uses the repository
|
||||
`.venv` automatically; set `ODYSSEUS_TEST_PYTHON` to another Python executable
|
||||
when using a different environment layout.
|
||||
|
||||
The disposable account is created in the temporary data directory and exercises
|
||||
owner-scoped draft and notification routes without using an existing account.
|
||||
`PHOTO_EDITOR_E2E_AUTH=0` selects the legacy unauthenticated configuration for
|
||||
investigating it; offline-upgrade coverage requires the authenticated mode.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
const { chooseFilterAction, selectTool } = require('./helpers.js');
|
||||
const {
|
||||
dragOnCanvas,
|
||||
encodedImagePixelDigest,
|
||||
@@ -11,7 +11,7 @@ const {
|
||||
} = require('./helpers.js');
|
||||
|
||||
async function addAdjustment(page, type) {
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('#ge-add-fx').click();
|
||||
const menu = page.locator('.ge-add-layer-menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.locator(`[data-adjustment-type="${type}"]`).click();
|
||||
@@ -252,8 +252,7 @@ test('retained Gaussian Blur survives flattening and draft reopen', async ({ pag
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
|
||||
const before = await flattenedPixelDigest(page);
|
||||
|
||||
await page.locator('#ge-filter-menu-btn').click();
|
||||
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
|
||||
await chooseFilterAction(page, 'effect-blur-gaussian');
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
@@ -282,8 +281,7 @@ test('retained Gaussian Blur survives flattening and draft reopen', async ({ pag
|
||||
|
||||
test('retained Sharpen keeps editable amount and survives draft reopen', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained sharpen E2E');
|
||||
await page.locator('#ge-filter-menu-btn').click();
|
||||
await page.locator('[data-filter-action="effect-preset-crisp-detail"]').click();
|
||||
await chooseFilterAction(page, 'effect-preset-crisp-detail');
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
expect(await page.locator('.ge-filter-row input[data-key="amount"]').inputValue()).toBe('35');
|
||||
await page.locator('.ge-filter-row input[data-key="amount"]').fill('75');
|
||||
@@ -308,16 +306,14 @@ test('retained overlay and shadow effects keep editable metadata', async ({ page
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(canvasBox.x + 150, canvasBox.y + 140);
|
||||
await page.mouse.up();
|
||||
await page.locator('#ge-filter-menu-btn').click();
|
||||
await page.locator('[data-filter-action="effect-color-overlay"]').click();
|
||||
await chooseFilterAction(page, 'effect-color-overlay');
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
|
||||
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
await page.locator('button[title="Add effect mask from selection"]').click();
|
||||
|
||||
await page.locator('#ge-filter-menu-btn').click();
|
||||
await page.locator('[data-filter-action="effect-drop-shadow"]').click();
|
||||
await chooseFilterAction(page, 'effect-drop-shadow');
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[data-key="blur"]').fill('18');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
@@ -348,8 +344,7 @@ test('retained Stroke uses layer alpha and persists its controls', async ({ page
|
||||
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
|
||||
await selectTool(page, 'brush');
|
||||
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
|
||||
await page.locator('#ge-filter-menu-btn').click();
|
||||
await page.locator('[data-filter-action="effect-stroke"]').click();
|
||||
await chooseFilterAction(page, 'effect-stroke');
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[data-key="width"]').fill('9');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
// These tests borrow /login as a lightweight page. A signed-in session would
|
||||
// make the login page redirect to the app mid-test, so run them signed out.
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
async function addAppStyles(page) {
|
||||
const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs');
|
||||
for (const url of await stylesheetUrls()) {
|
||||
|
||||
@@ -54,7 +54,8 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page }
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(end.x, end.y, { steps: 240 });
|
||||
await page.mouse.up();
|
||||
expect(Date.now() - startedAt).toBeLessThan(5000);
|
||||
const strokeDuration = Date.now() - startedAt;
|
||||
expect(strokeDuration).toBeLessThan(5000);
|
||||
const after = await flattenedPixelDigest(page);
|
||||
expect(after).not.toEqual(before);
|
||||
await expect(thumb).toHaveAttribute('width', '68');
|
||||
@@ -78,14 +79,17 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
|
||||
await selectTool(page, 'eyedropper');
|
||||
await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
|
||||
await page.locator('#ge-eyedropper-sample').selectOption('composite');
|
||||
await page.mouse.move(sample.x, sample.y);
|
||||
// The options strip height differs per tool, which re-centres the canvas a
|
||||
// few pixels; hover the same image point that was painted.
|
||||
const painted = await canvasPoint(page, 0.3, 0.3);
|
||||
await page.mouse.move(painted.x, painted.y);
|
||||
await expect(page.locator('#ge-eyedropper-live-value')).toHaveText('#33aa55');
|
||||
await expect(page.locator('#ge-eyedropper-live-rgb')).toHaveText('RGB 51 170 85');
|
||||
await expect(page.locator('#ge-eyedropper-live-hsl')).toHaveText('HSL 137 54% 43%');
|
||||
await expect(page.locator('#ge-eyedropper-live-swatch')).not.toHaveClass(/empty/);
|
||||
await expect(page.locator('#ge-eyedropper-loupe')).toBeVisible();
|
||||
expect((await page.locator('#ge-eyedropper-loupe').evaluate(canvas => canvas.getContext('2d').getImageData(42, 42, 1, 1).data[3]))).toBe(255);
|
||||
await page.mouse.click(sample.x, sample.y);
|
||||
await page.mouse.click(painted.x, painted.y);
|
||||
await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55');
|
||||
|
||||
for (const tool of ['dodge', 'burn']) {
|
||||
@@ -178,7 +182,11 @@ test('smudge carries nearby pixels along a stroke and undoes atomically', async
|
||||
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
|
||||
});
|
||||
await selectTool(page, 'smudge');
|
||||
// Smudge strength lives in the brush-family "More" menu with the other
|
||||
// per-tool stroke modifiers.
|
||||
await page.locator('#ge-brush-more').click();
|
||||
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.mouse.move(source.x, source.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(target.x, target.y, { steps: 20 });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
|
||||
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor, settledCanvasBox } = require('./helpers.js');
|
||||
|
||||
test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Gesture cancellation');
|
||||
@@ -75,13 +75,15 @@ test('touch crop and selection gestures complete through the shared lifecycle',
|
||||
try {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch gestures');
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
|
||||
type,
|
||||
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
|
||||
});
|
||||
|
||||
await selectTool(page, 'crop');
|
||||
// Picking a tool minimizes the phone Layers sheet and refits the canvas;
|
||||
// aim at where the canvas settles, as a finger would.
|
||||
const canvasBox = await settledCanvasBox(page);
|
||||
await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30);
|
||||
await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145);
|
||||
await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145);
|
||||
@@ -113,18 +115,24 @@ test('touch brush paints and undoes as one stroke', async ({ browser, browserNam
|
||||
try {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch brush');
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
|
||||
type,
|
||||
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
|
||||
});
|
||||
await selectTool(page, 'brush');
|
||||
// Re-tapping the active tool collapses its controls sheet (canvas-first
|
||||
// phone editing), which also uncovers the Layers sheet grab handle.
|
||||
await selectTool(page, 'brush');
|
||||
const canvasBox = await settledCanvasBox(page);
|
||||
const before = await flattenedPixelDigest(page);
|
||||
await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5);
|
||||
await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
|
||||
await touch('touchEnd', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
|
||||
const after = await flattenedPixelDigest(page);
|
||||
expect(after).not.toEqual(before);
|
||||
// History buttons live in the Layers sheet, which the tool pick minimized.
|
||||
await page.locator('.ge-layers-grab').click();
|
||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(before);
|
||||
} finally {
|
||||
|
||||
@@ -39,7 +39,7 @@ async function downloadFormat(page, format, filename) {
|
||||
}
|
||||
|
||||
async function addAdjustment(page, type) {
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('#ge-add-fx').click();
|
||||
await expect(page.locator('.ge-add-layer-menu')).toBeVisible();
|
||||
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${type}"]`).click();
|
||||
await expect(page.locator('.ge-adj-popup')).toBeVisible();
|
||||
@@ -100,7 +100,7 @@ test('composited correction exports preserve pixels and format metadata', async
|
||||
await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity');
|
||||
await seedGradient(page);
|
||||
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('#ge-add-fx').click();
|
||||
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
|
||||
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('35');
|
||||
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
const { chooseFilterAction, selectTool } = require('./helpers.js');
|
||||
const fs = require('node:fs');
|
||||
const {
|
||||
compareExportPixels,
|
||||
@@ -38,8 +38,7 @@ test('group retained effects edit, toggle, reorder surface, and survive reopen',
|
||||
const groupId = (await editorState(page)).groups[0].id;
|
||||
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
|
||||
|
||||
await page.locator('.ge-layer-controls:visible .ge-layer-btn[title*="effect"]').click();
|
||||
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
|
||||
await chooseFilterAction(page, 'effect-blur-gaussian', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
@@ -84,8 +83,7 @@ test('group retained effects export the visible composite and survive reopen', a
|
||||
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
|
||||
await page.locator('#ge-group-selected').click();
|
||||
|
||||
await page.locator('.ge-layer-controls:visible .ge-layer-btn[title*="effect"]').click();
|
||||
await page.locator('[data-filter-action="effect-color-overlay"]').click();
|
||||
await chooseFilterAction(page, 'effect-color-overlay', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
|
||||
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('40');
|
||||
|
||||
@@ -10,7 +10,6 @@ test('group drag reorders the complete subtree with undo and server persistence'
|
||||
const originalOrder = current.layers.map(layer => layer.id);
|
||||
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||
current = await editorState(page);
|
||||
const looseId = current.activeLayerId;
|
||||
expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
|
||||
@@ -66,7 +65,7 @@ test('mobile layer drag handle supports long-press reorder', async ({ browser, b
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder');
|
||||
await page.locator('.ge-layers-header').click();
|
||||
await page.locator('.ge-layers-title').click();
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
|
||||
const before = await editorState(page);
|
||||
// State is bottom-to-top; the panel is top-to-bottom.
|
||||
|
||||
@@ -1,40 +1,22 @@
|
||||
const { expect } = require('@playwright/test');
|
||||
|
||||
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
|
||||
let lastError = null;
|
||||
for (let pageAttempt = 0; pageAttempt < 3; pageAttempt += 1) {
|
||||
try {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
|
||||
await page.evaluate(async () => {
|
||||
window.__photoEditorImport = async path => {
|
||||
let lastImportError = null;
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
const url = attempt === 0 ? path : `${path}?e2e_retry=${attempt}-${Date.now()}`;
|
||||
try { return await import(url); } catch (error) { lastImportError = error; }
|
||||
await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1)));
|
||||
}
|
||||
throw lastImportError;
|
||||
};
|
||||
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
|
||||
gallery.openGallery();
|
||||
});
|
||||
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
|
||||
await page.locator('#gallery-editor-tab').click();
|
||||
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
|
||||
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
|
||||
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
|
||||
}, { size, name });
|
||||
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await page.goto('about:blank');
|
||||
await page.waitForTimeout(250 * (pageAttempt + 1));
|
||||
}
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
// Workflow tests exercise the canvas, not the optional setup overlay.
|
||||
await page.evaluate(() => localStorage.setItem('ge-preflight-dismissed', '1'));
|
||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
|
||||
await page.evaluate(async () => {
|
||||
window.__photoEditorImport = path => import(path);
|
||||
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
|
||||
gallery.openGallery();
|
||||
});
|
||||
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
|
||||
await page.locator('#gallery-editor-tab').click();
|
||||
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
|
||||
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
|
||||
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
|
||||
}, { size, name });
|
||||
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.layers.length;
|
||||
@@ -368,12 +350,51 @@ async function selectTool(page, toolId) {
|
||||
await face.first().click();
|
||||
return;
|
||||
}
|
||||
// Flyout members are pre-rendered (hidden until the slot opens). Dispatch a
|
||||
// click directly so the helper does not depend on hover/flyout visibility.
|
||||
await page.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`).first().dispatchEvent('click');
|
||||
// Open the flyout via its corner triangle, wait for item visibility, and click realistically
|
||||
const slot = page.locator('.ge-tool-slot', { has: page.locator(`[data-tool-option="${toolId}"]`) }).first();
|
||||
const tri = slot.locator('.ge-tool-flyout-tri');
|
||||
if (await tri.count() > 0) {
|
||||
await tri.click();
|
||||
}
|
||||
const item = slot.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`);
|
||||
await expect(item).toBeVisible({ timeout: 3000 });
|
||||
await item.click();
|
||||
}
|
||||
|
||||
// On phones, picking a tool minimizes the Layers sheet and refits the canvas.
|
||||
// Wait for the canvas to stop moving before aiming at it.
|
||||
async function settledCanvasBox(page) {
|
||||
let box = null;
|
||||
await expect.poll(async () => {
|
||||
const previous = box;
|
||||
box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
return JSON.stringify(previous) === JSON.stringify(box);
|
||||
}).toBe(true);
|
||||
return box;
|
||||
}
|
||||
|
||||
// Rare text/shape options live in the options strip's "More" popover, which
|
||||
// closes on any outside press. Open it (idempotently) before using them.
|
||||
async function openOptionsMore(page, kind) {
|
||||
const menu = page.locator(`#ge-${kind}-more`);
|
||||
if (await menu.isHidden()) await page.locator(`#ge-${kind}-more-toggle`).click();
|
||||
await expect(menu).toBeVisible();
|
||||
}
|
||||
|
||||
// Filter actions live in Photoshop-style flyouts (Blur, Retained effects, ...).
|
||||
// Open the menu, then the item's parent row, as a user would.
|
||||
async function chooseFilterAction(page, action, opener = '#ge-filter-menu-btn') {
|
||||
await page.locator(opener).click();
|
||||
const menu = page.locator('#ge-filter-menu');
|
||||
await expect(menu).toBeVisible();
|
||||
const item = menu.locator(`[data-filter-action="${action}"]`);
|
||||
const panel = await item.evaluate(node => node.closest('.ge-submenu')?.dataset.submenuPanel || null);
|
||||
if (panel) await menu.locator(`.ge-menu-parent[data-submenu="${panel}"]`).click();
|
||||
await item.click();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
chooseFilterAction,
|
||||
compareExportPixels,
|
||||
dragOnCanvas,
|
||||
encodedImagePixelDigest,
|
||||
@@ -381,7 +402,9 @@ module.exports = {
|
||||
flattenedPixelDigest,
|
||||
openBlankEditor,
|
||||
openExportDialog,
|
||||
openOptionsMore,
|
||||
reopenDraft,
|
||||
selectTool,
|
||||
settledCanvasBox,
|
||||
waitForDraft,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor, editorState, dragOnCanvas } = require('./helpers');
|
||||
const { chooseFilterAction, dragOnCanvas, editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// The isolated editor server has no logged-in notification owner.
|
||||
@@ -35,6 +35,96 @@ test('top toolbar stays on one row and its menus remain clickable at narrow widt
|
||||
}
|
||||
});
|
||||
|
||||
test('save button icon follows the draft status through an edit and autosave', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
// Autosave can settle within a frame, so record the rendered icon at every
|
||||
// status change instead of racing it with polling assertions.
|
||||
await page.evaluate(() => {
|
||||
const status = document.getElementById('ge-draft-status');
|
||||
const visibleIcon = () => [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')]
|
||||
.filter(icon => icon.getBoundingClientRect().width > 0)
|
||||
.map(icon => (icon.classList.contains('ge-save-state-saved') ? 'saved' : 'dirty'));
|
||||
window.__saveStates = [];
|
||||
new MutationObserver(() => window.__saveStates.push({ text: status.textContent, icons: visibleIcon() }))
|
||||
.observe(status, { childList: true, characterData: true, subtree: true });
|
||||
});
|
||||
await page.keyboard.press('b');
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
|
||||
await waitForDraft(page);
|
||||
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
|
||||
const states = await page.evaluate(() => window.__saveStates);
|
||||
expect(states.some(entry => entry.text === 'Unsaved')).toBe(true);
|
||||
for (const entry of states) {
|
||||
expect(entry.icons).toEqual([entry.text === 'Saved' ? 'saved' : 'dirty']);
|
||||
}
|
||||
});
|
||||
|
||||
test('save-state representations cover all draft states without error normalization', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await waitForDraft(page);
|
||||
|
||||
const cases = [
|
||||
{ label: 'Unsaved', state: 'dirty', expectedText: 'Unsaved', expectedState: 'dirty', iconClass: 'ge-save-state-dirty', isError: false },
|
||||
{ label: 'Saving soon…', state: 'pending', expectedText: 'Saving soon…', expectedState: 'pending', iconClass: 'ge-save-state-dirty', isError: false },
|
||||
{ label: 'Saving…', state: 'saving', expectedText: 'Saving…', expectedState: 'saving', iconClass: 'ge-save-state-dirty', isError: false },
|
||||
{ label: 'Saved', state: 'saved', expectedText: 'Saved', expectedState: 'saved', iconClass: 'ge-save-state-saved', isError: false },
|
||||
{ label: 'Save failed', state: 'error', expectedText: 'Save failed', expectedState: 'error', iconClass: 'ge-save-state-error', isError: true },
|
||||
{ label: 'Save paused', state: 'paused', expectedText: 'Save paused', expectedState: 'paused', iconClass: 'ge-save-state-error', isError: true },
|
||||
{ label: 'Save failed: network offline', state: '', expectedText: 'Save failed: network offline', expectedState: 'error', iconClass: 'ge-save-state-error', isError: true },
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
await page.evaluate(({ label, state }) => {
|
||||
window.__geSetDraftStatus(label, state);
|
||||
}, c);
|
||||
|
||||
const statusEl = page.locator('#ge-draft-status');
|
||||
const saveBtn = page.locator('#ge-save-menu-btn');
|
||||
await expect(statusEl).toHaveText(c.expectedText);
|
||||
await expect(statusEl).toHaveAttribute('data-state', c.expectedState);
|
||||
|
||||
const visibleIcon = await page.evaluate(() => {
|
||||
const icons = [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')];
|
||||
const visible = icons.filter(i => !i.hasAttribute('hidden'));
|
||||
return visible.map(i => i.className.baseVal || i.className);
|
||||
});
|
||||
expect(visibleIcon.length).toBe(1);
|
||||
expect(visibleIcon[0]).toContain(c.iconClass);
|
||||
|
||||
if (c.isError) {
|
||||
await expect(saveBtn).toHaveAttribute('aria-label', new RegExp(`Draft autosave failed: ${c.expectedText}`));
|
||||
} else {
|
||||
await expect(saveBtn).toHaveAttribute('aria-label', `Draft status: ${c.expectedText}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('autosave network failure transitions to error state and shows failure indicator', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await page.route(/\/api\/editor-drafts/, route => route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ detail: 'Database error' }),
|
||||
}));
|
||||
|
||||
await page.keyboard.press('b');
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
|
||||
|
||||
const statusEl = page.locator('#ge-draft-status');
|
||||
await expect(statusEl).toHaveText('Save failed', { timeout: 10_000 });
|
||||
await expect(statusEl).toHaveAttribute('data-state', 'error');
|
||||
|
||||
const saveBtn = page.locator('#ge-save-menu-btn');
|
||||
await expect(saveBtn).toHaveAttribute('aria-label', /Draft autosave failed/);
|
||||
|
||||
const visibleIcons = await page.evaluate(() => {
|
||||
const icons = [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')];
|
||||
return icons.filter(i => !i.hasAttribute('hidden')).map(i => i.className.baseVal || i.className);
|
||||
});
|
||||
expect(visibleIcons).toHaveLength(1);
|
||||
expect(visibleIcons[0]).toContain('ge-save-state-error');
|
||||
});
|
||||
|
||||
test('committed paint refreshes the layer thumbnail and flashes only the edited row', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
const activeId = (await editorState(page)).activeLayerId;
|
||||
@@ -55,8 +145,7 @@ test('filter Escape restores pixels and preserves redo history', async ({ page }
|
||||
await dragOnCanvas(page, { x: 0.7, y: 0.2 }, { x: 0.2, y: 0.7 });
|
||||
await page.keyboard.press('Control+z');
|
||||
const before = await editorState(page);
|
||||
await page.locator('#ge-filter-menu-btn').click();
|
||||
await page.locator('[data-filter-action="blur-gaussian"]').click();
|
||||
await chooseFilterAction(page, 'blur-gaussian');
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.ge-filter-modal')).toHaveCount(0);
|
||||
@@ -75,7 +164,10 @@ test('focused fields own undo, selection and clipboard without changing layers',
|
||||
await input.fill('Editable text');
|
||||
await input.press('Control+Enter');
|
||||
// A tool's ordinary text input must also retain native clipboard ownership.
|
||||
// Frame width lives in the text options' collapsed "More" section.
|
||||
await page.locator('#ge-text-more-toggle').click();
|
||||
await page.locator('#ge-text-frame-width').focus();
|
||||
await expect(page.locator('#ge-text-frame-width')).toBeFocused();
|
||||
const before = await editorState(page);
|
||||
await page.keyboard.press('Control+z');
|
||||
await page.keyboard.press('Control+j');
|
||||
@@ -247,3 +339,76 @@ test('switching away from a held brush ends one stroke and desktop reselect keep
|
||||
await page.keyboard.press('Control+z');
|
||||
expect((await editorState(page)).layers).toEqual(before.layers);
|
||||
});
|
||||
|
||||
test('concurrent edits during in-flight save preserve dirty unsaved status and revision sequence', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await waitForDraft(page);
|
||||
|
||||
let delayedRouteResolve = null;
|
||||
const delayedRoutePromise = new Promise(resolve => { delayedRouteResolve = resolve; });
|
||||
|
||||
let saveCallCount = 0;
|
||||
await page.route(/\/api\/editor-drafts/, async route => {
|
||||
saveCallCount += 1;
|
||||
if (saveCallCount === 1) {
|
||||
await delayedRoutePromise;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ id: 'concurrent-draft-1', name: 'Untitled' }),
|
||||
});
|
||||
});
|
||||
|
||||
// Edit 1
|
||||
await page.keyboard.press('b');
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
|
||||
|
||||
// Wait until save is in flight
|
||||
await expect(page.locator('#ge-draft-status')).toHaveText('Saving…', { timeout: 10_000 });
|
||||
|
||||
// Edit 2 while save 1 is in flight
|
||||
await dragOnCanvas(page, { x: 0.5, y: 0.5 }, { x: 0.7, y: 0.7 });
|
||||
|
||||
const revsDuringFlight = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return {
|
||||
docRev: state.documentRevision,
|
||||
persistingRev: state.persistingRevision,
|
||||
dirty: state.persistDirty,
|
||||
};
|
||||
});
|
||||
expect(revsDuringFlight.docRev).toBeGreaterThan(revsDuringFlight.persistingRev);
|
||||
expect(revsDuringFlight.dirty).toBe(true);
|
||||
|
||||
// Now let save 1 complete
|
||||
delayedRouteResolve();
|
||||
|
||||
// Wait for the chained second save to complete
|
||||
await expect.poll(async () => {
|
||||
return page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.savedRevision >= state.documentRevision && !state.persistDirty;
|
||||
});
|
||||
}, { timeout: 15_000 }).toBe(true);
|
||||
|
||||
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
|
||||
});
|
||||
|
||||
test('failed draft save retains unsaved dirty status and failure indicator', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await waitForDraft(page);
|
||||
|
||||
await page.route(/\/api\/editor-drafts/, route => route.abort('failed'));
|
||||
|
||||
await page.keyboard.press('b');
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
|
||||
|
||||
await expect(page.locator('#ge-draft-status')).toHaveText('Save failed', { timeout: 10_000 });
|
||||
|
||||
const dirtyAfterFailure = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.persistDirty;
|
||||
});
|
||||
expect(dirtyAfterFailure).toBe(true);
|
||||
});
|
||||
|
||||
@@ -3,8 +3,6 @@ const { selectTool } = require('./helpers.js');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden', async ({ page }) => {
|
||||
// Notification polling requires login even in the auth-disabled test server.
|
||||
await page.route('**/api/tasks/notification-logs*', route => route.fulfill({ json: { logs: [] } }));
|
||||
await openBlankEditor(page, { width: 240, height: 160 }, 'Thumbnail selection');
|
||||
const id = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
@@ -32,7 +30,6 @@ test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden
|
||||
test('selected layers align to the canvas and undo as one operation', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Alignment E2E');
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const edit = state.layers.find(layer => layer.name === 'Edit');
|
||||
@@ -111,7 +108,6 @@ test('group rows can be selected with the keyboard', async ({ page }) => {
|
||||
test('Delete removes the selected layer and undo restores it', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard delete E2E');
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||
const addedId = (await editorState(page)).activeLayerId;
|
||||
await page.locator(`.ge-layer-item[data-layer-id="${addedId}"]`).click();
|
||||
await page.keyboard.press('Backspace');
|
||||
@@ -193,7 +189,6 @@ test('multi-selected layers move together and support history-backed bulk action
|
||||
|
||||
await editRow.click();
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||
const addedId = (await editorState(page)).activeLayerId;
|
||||
await editRow.click({ modifiers: ['Control'] });
|
||||
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
|
||||
@@ -278,7 +273,6 @@ test('nested groups preserve hierarchy, ancestor locks, collapse, and server reo
|
||||
const innerId = current.groups[0].id;
|
||||
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||
current = await editorState(page);
|
||||
const looseId = current.activeLayerId;
|
||||
await page.locator(`.ge-layer-group-row[data-group-id="${innerId}"]`).click();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
|
||||
const { chooseFilterAction, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
|
||||
|
||||
async function seedVisibleLayers(page) {
|
||||
await page.evaluate(async () => {
|
||||
@@ -18,14 +18,13 @@ test('Merge all preserves retained effects and adjustment output', async ({ page
|
||||
await openBlankEditor(page, { width: 320, height: 220 }, 'Merge fidelity E2E');
|
||||
await seedVisibleLayers(page);
|
||||
|
||||
await page.locator('#ge-filter-menu-btn').click();
|
||||
await page.locator('[data-filter-action="effect-color-overlay"]').click();
|
||||
await chooseFilterAction(page, 'effect-color-overlay');
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[type="color"]').fill('#f0c04a');
|
||||
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('#ge-add-fx').click();
|
||||
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
|
||||
await expect(page.locator('.ge-adj-popup')).toBeVisible();
|
||||
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('45');
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor, selectTool } = require('./helpers.js');
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
|
||||
test('minimizing layers restores fit view and keeps an explicit zoom', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
const canvas = page.locator('.ge-main-canvas');
|
||||
const zoom = () => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoom);
|
||||
await page.locator('.ge-layers-title').click();
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
|
||||
await selectTool(page, 'eraser');
|
||||
await selectTool(page, 'eraser');
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
|
||||
await expect.poll(async () => (await canvas.boundingBox()).width).toBeGreaterThan(200);
|
||||
|
||||
await page.locator('.ge-layers-grab').click();
|
||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
|
||||
await page.locator('#ge-zoom-in').evaluate(button => button.click());
|
||||
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoomViewMode)).toBe('scale');
|
||||
const manualZoom = await zoom();
|
||||
await page.locator('.ge-layers-title').click();
|
||||
await selectTool(page, 'eraser');
|
||||
await selectTool(page, 'eraser');
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
|
||||
await expect.poll(zoom).toBe(manualZoom);
|
||||
});
|
||||
|
||||
test('minimizing layers keeps a pinch zoom', async ({ page, context, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP multi-touch injection');
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
const state = () => page.evaluate(async () => {
|
||||
const { state: editor } = await import('/static/js/editor/state.js');
|
||||
return { zoom: editor.zoom, mode: editor.zoomViewMode };
|
||||
});
|
||||
const fitted = await state();
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
const cx = box.x + box.width / 2;
|
||||
const cy = box.y + box.height / 2;
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const fingers = spread => [{ x: cx - spread, y: cy, id: 1 }, { x: cx + spread, y: cy, id: 2 }];
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: fingers(20) });
|
||||
for (const spread of [30, 40, 50]) {
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: fingers(spread) });
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
const pinched = await state();
|
||||
expect(pinched.mode).toBe('scale');
|
||||
expect(pinched.zoom).toBeGreaterThan(fitted.zoom * 2);
|
||||
|
||||
await page.locator('.ge-layers-title').click();
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
|
||||
await selectTool(page, 'eraser');
|
||||
await selectTool(page, 'eraser');
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
|
||||
await expect.poll(async () => (await state()).zoom).toBe(pinched.zoom);
|
||||
});
|
||||
@@ -121,6 +121,10 @@ test('mobile touch mask editing supports undo and persists through reload', asyn
|
||||
const painted = await editorState(page);
|
||||
const paintedLayer = painted.layers.find(layer => layer.masks.length);
|
||||
expect(paintedLayer.masks[0].pixelHash).not.toBe(before);
|
||||
// History controls live in the layers header, which is hidden while the
|
||||
// sheet is minimized for painting. Reopen it as a phone user would.
|
||||
await page.locator('.ge-layers-grab').click();
|
||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
|
||||
.toBe(before);
|
||||
@@ -207,6 +211,8 @@ test('mobile touch moves an unlinked layer mask without moving its layer', async
|
||||
expect(movedLayer.offset).toEqual(beforeLayer.offset);
|
||||
expect(movedLayer.masks[0].offset.x).not.toBe(beforeLayer.masks[0].offset.x);
|
||||
expect(movedLayer.masks[0].offset.y).not.toBe(beforeLayer.masks[0].offset.y);
|
||||
await page.locator('.ge-layers-grab').click();
|
||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.id === layerId).masks[0].offset)
|
||||
.toEqual(beforeLayer.masks[0].offset);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const path = require('node:path');
|
||||
|
||||
test('static editor modules survive a cache upgrade and offline reopen', async ({ browser }, testInfo) => {
|
||||
const context = await browser.newContext({
|
||||
baseURL: testInfo.project.use.baseURL,
|
||||
storageState: path.join(process.env.PHOTO_EDITOR_E2E_DATA_DIR, 'browser-auth.json'),
|
||||
serviceWorkers: 'allow',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const pickerUrl = '/static/js/colorPicker.js?v=20260910eyedropper1';
|
||||
const cssUrl = '/static/css/13-image-editor.css?v=20260929csssplit';
|
||||
const editorUrl = '/static/js/galleryEditor.js?v=20260909movepicklayer1';
|
||||
try {
|
||||
// The app's worker intentionally controls /static/, not the SPA root.
|
||||
await page.goto('/static/js/panels.js?v=20260909movepicklayer1');
|
||||
const installed = await page.evaluate(async ({ pickerUrl, cssUrl }) => {
|
||||
const previous = await caches.open('odysseus-v1113-pr40-editor-precache');
|
||||
await previous.put(pickerUrl, new Response('stale picker'));
|
||||
await previous.put(cssUrl, new Response('stale css'));
|
||||
const registration = await navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1');
|
||||
await navigator.serviceWorker.ready;
|
||||
if (!navigator.serviceWorker.controller) {
|
||||
await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
|
||||
}
|
||||
return { scope: new URL(registration.scope).pathname, root: !!(await navigator.serviceWorker.getRegistration('/')) };
|
||||
}, { pickerUrl, cssUrl });
|
||||
expect(installed).toEqual({ scope: '/static/', root: false });
|
||||
await expect.poll(() => page.evaluate(async () => (await caches.keys()).includes('odysseus-v1113-pr40-editor-precache'))).toBe(false);
|
||||
for (const url of [pickerUrl, cssUrl, editorUrl, '/static/js/platform.js', '/static/js/modelSort.js']) {
|
||||
expect(await page.evaluate(async value => !!(await caches.match(value)), url)).toBe(true);
|
||||
}
|
||||
|
||||
// Simulate stale entries in the active version, then verify network-first
|
||||
// refresh updates both JS and CSS before testing the offline fallback.
|
||||
const currentCache = await page.evaluate(async () => (await caches.keys()).find(name => name.startsWith('odysseus-')));
|
||||
expect(currentCache).toMatch(/^odysseus-v\d+-pr40-editor-precache$/);
|
||||
expect(currentCache).not.toBe('odysseus-v1113-pr40-editor-precache');
|
||||
await page.evaluate(async ({ currentCache, pickerUrl, cssUrl }) => {
|
||||
const cache = await caches.open(currentCache);
|
||||
await cache.put(pickerUrl, new Response('stale picker'));
|
||||
await cache.put(cssUrl, new Response('stale css'));
|
||||
for (const url of [pickerUrl, cssUrl]) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Network refresh failed: ${url} ${response.status}`);
|
||||
}
|
||||
}, { currentCache, pickerUrl, cssUrl });
|
||||
for (const url of [pickerUrl, cssUrl]) {
|
||||
await expect.poll(() => page.evaluate(async value => {
|
||||
const response = await caches.match(value);
|
||||
return response ? !(await response.text()).startsWith('stale ') : false;
|
||||
}, url)).toBe(true);
|
||||
}
|
||||
|
||||
// Origin is the only Vary header the offline fallback may ignore. An entry
|
||||
// that varies on anything else (as a per-user response would) must miss.
|
||||
const varyProbeUrl = '/static/js/e2e-vary-probe.js';
|
||||
await page.evaluate(async ({ currentCache, varyProbeUrl }) => {
|
||||
const cache = await caches.open(currentCache);
|
||||
await cache.put(
|
||||
new Request(varyProbeUrl, { headers: { 'X-Probe-Owner': 'another-user' } }),
|
||||
new Response('export default "private";', { headers: { 'Content-Type': 'text/javascript', Vary: 'Origin, X-Probe-Owner' } }),
|
||||
);
|
||||
}, { currentCache, varyProbeUrl });
|
||||
|
||||
await context.setOffline(true);
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const offline = await context.newPage();
|
||||
await offline.goto('/static/js/panels.js?v=20260909movepicklayer1');
|
||||
expect(await offline.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
|
||||
const result = await offline.evaluate(async ({ pickerUrl, editorUrl, cssUrl, varyProbeUrl }) => {
|
||||
const editor = await import(editorUrl);
|
||||
const picker = await import(pickerUrl);
|
||||
const css = await fetch(cssUrl);
|
||||
const privateProbe = await import(varyProbeUrl).then(() => 'served', () => 'rejected');
|
||||
return {
|
||||
editor: typeof editor.openEditor,
|
||||
picker: ['getRecentColors', 'recordRecentColor', 'suggestColorsFor'].every(key => typeof picker[key] === 'function'),
|
||||
cssOk: css.ok,
|
||||
privateProbe,
|
||||
};
|
||||
}, { pickerUrl, editorUrl, cssUrl, varyProbeUrl });
|
||||
expect(result).toEqual({ editor: 'function', picker: true, cssOk: true, privateProbe: 'rejected' });
|
||||
await offline.close();
|
||||
}
|
||||
} finally { await context.close(); }
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor } = require('./helpers.js');
|
||||
|
||||
test('channel and path panels display hostile layer metadata as text', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 64, height: 64 });
|
||||
const result = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const { createColorPanels } = await import('/static/js/editor/panels/panels.js');
|
||||
const { createStudioPanels } = await import('/static/js/editor/panels/studio-panels.js');
|
||||
const attack = '<img src=x onerror="window.__panelXss=true">';
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
layer.name = attack;
|
||||
layer.kind = 'shape';
|
||||
layer.shape = { type: attack };
|
||||
const colors = createColorPanels({ state });
|
||||
const studio = createStudioPanels({ state });
|
||||
document.body.append(colors.channels, studio.paths);
|
||||
colors.refresh();
|
||||
studio.refresh();
|
||||
return {
|
||||
attack,
|
||||
channel: colors.channels.querySelector('.ge-channel-name').textContent,
|
||||
path: studio.paths.querySelector('.ge-channel-name').textContent,
|
||||
images: colors.channels.querySelectorAll('img').length + studio.paths.querySelectorAll('img').length,
|
||||
executed: !!window.__panelXss,
|
||||
};
|
||||
});
|
||||
expect(result.channel).toBe(result.attack);
|
||||
expect(result.path).toBe(`${result.attack} · ${result.attack}`);
|
||||
expect(result.images).toBe(0);
|
||||
expect(result.executed).toBe(false);
|
||||
});
|
||||
|
||||
test('unknown effect types cannot inject markup into the layer panel', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 64, height: 64 });
|
||||
const attack = '<img src=x onerror="window.__effectXss=true">';
|
||||
const id = await page.evaluate(async value => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
layer.effects = [{ id: 'effect-test', type: value, visible: true, params: {} }];
|
||||
return layer.id;
|
||||
}, attack);
|
||||
await page.locator(`.ge-layer-item[data-layer-id="${id}"] .ge-layer-vis`).first().click();
|
||||
const list = page.locator('#ge-layers-list');
|
||||
await expect(list).toContainText('Gaussian Blur');
|
||||
await expect(list).not.toContainText(attack);
|
||||
await expect(list.locator('img')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => !!window.__effectXss)).toBe(false);
|
||||
});
|
||||
|
||||
test('saved brush preset names remain literal text in the flyout', async ({ page }) => {
|
||||
const attack = '<img src=x onerror="window.__brushXss=true">';
|
||||
await page.addInitScript(value => {
|
||||
localStorage.setItem('odysseus.editor.brush-presets.v1', JSON.stringify([
|
||||
{ id: 'hostile-preset', name: value, size: 8, opacity: 100, flow: 100, softness: 20 },
|
||||
]));
|
||||
}, attack);
|
||||
await openBlankEditor(page, { width: 64, height: 64 });
|
||||
const preset = page.locator('.ge-tool-flyout-preset[data-brush-preset="hostile-preset"]');
|
||||
await expect(preset.locator('.ge-tool-flyout-label')).toHaveText(attack);
|
||||
await expect(preset.locator('img')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => !!window.__brushXss)).toBe(false);
|
||||
});
|
||||
|
||||
test('imported adjustment parameters cannot inject markup into the adjustment popup', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 64, height: 64 });
|
||||
const attack = '"><img src=x onerror="window.__adjXss=true">';
|
||||
const cases = [
|
||||
{ type: 'color-lookup', params: { cube: { size: attack, data: [0, 0, 0] }, strength: attack }, text: '.ge-adj-lut-name', expected: 'Loaded LUT · 0³' },
|
||||
{ type: 'gradient-map', params: { shadows: attack, highlights: attack } },
|
||||
{ type: 'levels', params: { inBlack: attack, outWhite: attack } },
|
||||
];
|
||||
for (const item of cases) {
|
||||
await page.locator('#ge-add-fx').click();
|
||||
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${item.type}"]`).click();
|
||||
await page.locator('.ge-adj-popup [data-adj-action="ok"]').click();
|
||||
await expect(page.locator('.ge-adj-popup')).toHaveCount(0);
|
||||
// Project files and drafts reach the editor through the codec's
|
||||
// normalizer, which validates the type but not every parameter.
|
||||
await page.evaluate(async ({ type, params }) => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const { normalizeAdjustmentData } = await import('/static/js/editor/adjustment-layer.js');
|
||||
const layer = state.layers.find(entry => entry.id === state.activeLayerId);
|
||||
layer.adjustment = normalizeAdjustmentData({ type, params });
|
||||
}, item);
|
||||
await page.locator('.ge-layer-controls:visible .ge-layer-fx-btn').click();
|
||||
const popup = page.locator('.ge-adj-popup');
|
||||
await expect(popup).toBeVisible();
|
||||
await expect(popup.locator('img')).toHaveCount(0);
|
||||
if (item.text) await expect(popup.locator(item.text)).toHaveText(item.expected);
|
||||
expect(await popup.evaluate(node => node.innerHTML.includes('onerror'))).toBe(false);
|
||||
await popup.locator('[data-adj-action="cancel"]').click();
|
||||
}
|
||||
expect(await page.evaluate(() => !!window.__adjXss)).toBe(false);
|
||||
});
|
||||
@@ -38,3 +38,45 @@ test('mobile footer docks flush between the canvas and the layers sheet', async
|
||||
expect(g.footerLeft).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(g.footerRight - 390)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('phone footer controls for zoom, setup, and AI settings are accessible via normal clicks', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile footer controls');
|
||||
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
|
||||
|
||||
const footer = page.locator('.ge-editor-footer');
|
||||
await expect(footer).toBeVisible();
|
||||
|
||||
const setupBtn = page.locator('#ge-setup-btn');
|
||||
const aiBtn = page.locator('#ge-ai-settings-btn');
|
||||
const zoomIn = page.locator('#ge-zoom-in');
|
||||
const zoomOut = page.locator('#ge-zoom-out');
|
||||
const zoomFit = page.locator('#ge-zoom-100');
|
||||
|
||||
await expect(setupBtn).toBeVisible();
|
||||
await expect(aiBtn).toBeVisible();
|
||||
await expect(zoomIn).toBeVisible();
|
||||
await expect(zoomOut).toBeVisible();
|
||||
await expect(zoomFit).toBeVisible();
|
||||
|
||||
// Test zoom controls via normal clicks
|
||||
const zoomLabel = page.locator('.ge-zoom-label');
|
||||
const initialZoom = await zoomLabel.textContent();
|
||||
await zoomIn.click();
|
||||
await expect(zoomLabel).not.toHaveText(initialZoom);
|
||||
await zoomFit.click();
|
||||
await expect(zoomLabel).toHaveText(/%/);
|
||||
|
||||
// Test Setup modal accessibility via normal click
|
||||
await setupBtn.click();
|
||||
const preflight = page.locator('.ge-preflight');
|
||||
await expect(preflight).toBeVisible();
|
||||
await page.locator('.ge-preflight-start').click();
|
||||
await expect(preflight).toBeHidden();
|
||||
|
||||
// Test AI Settings modal accessibility via normal click
|
||||
await aiBtn.click();
|
||||
const aiSettings = page.locator('.ge-ai-settings');
|
||||
await expect(aiSettings).toBeVisible();
|
||||
await page.locator('.ge-ai-settings-done').click();
|
||||
await expect(aiSettings).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor } = require('./helpers.js');
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
|
||||
test('setup checklist remains usable above the mobile layers sheet', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('#ge-setup-btn').click();
|
||||
const checklist = page.locator('.ge-preflight');
|
||||
const start = checklist.locator('[data-preflight-start]');
|
||||
await expect(checklist).toBeVisible();
|
||||
await start.scrollIntoViewIfNeeded();
|
||||
await expect(start).toHaveCSS('white-space', 'nowrap');
|
||||
expect(await start.evaluate(button => button.scrollWidth <= button.clientWidth)).toBe(true);
|
||||
await start.click();
|
||||
await expect(checklist).toBeHidden();
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
const { openBlankEditor, editorState } = require('./helpers.js');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, selectTool } = require('./helpers.js');
|
||||
|
||||
test('paint tools offer rasterization, cancel preserves text, Enter accepts and undo restores it', async ({ page }) => {
|
||||
test('paint tools require confirmation before rasterizing text layers, preserving text on cancel', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await selectTool(page, 'text');
|
||||
const canvas = await page.locator('.ge-main-canvas').boundingBox();
|
||||
@@ -11,19 +10,39 @@ test('paint tools offer rasterization, cancel preserves text, Enter accepts and
|
||||
await text.fill('Keep editable');
|
||||
await text.press('Control+Enter');
|
||||
const retained = (await editorState(page)).layers.find(layer => layer.kind === 'text');
|
||||
expect(retained).toBeDefined();
|
||||
const kind = async () => (await editorState(page)).layers.find(layer => layer.id === retained.id)?.kind;
|
||||
|
||||
// Tool selection alone does not prompt and does not rasterize
|
||||
for (const tool of ['brush', 'eraser']) {
|
||||
await selectTool(page, tool);
|
||||
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
||||
expect(await kind()).toBe('text');
|
||||
}
|
||||
|
||||
// Attempting to draw triggers confirmation
|
||||
await selectTool(page, 'brush');
|
||||
await expect(page.locator('#styled-confirm-ok')).toHaveText('Rasterize');
|
||||
await page.keyboard.press('Escape');
|
||||
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.25, y: 0.2 });
|
||||
await expect(page.locator('#styled-confirm-overlay')).toBeVisible();
|
||||
|
||||
// Cancelling keeps text editable with no changes
|
||||
await page.locator('#styled-confirm-cancel').click();
|
||||
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
||||
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
|
||||
await selectTool(page, 'brush');
|
||||
await expect(page.locator('#styled-confirm-ok')).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
expect(await kind()).toBe('text');
|
||||
|
||||
// Second stroke prompt confirmed -> rasterizes layer
|
||||
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.25, y: 0.2 });
|
||||
await expect(page.locator('#styled-confirm-overlay')).toBeVisible();
|
||||
await page.locator('#styled-confirm-ok').click();
|
||||
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
||||
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('raster');
|
||||
expect(await kind()).toBe('raster');
|
||||
|
||||
// Subsequent strokes on the raster layer draw immediately without prompting
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.35, y: 0.3 });
|
||||
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
||||
|
||||
// Undo restores editable text
|
||||
await page.keyboard.press('Control+z');
|
||||
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
|
||||
await selectTool(page, 'eraser');
|
||||
await expect(page.locator('#styled-confirm-ok')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.press('Control+z');
|
||||
expect(await kind()).toBe('text');
|
||||
});
|
||||
|
||||
@@ -106,12 +106,15 @@ test('transform selection moves, scales, rotates, cancels, and preserves layer p
|
||||
await page.waitForTimeout(250);
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('[data-selection-action="transform"]').click();
|
||||
await expect(page.locator('#ge-transform-section')).toBeVisible();
|
||||
const mobileSection = await page.locator('#ge-transform-section').boundingBox();
|
||||
expect(mobileSection.x).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileSection.y).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileSection.x + mobileSection.width).toBeLessThanOrEqual(390);
|
||||
expect(mobileSection.y + mobileSection.height).toBeLessThanOrEqual(844);
|
||||
await page.locator('#ge-transform-cancel-btn').click();
|
||||
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.transformActive)).toBe(false);
|
||||
expect((await editorState(page)).selection.pixelHash).toBe(original.selection.pixelHash);
|
||||
});
|
||||
|
||||
test('named selections support reselect, load, delete, and server-draft reopen', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor, selectTool } = require('./helpers.js');
|
||||
|
||||
// Editors opened wider than 700px move the tool controls into the options
|
||||
// strip. At tablet widths (701-820px) that strip must stay visible, or every
|
||||
// tool control is stranded in a hidden container.
|
||||
for (const width of [768, 820]) {
|
||||
test(`tool controls stay reachable at a ${width}px tablet width`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 1024 });
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, `Tablet ${width}`);
|
||||
const inViewport = locator => locator.evaluate(el => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return box.width > 0 && box.left >= 0 && box.right <= innerWidth + 1 && box.bottom <= innerHeight + 1;
|
||||
});
|
||||
|
||||
await selectTool(page, 'brush');
|
||||
await expect(page.locator('#ge-options-bar')).toBeVisible();
|
||||
await expect(page.locator('#ge-options-bar .ge-size-slider')).toBeVisible();
|
||||
expect(await inViewport(page.locator('#ge-options-bar .ge-size-slider'))).toBe(true);
|
||||
|
||||
await selectTool(page, 'transform');
|
||||
await expect(page.locator('#ge-transform-w')).toBeVisible();
|
||||
await page.locator('#ge-transform-w').fill('200');
|
||||
await page.locator('#ge-transform-w').dispatchEvent('input');
|
||||
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.transformPendingW)).toBe(200);
|
||||
await page.locator('#ge-transform-cancel-btn').click();
|
||||
});
|
||||
}
|
||||
|
||||
test('responsive transition across 700px breakpoint preserves dock/sheet layout and state', async ({ page }) => {
|
||||
// Desktop -> Phone transition
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Responsive transitions');
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/ge-right-panel--dock/);
|
||||
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeVisible();
|
||||
|
||||
// Resize across 700px to phone
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/ge-right-panel--dock/);
|
||||
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeHidden();
|
||||
|
||||
// Resize back to desktop
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/ge-right-panel--dock/);
|
||||
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeVisible();
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, openOptionsMore, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
test('text edits directly on canvas and remains retained after reopen', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await selectTool(page, 'text');
|
||||
await openOptionsMore(page, 'text');
|
||||
await page.locator('#ge-text-frame-width').fill('260');
|
||||
const canvas = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.click(canvas.x + canvas.width * 0.2, canvas.y + canvas.height * 0.2);
|
||||
@@ -14,11 +15,13 @@ test('text edits directly on canvas and remains retained after reopen', async ({
|
||||
await editor.press('Control+Enter');
|
||||
await expect(editor).toHaveCount(0);
|
||||
|
||||
await openOptionsMore(page, 'text');
|
||||
await page.locator('#ge-text-letter-spacing').fill('3.5');
|
||||
await page.locator('#ge-text-line-height').fill('1.4');
|
||||
await page.locator('#ge-text-font').selectOption('Georgia');
|
||||
await page.locator('.ge-text-auto-width-option').click();
|
||||
await expect(page.locator('#ge-text-auto-width')).toBeChecked();
|
||||
await openOptionsMore(page, 'text');
|
||||
await expect(page.locator('#ge-text-frame-width')).toBeDisabled();
|
||||
await page.locator('#ge-text-frame-height').fill('180');
|
||||
await page.locator('#ge-text-vertical-align').selectOption('bottom');
|
||||
@@ -47,8 +50,13 @@ test('text paragraph controls remain usable in a narrow phone viewport', async (
|
||||
const section = page.locator('#ge-text-section');
|
||||
await expect(section).toBeVisible();
|
||||
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
|
||||
await openOptionsMore(page, 'text');
|
||||
await expect(page.locator('#ge-text-frame-height')).toBeVisible();
|
||||
await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
|
||||
expect(await page.locator('#ge-text-more').evaluate(el => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return box.left >= 0 && box.right <= innerWidth + 1;
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => {
|
||||
@@ -71,6 +79,7 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
|
||||
let current = await editorState(page);
|
||||
const shapes = current.layers.filter(layer => layer.kind === 'shape');
|
||||
expect(shapes.map(layer => layer.shape.type)).toEqual(types);
|
||||
await openOptionsMore(page, 'shape');
|
||||
await page.locator('#ge-shape-sides').fill('7');
|
||||
await page.locator('#ge-shape-stroke-width').fill('6');
|
||||
await page.locator('#ge-shape-radius').fill('14');
|
||||
@@ -96,6 +105,7 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
|
||||
test('gradient shape fill remains editable after reopening the project', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await selectTool(page, 'shape');
|
||||
await openOptionsMore(page, 'shape');
|
||||
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeHidden();
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.65, y: 0.55 });
|
||||
await page.locator('#ge-shape-gradient-angle').evaluate((angle) => {
|
||||
@@ -110,6 +120,7 @@ test('gradient shape fill remains editable after reopening the project', async (
|
||||
angle.value = '35';
|
||||
angle.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await openOptionsMore(page, 'shape');
|
||||
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible();
|
||||
|
||||
const before = await editorState(page);
|
||||
@@ -143,6 +154,7 @@ test('shape gradients retain added stops through editing and reopen', async ({ p
|
||||
await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E');
|
||||
await selectTool(page, 'shape');
|
||||
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
|
||||
await openOptionsMore(page, 'shape');
|
||||
await page.locator('#ge-shape-fill-type').selectOption('linear-gradient');
|
||||
await page.evaluate(() => {
|
||||
for (const [id, value] of [['ge-shape-gradient-start', '#ff0000'], ['ge-shape-gradient-end', '#0000ff']]) {
|
||||
|
||||
@@ -223,15 +223,23 @@ test('mobile touch input can grab every transform handle', async ({ browser, bro
|
||||
const handlePoint = id => page.evaluate(async handleId => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
|
||||
const geometry = transformFrameGeometry({
|
||||
const frame = {
|
||||
centerX: state.transformCenter.x,
|
||||
centerY: state.transformCenter.y,
|
||||
width: state.transformPendingW,
|
||||
height: state.transformPendingH,
|
||||
rotation: state.transformPendingRot,
|
||||
}, { zoom: state.zoom, rotationInside: handleId === 'rot' });
|
||||
const handle = geometry.handles.find(item => item.id === handleId);
|
||||
};
|
||||
const rect = state.mainCanvas.getBoundingClientRect();
|
||||
const outside = transformFrameGeometry(frame, { zoom: state.zoom }).rotationHandle;
|
||||
const x = rect.left + outside.x * rect.width / state.mainCanvas.width;
|
||||
const y = rect.top + outside.y * rect.height / state.mainCanvas.height;
|
||||
const area = state.container.querySelector('.ge-canvas-area').getBoundingClientRect();
|
||||
const popup = state.transformPopup?.getBoundingClientRect();
|
||||
const rotationInside = y < area.top + 6 || x < area.left + 6 || x > area.right - 6 ||
|
||||
!!(popup && x >= popup.left && x <= popup.right && y >= popup.top && y <= popup.bottom);
|
||||
const geometry = transformFrameGeometry(frame, { zoom: state.zoom, rotationInside });
|
||||
const handle = geometry.handles.find(item => item.id === handleId);
|
||||
return {
|
||||
x: rect.left + handle.x * rect.width / state.mainCanvas.width,
|
||||
y: rect.top + handle.y * rect.height / state.mainCanvas.height,
|
||||
@@ -366,3 +374,42 @@ test('transform handle keeps tracking when the pointer crosses the right panel',
|
||||
});
|
||||
expect(after).toBeGreaterThan(setup.width);
|
||||
});
|
||||
|
||||
test('phone transform overlay follows the canvas through refits and zoom', async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Phone transform overlay');
|
||||
const fitBefore = await page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoom);
|
||||
await selectTool(page, 'transform');
|
||||
await expect(page.locator('#ge-transform-section')).toBeVisible();
|
||||
const alignment = () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const overlay = state.transformOverlay.getBoundingClientRect();
|
||||
const canvas = state.mainCanvas.getBoundingClientRect();
|
||||
return {
|
||||
zoom: state.zoom,
|
||||
overlayScale: overlay.width / state.transformOverlay.width,
|
||||
covers: overlay.left <= canvas.left + 1 && overlay.top <= canvas.top + 1
|
||||
&& overlay.right >= canvas.right - 1 && overlay.bottom >= canvas.bottom - 1,
|
||||
};
|
||||
});
|
||||
// Selecting Transform collapses the Layers sheet, which refits the canvas.
|
||||
await expect.poll(async () => (await alignment()).zoom).not.toBe(fitBefore);
|
||||
let current = await alignment();
|
||||
expect(Math.abs(current.overlayScale - current.zoom)).toBeLessThan(0.002);
|
||||
expect(current.covers).toBe(true);
|
||||
|
||||
await page.locator('#ge-zoom-in').evaluate(button => button.click());
|
||||
await expect.poll(async () => (await alignment()).zoom).toBeGreaterThan(current.zoom);
|
||||
current = await alignment();
|
||||
expect(Math.abs(current.overlayScale - current.zoom)).toBeLessThan(0.002);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,10 +10,12 @@ const defaultPython = process.platform === 'win32'
|
||||
? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe')
|
||||
: path.join(repositoryRoot, '.venv', 'bin', 'python');
|
||||
const python = process.env.ODYSSEUS_TEST_PYTHON || (fs.existsSync(defaultPython) ? defaultPython : 'python');
|
||||
const tmpBase = fs.existsSync('/var/tmp') ? '/var/tmp' : os.tmpdir();
|
||||
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR
|
||||
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-photo-editor-e2e-'));
|
||||
|| fs.mkdtempSync(path.join(tmpBase, 'odysseus-photo-editor-e2e-'));
|
||||
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH
|
||||
|| path.join(dataDirectory, 'app.db');
|
||||
const authenticated = process.env.PHOTO_EDITOR_E2E_AUTH !== '0';
|
||||
process.env.PHOTO_EDITOR_E2E_DB_PATH = databasePath;
|
||||
process.env.PHOTO_EDITOR_E2E_DATA_DIR = dataDirectory;
|
||||
|
||||
@@ -22,6 +24,9 @@ if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
|
||||
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
|
||||
}
|
||||
|
||||
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
|
||||
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: path.join(__dirname, 'photo-editor'),
|
||||
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
|
||||
@@ -29,18 +34,24 @@ module.exports = defineConfig({
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
|
||||
reporter: isCI ? 'line' : 'line',
|
||||
globalSetup: require.resolve('./setup.js'),
|
||||
globalTeardown: require.resolve('./teardown.js'),
|
||||
use: {
|
||||
baseURL,
|
||||
browserName,
|
||||
headless: true,
|
||||
launchOptions: {
|
||||
args: ['--disable-dev-shm-usage', '--no-sandbox'],
|
||||
},
|
||||
viewport: { width: 1440, height: 960 },
|
||||
extraHTTPHeaders: { 'Accept-Encoding': 'identity' },
|
||||
serviceWorkers: 'block',
|
||||
...(authenticated ? { storageState: path.join(dataDirectory, 'browser-auth.json') } : {}),
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
// Unconditional CI trace suppression: Playwright traces are NEVER recorded in CI.
|
||||
// Local developer tracing requires explicit PLAYWRIGHT_ENABLE_TRACE=1.
|
||||
trace: traceOptIn ? 'retain-on-failure' : 'off',
|
||||
},
|
||||
webServer: {
|
||||
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
|
||||
@@ -50,7 +61,7 @@ module.exports = defineConfig({
|
||||
reuseExistingServer: false,
|
||||
env: {
|
||||
...process.env,
|
||||
AUTH_ENABLED: 'false',
|
||||
AUTH_ENABLED: authenticated ? 'true' : 'false',
|
||||
DATABASE_URL: `sqlite:///${databasePath}`,
|
||||
ODYSSEUS_DATA_DIR: dataDirectory,
|
||||
ODYSSEUS_STARTUP_WARMUPS: '0',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { request } = require('@playwright/test');
|
||||
|
||||
module.exports = async () => {
|
||||
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
|
||||
@@ -7,4 +9,18 @@ module.exports = async () => {
|
||||
throw new Error('Photo editor E2E data paths were not configured');
|
||||
}
|
||||
fs.mkdirSync(dataDirectory, { recursive: true });
|
||||
if (process.env.PHOTO_EDITOR_E2E_AUTH !== '0') {
|
||||
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
|
||||
const client = await request.newContext({ baseURL: `http://127.0.0.1:${port}` });
|
||||
try {
|
||||
const credentials = { username: 'editor-e2e', password: 'disposable-editor-e2e-password' };
|
||||
const setup = await client.post('/api/auth/setup', { data: credentials });
|
||||
if (!setup.ok()) throw new Error(`Disposable editor account setup failed: ${setup.status()}`);
|
||||
const login = await client.post('/api/auth/login', { data: credentials });
|
||||
if (!login.ok()) throw new Error(`Disposable editor account login failed: ${login.status()}`);
|
||||
await client.storageState({ path: path.join(dataDirectory, 'browser-auth.json') });
|
||||
} finally {
|
||||
await client.dispose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+81
-2
@@ -1,7 +1,86 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execSync } = require('node:child_process');
|
||||
|
||||
module.exports = async () => {
|
||||
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
|
||||
if (!dataDirectory) return;
|
||||
fs.rmSync(dataDirectory, { recursive: true, force: true });
|
||||
if (dataDirectory) {
|
||||
try {
|
||||
fs.rmSync(dataDirectory, { recursive: true, force: true });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Secure default tracing: never publish trace archives in CI or without explicit opt-in.
|
||||
// When traces are not opted-in, purge all archives unconditionally.
|
||||
// When local opt-in tracing is active, run the strict fail-closed structured sanitizer.
|
||||
const resultsDir = path.join(__dirname, '..', '..', 'test-results');
|
||||
const reportDir = path.join(__dirname, '..', '..', 'playwright-report');
|
||||
if (fs.existsSync(reportDir)) {
|
||||
try {
|
||||
fs.rmSync(reportDir, { recursive: true, force: true });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (fs.existsSync(resultsDir)) {
|
||||
const purgeSensitiveArtifacts = (dir) => {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
purgeSensitiveArtifacts(fullPath);
|
||||
} else if (entry.isFile() && (
|
||||
entry.name.endsWith('.zip') ||
|
||||
entry.name.endsWith('.tmp') ||
|
||||
entry.name.endsWith('.png') ||
|
||||
entry.name.endsWith('.har')
|
||||
)) {
|
||||
try {
|
||||
fs.rmSync(fullPath, { force: true });
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const purgeZipAndTmp = (dir) => {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
purgeZipAndTmp(fullPath);
|
||||
} else if (entry.isFile() && (entry.name.endsWith('.zip') || entry.name.endsWith('.tmp'))) {
|
||||
try {
|
||||
fs.rmSync(fullPath, { force: true });
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
|
||||
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
|
||||
|
||||
if (isCI || !traceOptIn) {
|
||||
// In CI or default local runs, purge all sensitive trace archives and artifacts
|
||||
purgeSensitiveArtifacts(resultsDir);
|
||||
} else {
|
||||
const python = process.env.ODYSSEUS_TEST_PYTHON || 'python';
|
||||
const sanitizerScript = path.join(__dirname, '..', '..', 'scripts', 'sanitize_trace_artifacts.py');
|
||||
let sanitizerSucceeded = false;
|
||||
try {
|
||||
execSync(`${JSON.stringify(python)} ${JSON.stringify(sanitizerScript)} ${JSON.stringify(resultsDir)}`, {
|
||||
stdio: 'ignore',
|
||||
timeout: 30_000,
|
||||
});
|
||||
sanitizerSucceeded = true;
|
||||
} catch (_) {
|
||||
sanitizerSucceeded = false;
|
||||
}
|
||||
|
||||
if (!sanitizerSucceeded) {
|
||||
// Fail closed: purge all remaining zip or tmp archives
|
||||
purgeZipAndTmp(resultsDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1043,8 +1043,8 @@ def test_editor_agent_model_override_is_first_candidate(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(eam, "local_bases", lambda: ["http://local1"])
|
||||
monkeypatch.setattr(eam, "local_model", lambda: "LMODEL")
|
||||
cands = eam._candidates(None, "http://ovr::OVERRIDE")
|
||||
assert cands[0] == {"base": "http://ovr", "model": "OVERRIDE", "headers": None,
|
||||
cands = eam._candidates(None, "http://local1::OVERRIDE")
|
||||
assert cands[0] == {"base": "http://local1", "model": "OVERRIDE", "headers": None,
|
||||
"source": "override"}
|
||||
assert any(c["source"] == "local" and c["base"] == "http://local1" for c in cands)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
@@ -27,10 +28,22 @@ def test_action_names_and_unknown_steps_are_literal_text():
|
||||
const panel = createActionsPanel();
|
||||
document.body.append(panel.element);
|
||||
panel.setSteps([attack]);
|
||||
const input = panel.element.querySelector('.ge-actions-name');
|
||||
input.value = attack + '<svg onload="window.__actionsXss=true">';
|
||||
input.parentElement.querySelector('button').click();
|
||||
const savedName = input.value;
|
||||
const savedButton = Array.from(panel.element.querySelectorAll('.ge-actions-saved-row button'))
|
||||
.find(button => button.textContent === savedName);
|
||||
savedButton.click();
|
||||
return {
|
||||
images: panel.element.querySelectorAll('img').length,
|
||||
svgs: panel.element.querySelectorAll('svg').length,
|
||||
saved: panel.element.querySelector('.ge-actions-saved-row button').textContent,
|
||||
step: panel.element.querySelector('.ge-actions-step-label').textContent,
|
||||
option: Array.from(panel.element.querySelector('.ge-actions-batch-select').options)
|
||||
.find(option => option.value === savedName)?.textContent,
|
||||
persisted: JSON.parse(localStorage.getItem('ge-actions'))[savedName],
|
||||
savedName,
|
||||
executed: Boolean(window.__actionsXss),
|
||||
attack,
|
||||
};
|
||||
@@ -39,12 +52,21 @@ def test_action_names_and_unknown_steps_are_literal_text():
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exit(1); });
|
||||
'''
|
||||
env = os.environ.copy()
|
||||
if 'PLAYWRIGHT_BROWSERS_PATH' not in env:
|
||||
for candidate in [Path.home() / '.cache/ms-playwright', Path('/home/alteixeira20/.cache/ms-playwright')]:
|
||||
if candidate.exists():
|
||||
env['PLAYWRIGHT_BROWSERS_PATH'] = str(candidate)
|
||||
break
|
||||
result = subprocess.run(
|
||||
['node', '-e', script, str(ROOT / 'static/js/editor/actions-panel.js')],
|
||||
cwd=ROOT, capture_output=True, text=True, timeout=30,
|
||||
cwd=ROOT, capture_output=True, text=True, timeout=30, env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
observed = json.loads(result.stdout)
|
||||
assert observed['images'] == 0
|
||||
assert observed['svgs'] == 0
|
||||
assert observed['saved'] == observed['step'] == observed['attack']
|
||||
assert observed['option'] == observed['savedName']
|
||||
assert observed['persisted'] == [observed['attack']]
|
||||
assert not observed['executed']
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Every adjustment popup that renders slider rows must store their values.
|
||||
|
||||
``buildAdjBody`` renders ``sliderRow`` controls per adjustment type, and
|
||||
``onAdjSliderInput`` maps a moved slider onto ``layer._stagedAdj.params``. A
|
||||
type with rows but no input branch shows a working slider whose value is
|
||||
silently dropped on OK (Vibrance regressed exactly this way). The browser
|
||||
behavior is covered by tests/e2e/photo-editor/adjustment-layers.spec.js.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
POPUP = (ROOT / "static/js/editor/fx/adj-popup.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_body(name: str, next_name: str) -> str:
|
||||
start = POPUP.index(f"function {name}(")
|
||||
return POPUP[start:POPUP.index(f"function {next_name}(", start)]
|
||||
|
||||
|
||||
def _types_with_slider_rows() -> set[str]:
|
||||
builder = _function_body("buildAdjBody", "onAdjSliderInput")
|
||||
branches = re.split(r"\}\s*else if \(|if \(type === ", builder)
|
||||
types = set()
|
||||
for branch in branches:
|
||||
names = re.findall(r"type === '([a-z-]+)'", "type === " + branch.split(")", 1)[0])
|
||||
if names and "sliderRow(" in branch:
|
||||
types.update(names)
|
||||
return types
|
||||
|
||||
|
||||
def test_every_slider_adjustment_type_writes_its_params():
|
||||
rendered = _types_with_slider_rows()
|
||||
assert {"exposure", "white-balance", "vibrance", "black-white"} <= rendered, rendered
|
||||
handler = _function_body("onAdjSliderInput", "wireCurveEditor")
|
||||
handled = set(re.findall(r"type === '([a-z-]+)'", handler))
|
||||
missing = sorted(rendered - handled)
|
||||
assert not missing, f"slider rows whose input is never stored: {missing}"
|
||||
|
||||
|
||||
def test_vibrance_slider_stores_the_raw_value():
|
||||
handler = _function_body("onAdjSliderInput", "wireCurveEditor")
|
||||
assert re.search(r"else if \(type === 'vibrance'\) \{\s*p\[key\] = raw;", handler)
|
||||
@@ -102,23 +102,12 @@ def test_bucket_tool_is_registered_and_wired():
|
||||
assert "bucketTolerance: 32" in state
|
||||
|
||||
|
||||
def test_bucket_rasterizes_a_pixel_layer_in_one_click():
|
||||
# A text/shape/placed layer used to bounce the user through a rasterize
|
||||
# dialog that could then sit behind the canvas, so the bucket looked broken.
|
||||
# Now the layer is rasterized silently (undoable) and filled in one click.
|
||||
def test_paint_tools_require_confirmation_before_rasterizing():
|
||||
editor = (ROOT / "static/js/galleryEditor.js").read_text()
|
||||
assert "function _rasterizeLayerNow(layer) {" in editor
|
||||
assert "async function _confirmRasterizeLayer(layer) {" in editor
|
||||
assert '_saveState(`Rasterize "${layer.name}"`);' in editor
|
||||
assert "if (!_rasterizeLayerNow(layer)) { void _offerRasterizeForTool(); return; }" in editor
|
||||
assert "if (!_pixelPaintTools.has(toolId)) void _offerRasterizeForTool(toolId);" in editor
|
||||
|
||||
|
||||
def test_all_paint_tools_rasterize_silently_instead_of_prompting():
|
||||
# Clone/heal/brush/etc. hit the same rasterize prompt on text/shape/placed
|
||||
# layers, so every paint tool now rasterizes silently and continues.
|
||||
editor = (ROOT / "static/js/galleryEditor.js").read_text()
|
||||
assert "// fall through — `layer` is a pixel layer now, so the tool runs below." in editor
|
||||
assert "if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive &&" in editor
|
||||
assert "void _confirmRasterizeLayer(layer);" in editor
|
||||
|
||||
|
||||
def test_smudge_and_dodge_icons_are_distinct():
|
||||
|
||||
@@ -20,8 +20,9 @@ def test_editor_paste_owns_clipboard_images_while_editor_open():
|
||||
"""
|
||||
src = SRC.read_text(encoding="utf-8")
|
||||
assert "wireClipboardAndDrop" in src
|
||||
# The bail-out must be scoped to targets inside the editor.
|
||||
assert "const insideEditor = target && container && container.contains(target);" in src
|
||||
# The bail-out must be scoped to targets inside the editor, and must not
|
||||
# throw for a paste whose target is not a Node (e.g. dispatched at window).
|
||||
assert "const insideEditor = target instanceof Node && !!container && container.contains(target);" in src
|
||||
assert (
|
||||
"if (insideEditor && (target.isContentEditable || "
|
||||
"target.closest?.('input, textarea, select'))) return;"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
def test_shared_picker_exports_editor_color_contract():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
script = r'''
|
||||
import assert from 'node:assert/strict';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const store = new Map();
|
||||
globalThis.localStorage = { getItem: key => store.get(key), setItem: (key, value) => store.set(key, value) };
|
||||
globalThis.HTMLInputElement = class {};
|
||||
const picker = await import(pathToFileURL(process.argv[1]));
|
||||
store.set('odysseus-recent-colors', JSON.stringify(['<img src=x onerror=alert(1)>', 123, '#ABCDEF']));
|
||||
assert.deepEqual(picker.getRecentColors(), ['#ABCDEF']);
|
||||
picker.recordRecentColor('#abcdef');
|
||||
picker.recordRecentColor('#ff0000');
|
||||
assert.deepEqual(picker.getRecentColors(), ['#ff0000', '#abcdef']);
|
||||
const suggestions = picker.suggestColorsFor('#ff0000');
|
||||
assert.equal(suggestions.length, 5);
|
||||
assert.equal(suggestions[0].hex, '#00ffff');
|
||||
assert.ok(suggestions.every(s => /^#[0-9a-f]{6}$/.test(s.hex)));
|
||||
store.set('odysseus-recent-colors', '{}');
|
||||
assert.deepEqual(picker.getRecentColors(), []);
|
||||
'''
|
||||
result = subprocess.run(['node', '--input-type=module', '-e', script,
|
||||
str(root / 'static/js/colorPicker.js')],
|
||||
cwd=root, capture_output=True, text=True, timeout=30)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -18,21 +18,10 @@ CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
MOBILE_BLOCK = """@media (max-width: 700px) {
|
||||
.ge-editor-footer {
|
||||
display: none !important;
|
||||
}
|
||||
/* The Quick Edit card lifts over the layers sheet / keyboard; keep it above
|
||||
every other editor asset (sheet headers, tool rail, popups) while lifted. */
|
||||
.ge-ai-command {
|
||||
z-index: 90;
|
||||
}
|
||||
}"""
|
||||
|
||||
|
||||
def test_mobile_footer_is_removed():
|
||||
assert MOBILE_BLOCK in CSS
|
||||
assert ".ge-editor-footer {\n display: none !important;" in CSS
|
||||
def test_mobile_footer_is_docked_flush_and_accessible():
|
||||
assert ".ge-editor-footer" in CSS
|
||||
assert "overflow-x: auto;" in CSS
|
||||
assert ".ge-editor-footer {\n display: none !important;" not in CSS
|
||||
|
||||
|
||||
def test_quick_edit_stacks_above_other_assets():
|
||||
|
||||
@@ -19,8 +19,11 @@ def test_phone_menu_bar_compaction_rules_present():
|
||||
assert ".ge-topbar-left .ge-stacked-btn {" in CSS
|
||||
assert "font-size: 11px;" in CSS
|
||||
assert "padding: 0 6px !important;" in CSS
|
||||
assert ".ge-topbar.ge-topbar-compact #ge-draft-status {" in CSS
|
||||
assert "display: none;" in CSS
|
||||
# Compaction drops the Save action label only. The draft status is the
|
||||
# button's role="status" live region (its icon is aria-hidden), so it must
|
||||
# stay rendered; interaction-contract.spec.js checks it at 390px.
|
||||
assert ".ge-topbar.ge-topbar-compact #ge-save-menu-btn .ge-save-action-label { display: none !important; }" in CSS
|
||||
assert not re.search(r"ge-topbar-compact\s+#ge-draft-status\s*\{[^}]*display:\s*none", CSS)
|
||||
|
||||
|
||||
def test_sw_cache_name_is_version_agnostic_pattern():
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from starlette.requests import Request
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from routes.editor_agent_routes import setup_editor_agent_routes
|
||||
from routes.gallery.gallery_routes import _visible_image_endpoint_for_base
|
||||
|
||||
|
||||
def create_fake_request(user: str | None = None, is_admin: bool = False):
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/api/editor/image-models",
|
||||
"headers": [],
|
||||
"app": MagicMock(),
|
||||
}
|
||||
req = Request(scope)
|
||||
# Mock auth_manager on app state
|
||||
auth_mgr = MagicMock()
|
||||
auth_mgr.is_admin.return_value = is_admin
|
||||
auth_mgr.is_configured = True
|
||||
req.app.state.auth_manager = auth_mgr
|
||||
return req
|
||||
|
||||
|
||||
class FakeEndpoint:
|
||||
def __init__(self, id, name, base_url, model_type="image", is_enabled=True, owner=None, cached_models=None):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.base_url = base_url
|
||||
self.model_type = model_type
|
||||
self.is_enabled = is_enabled
|
||||
self.owner = owner
|
||||
self.cached_models = cached_models or json.dumps(["qwen-image-edit", "qwen-generate"])
|
||||
self.pinned_models = None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_image_models_non_admin_redacts_urls():
|
||||
router = setup_editor_agent_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/editor/image-models")
|
||||
|
||||
fake_ep = FakeEndpoint(
|
||||
id=42,
|
||||
name="private-gpu-cluster",
|
||||
base_url="http://10.0.0.50:8000/v1",
|
||||
owner="testuser",
|
||||
)
|
||||
|
||||
fake_db = MagicMock()
|
||||
fake_query = MagicMock()
|
||||
fake_db.query.return_value = fake_query
|
||||
fake_query.filter.return_value = fake_query
|
||||
fake_query.all.return_value = [fake_ep]
|
||||
|
||||
req = create_fake_request(user="testuser", is_admin=False)
|
||||
|
||||
with patch("routes.editor_agent_routes._user", return_value="testuser"), \
|
||||
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False), \
|
||||
patch("core.database.SessionLocal", return_value=fake_db), \
|
||||
patch("src.auth_helpers.owner_filter", side_effect=lambda q, *a, **k: q), \
|
||||
patch("src.endpoint_resolver._endpoint_enabled_models", return_value=["qwen-image-edit"]):
|
||||
resp = await route.endpoint(req)
|
||||
|
||||
data = json.loads(resp.body.decode())
|
||||
assert "models" in data
|
||||
assert len(data["models"]) == 1
|
||||
m = data["models"][0]
|
||||
assert m["id"] == "qwen-image-edit"
|
||||
assert m["endpoint_id"] == 42
|
||||
# Non-admin must NOT see internal URL!
|
||||
assert "base_url" not in m
|
||||
assert "10.0.0.50" not in json.dumps(data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_image_models_admin_includes_urls():
|
||||
router = setup_editor_agent_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/editor/image-models")
|
||||
|
||||
fake_ep = FakeEndpoint(
|
||||
id=42,
|
||||
name="private-gpu-cluster",
|
||||
base_url="http://10.0.0.50:8000/v1",
|
||||
owner="adminuser",
|
||||
)
|
||||
|
||||
fake_db = MagicMock()
|
||||
fake_query = MagicMock()
|
||||
fake_db.query.return_value = fake_query
|
||||
fake_query.filter.return_value = fake_query
|
||||
fake_query.all.return_value = [fake_ep]
|
||||
|
||||
req = create_fake_request(user="adminuser", is_admin=True)
|
||||
|
||||
with patch("routes.editor_agent_routes._user", return_value="adminuser"), \
|
||||
patch("src.tool_security.owner_is_admin_or_single_user", return_value=True), \
|
||||
patch("core.database.SessionLocal", return_value=fake_db), \
|
||||
patch("src.auth_helpers.owner_filter", side_effect=lambda q, *a, **k: q), \
|
||||
patch("src.endpoint_resolver._endpoint_enabled_models", return_value=["qwen-image-edit"]):
|
||||
resp = await route.endpoint(req)
|
||||
|
||||
data = json.loads(resp.body.decode())
|
||||
assert len(data["models"]) == 1
|
||||
m = data["models"][0]
|
||||
assert m["endpoint_id"] == 42
|
||||
# Admin is allowed to see the endpoint base URL
|
||||
assert m.get("base_url") == "http://10.0.0.50:8000/v1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_agent_models_non_admin_redacts_urls_and_probe():
|
||||
router = setup_editor_agent_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/editor/agent-models")
|
||||
|
||||
raw_models = [
|
||||
{"id": "Qwen/Qwen3.5-9B", "label": "Qwen3.5-9B · local", "source": "local", "vision": True, "base_url": "http://100.69.120.65:8097/v1"},
|
||||
{"id": "hosted-default", "label": "hosted-default", "source": "hosted", "vision": None, "base_url": "https://api.hosted.ai/v1"},
|
||||
]
|
||||
|
||||
req = create_fake_request(user="regularuser", is_admin=False)
|
||||
|
||||
with patch("routes.editor_agent_routes._user", return_value="regularuser"), \
|
||||
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False), \
|
||||
patch("src.editor_agent_model.list_editor_agent_models", return_value=raw_models):
|
||||
resp = await route.endpoint(req)
|
||||
|
||||
data = json.loads(resp.body.decode())
|
||||
assert data.get("probe") == {}
|
||||
assert len(data["models"]) == 2
|
||||
for m in data["models"]:
|
||||
assert "base_url" not in m
|
||||
assert "100.69.120.65" not in json.dumps(data)
|
||||
|
||||
|
||||
def test_visible_image_endpoint_for_base_supports_id_and_url():
|
||||
fake_ep = FakeEndpoint(
|
||||
id=7,
|
||||
name="my-diffusion",
|
||||
base_url="http://internal-host:8080/v1",
|
||||
owner="alice",
|
||||
)
|
||||
|
||||
fake_db = MagicMock()
|
||||
fake_query = MagicMock()
|
||||
fake_db.query.return_value = fake_query
|
||||
fake_query.filter.return_value = fake_query
|
||||
fake_query.all.return_value = [fake_ep]
|
||||
|
||||
with patch("src.auth_helpers.owner_filter", side_effect=lambda q, *a, **k: q):
|
||||
# 1. Lookup by numeric string ID
|
||||
res_id = _visible_image_endpoint_for_base(fake_db, "7", "alice", "qwen-image-edit")
|
||||
assert res_id == fake_ep
|
||||
|
||||
# 2. Lookup by URL
|
||||
res_url = _visible_image_endpoint_for_base(fake_db, "http://internal-host:8080", "alice", "qwen-image-edit")
|
||||
assert res_url == fake_ep
|
||||
|
||||
# 3. Lookup non-existent
|
||||
res_none = _visible_image_endpoint_for_base(fake_db, "999", "alice", "qwen-image-edit")
|
||||
assert res_none is None
|
||||
@@ -0,0 +1,458 @@
|
||||
import asyncio
|
||||
import http.server
|
||||
import json
|
||||
import threading
|
||||
from typing import List, Tuple
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from routes.editor_agent_routes import setup_editor_agent_routes
|
||||
import src.editor_agent_model as eam
|
||||
|
||||
|
||||
class TrackingRequestHandler(http.server.BaseHTTPRequestHandler):
|
||||
received_requests: List[Tuple[str, str, dict]] = []
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("content-length", 0))
|
||||
body = self.rfile.read(length) if length > 0 else b""
|
||||
try:
|
||||
body_json = json.loads(body.decode("utf-8"))
|
||||
except Exception:
|
||||
body_json = {}
|
||||
TrackingRequestHandler.received_requests.append((self.command, self.path, body_json))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"choices": [{"message": {"content": "ATTACKER_PLAN"}}]}')
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_listener():
|
||||
TrackingRequestHandler.received_requests = []
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), TrackingRequestHandler)
|
||||
port = server.server_address[1]
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield f"http://127.0.0.1:{port}/v1"
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def create_mock_request(path: str, body: dict, user: str = "testuser", is_admin: bool = False):
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": path,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"app": MagicMock(),
|
||||
}
|
||||
req = Request(scope)
|
||||
auth_mgr = MagicMock()
|
||||
auth_mgr.is_admin.return_value = is_admin
|
||||
auth_mgr.is_configured = True
|
||||
req.app.state.auth_manager = auth_mgr
|
||||
|
||||
async def mock_json():
|
||||
return body
|
||||
|
||||
req.json = mock_json
|
||||
return req
|
||||
|
||||
|
||||
SYNTHETIC_SCREENSHOT = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
|
||||
|
||||
def test_ssrf_arbitrary_listener_receives_zero_requests(local_listener, monkeypatch):
|
||||
"""Prove attacker-supplied agent_model URL receives 0 requests on planner routes."""
|
||||
router = setup_editor_agent_routes()
|
||||
plan_route = next(r for r in router.routes if r.path == "/api/editor/plan")
|
||||
agent_route = next(r for r in router.routes if r.path == "/api/editor/agent")
|
||||
|
||||
async def _fake_wait(fut, timeout=None, **kwargs):
|
||||
return {"ok": True, "result": {"width": 100, "height": 100}}
|
||||
|
||||
monkeypatch.setattr("routes.editor_agent_routes.asyncio.wait_for", _fake_wait)
|
||||
|
||||
payload = {
|
||||
"prompt": "make it brighter",
|
||||
"screenshot": SYNTHETIC_SCREENSHOT,
|
||||
"agent_model": f"{local_listener}::attacker-model",
|
||||
}
|
||||
|
||||
# Test /api/editor/plan
|
||||
req_plan = create_mock_request("/api/editor/plan", payload, user="alice", is_admin=False)
|
||||
with patch("routes.editor_agent_routes._user", return_value="alice"), \
|
||||
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False):
|
||||
resp_plan = asyncio.run(plan_route.endpoint(req_plan))
|
||||
assert resp_plan.status_code == 200
|
||||
assert len(TrackingRequestHandler.received_requests) == 0
|
||||
|
||||
# Test /api/editor/agent
|
||||
req_agent = create_mock_request("/api/editor/agent", payload, user="alice", is_admin=False)
|
||||
with patch("routes.editor_agent_routes._user", return_value="alice"), \
|
||||
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False):
|
||||
resp_agent = asyncio.run(agent_route.endpoint(req_agent))
|
||||
assert resp_agent.status_code == 200
|
||||
assert len(TrackingRequestHandler.received_requests) == 0
|
||||
|
||||
|
||||
def test_ssrf_admin_cannot_target_arbitrary_url(local_listener):
|
||||
"""Admin access must not grant arbitrary outbound network destinations."""
|
||||
payload = {
|
||||
"prompt": "make it brighter",
|
||||
"screenshot": SYNTHETIC_SCREENSHOT,
|
||||
"agent_model": f"{local_listener}::attacker-model",
|
||||
}
|
||||
router = setup_editor_agent_routes()
|
||||
plan_route = next(r for r in router.routes if r.path == "/api/editor/plan")
|
||||
|
||||
req = create_mock_request("/api/editor/plan", payload, user="admin_user", is_admin=True)
|
||||
with patch("routes.editor_agent_routes._user", return_value="admin_user"), \
|
||||
patch("src.tool_security.owner_is_admin_or_single_user", return_value=True):
|
||||
resp = asyncio.run(plan_route.endpoint(req))
|
||||
assert resp.status_code == 200
|
||||
assert len(TrackingRequestHandler.received_requests) == 0
|
||||
|
||||
|
||||
def test_candidates_cloud_metadata_rejected():
|
||||
"""Cloud metadata destination is rejected fail-closed."""
|
||||
cands = eam._candidates("user1", "http://169.254.169.254/latest/meta-data::cloud-meta")
|
||||
assert cands == []
|
||||
|
||||
|
||||
def test_candidates_foreign_owned_endpoint_rejected():
|
||||
"""Foreign-owned endpoint ID fails closed for a different user."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id="ep-foreign-test",
|
||||
name="Foreign Cluster",
|
||||
base_url="http://192.168.1.100:8000/v1",
|
||||
owner="victim_user",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["victim-model"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
# Attacker user query
|
||||
cands = eam._candidates("attacker_user", "ep-foreign-test::victim-model")
|
||||
assert cands == []
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-foreign-test").delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidates_disabled_endpoint_rejected():
|
||||
"""Disabled endpoint ID fails closed."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id="ep-disabled-test",
|
||||
name="Disabled Cluster",
|
||||
base_url="http://192.168.1.100:8000/v1",
|
||||
owner="user1",
|
||||
is_enabled=False,
|
||||
cached_models=json.dumps(["some-model"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
cands = eam._candidates("user1", "ep-disabled-test::some-model")
|
||||
assert cands == []
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-disabled-test").delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidates_nonexistent_endpoint_rejected():
|
||||
"""Nonexistent endpoint ID fails closed."""
|
||||
cands = eam._candidates("user1", "ep-nonexistent-9999::model1")
|
||||
assert cands == []
|
||||
|
||||
|
||||
def test_candidates_forged_model_id_rejected():
|
||||
"""Forged model ID on an authorized endpoint fails closed."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id="ep-auth-test",
|
||||
name="User Cluster",
|
||||
base_url="http://127.0.0.1:9099/v1",
|
||||
owner="user1",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["real-model"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
# Legitimate model passes
|
||||
valid_cands = eam._candidates("user1", "ep-auth-test::real-model")
|
||||
assert len(valid_cands) >= 1
|
||||
assert valid_cands[0]["model"] == "real-model"
|
||||
|
||||
# Forged model fails closed
|
||||
forged_cands = eam._candidates("user1", "ep-auth-test::forged-attacker-model")
|
||||
assert forged_cands == []
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-auth-test").delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidates_duplicate_model_ids_scoped_by_endpoint():
|
||||
"""Duplicate model IDs across different endpoints do not collide or cross providers."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep1 = ModelEndpoint(
|
||||
id="ep-provider-1",
|
||||
name="Provider 1",
|
||||
base_url="http://127.0.0.1:9001/v1",
|
||||
owner="user1",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["shared-model-name"]),
|
||||
)
|
||||
ep2 = ModelEndpoint(
|
||||
id="ep-provider-2",
|
||||
name="Provider 2",
|
||||
base_url="http://127.0.0.1:9002/v1",
|
||||
owner="user1",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["shared-model-name"]),
|
||||
)
|
||||
db.merge(ep1)
|
||||
db.merge(ep2)
|
||||
db.commit()
|
||||
|
||||
cands1 = eam._candidates("user1", "ep-provider-1::shared-model-name")
|
||||
assert len(cands1) >= 1
|
||||
assert cands1[0]["endpoint_id"] == "ep-provider-1"
|
||||
assert "9001" in cands1[0]["base"]
|
||||
|
||||
cands2 = eam._candidates("user1", "ep-provider-2::shared-model-name")
|
||||
assert len(cands2) >= 1
|
||||
assert cands2[0]["endpoint_id"] == "ep-provider-2"
|
||||
assert "9002" in cands2[0]["base"]
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id.in_(["ep-provider-1", "ep-provider-2"])).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidates_authorized_shared_endpoint():
|
||||
"""Shared endpoint (owner is NULL) is accessible to authenticated user."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id="ep-shared-test",
|
||||
name="Shared Org Cluster",
|
||||
base_url="http://127.0.0.1:9005/v1",
|
||||
owner=None,
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["org-planner-model"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
cands = eam._candidates("any_user", "ep-shared-test::org-planner-model")
|
||||
assert len(cands) >= 1
|
||||
assert cands[0]["endpoint_id"] == "ep-shared-test"
|
||||
assert cands[0]["model"] == "org-planner-model"
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-shared-test").delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidates_legacy_raw_url_override_verified_against_registered_db():
|
||||
"""Legacy raw URL format resolves only when matching a registered DB endpoint."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id="ep-legacy-test",
|
||||
name="Legacy Registered",
|
||||
base_url="http://registered-server.internal:8000/v1",
|
||||
owner="user1",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["legacy-model"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
# Matching registered row resolves
|
||||
cands = eam._candidates("user1", "http://registered-server.internal:8000/v1::legacy-model")
|
||||
assert len(cands) >= 1
|
||||
assert cands[0]["endpoint_id"] == "ep-legacy-test"
|
||||
|
||||
# Unregistered raw URL fails closed
|
||||
unreg_cands = eam._candidates("user1", "http://unregistered-server.internal:8000/v1::legacy-model")
|
||||
assert unreg_cands == []
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-legacy-test").delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidates_unauthorized_override_does_not_silently_fallback(monkeypatch):
|
||||
"""Unauthorized override fails closed rather than silently falling back to defaults."""
|
||||
monkeypatch.setattr(eam, "local_bases", lambda: ["http://default-local:8000/v1"])
|
||||
monkeypatch.setattr(eam, "local_model", lambda: "default-local-model")
|
||||
|
||||
# Override is unauthorized/attacker -> MUST NOT fallback to default-local
|
||||
cands = eam._candidates("user1", "http://attacker-listener.com:9999/v1::attacker-model")
|
||||
assert cands == []
|
||||
|
||||
|
||||
def test_editor_routes_unauthenticated_returns_401_when_auth_configured():
|
||||
"""Unauthenticated requests must return 401 when authentication is enabled."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
router = setup_editor_agent_routes()
|
||||
app.include_router(router)
|
||||
|
||||
auth_mgr = MagicMock()
|
||||
auth_mgr.is_configured = True
|
||||
app.state.auth_manager = auth_mgr
|
||||
|
||||
with patch("src.auth_helpers._auth_disabled", return_value=False), \
|
||||
patch("src.auth_helpers.get_current_user", return_value=None):
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp_plan = client.post("/api/editor/plan", json={"prompt": "draw cat"})
|
||||
assert resp_plan.status_code == 401
|
||||
assert resp_plan.json().get("detail") == "Not authenticated"
|
||||
|
||||
resp_agent = client.post("/api/editor/agent", json={"prompt": "draw cat"})
|
||||
assert resp_agent.status_code == 401
|
||||
assert resp_agent.json().get("detail") == "Not authenticated"
|
||||
|
||||
|
||||
def test_candidates_empty_owner_cannot_query_foreign_private_endpoint():
|
||||
"""Empty owner in multi-user mode cannot access private endpoints of other users."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id="ep-private-victim",
|
||||
name="Victim Private Cluster",
|
||||
base_url="http://192.168.1.105:8000/v1",
|
||||
owner="victim_user",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["victim-secret-model"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
with patch("src.auth_helpers._auth_disabled", return_value=False):
|
||||
# None owner fails closed
|
||||
cands_none = eam._candidates(None, "ep-private-victim::victim-secret-model")
|
||||
assert cands_none == []
|
||||
|
||||
# Empty string owner fails closed
|
||||
cands_empty = eam._candidates("", "ep-private-victim::victim-secret-model")
|
||||
assert cands_empty == []
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-private-victim").delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_editor_routes_api_token_without_chat_scope_returns_403():
|
||||
"""Bearer API token missing 'chat' scope is rejected with 403."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
async def inject_token_middleware(request, call_next):
|
||||
request.state.api_token = True
|
||||
request.state.api_token_scopes = ["image", "read"] # Missing "chat"
|
||||
request.state.api_token_owner = "alice"
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(setup_editor_agent_routes())
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
resp_plan = client.post("/api/editor/plan", json={"prompt": "make it pop"})
|
||||
assert resp_plan.status_code == 403
|
||||
assert "chat" in resp_plan.json().get("detail", "")
|
||||
|
||||
resp_agent = client.post("/api/editor/agent", json={"prompt": "make it pop"})
|
||||
assert resp_agent.status_code == 403
|
||||
assert "chat" in resp_agent.json().get("detail", "")
|
||||
|
||||
|
||||
def test_editor_routes_api_token_with_chat_scope_resolves_owner_endpoint():
|
||||
"""Bearer API token with 'chat' scope resolves the token owner's endpoints."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
alice_ep = ModelEndpoint(
|
||||
id="ep-alice-token",
|
||||
name="Alice Cluster",
|
||||
base_url="http://127.0.0.1:9098/v1",
|
||||
owner="alice",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["alice-model"]),
|
||||
)
|
||||
bob_ep = ModelEndpoint(
|
||||
id="ep-bob-token",
|
||||
name="Bob Cluster",
|
||||
base_url="http://127.0.0.1:9099/v1",
|
||||
owner="bob",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["bob-model"]),
|
||||
)
|
||||
db.merge(alice_ep)
|
||||
db.merge(bob_ep)
|
||||
db.commit()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
async def inject_token_middleware(request, call_next):
|
||||
request.state.api_token = True
|
||||
request.state.api_token_scopes = ["chat"]
|
||||
request.state.api_token_owner = "alice"
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(setup_editor_agent_routes())
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
# Alice's token querying Alice's endpoint resolves
|
||||
cands = eam._candidates("alice", "ep-alice-token::alice-model")
|
||||
assert len(cands) == 1
|
||||
assert cands[0]["endpoint_id"] == "ep-alice-token"
|
||||
|
||||
# Alice's token querying Bob's endpoint fails closed
|
||||
cands_bob = eam._candidates("alice", "ep-bob-token::bob-model")
|
||||
assert cands_bob == []
|
||||
|
||||
# Route-level check: calling /api/editor/plan with Alice's token targeting Bob's endpoint
|
||||
# falls back and does NOT route to Bob
|
||||
resp = client.post(
|
||||
"/api/editor/plan",
|
||||
json={"prompt": "enhance", "agent_model": "ep-bob-token::bob-model"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Should report fallback or default, never bob's endpoint
|
||||
assert resp.json().get("agent_model") != "ep-bob-token::bob-model"
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.id.in_(["ep-alice-token", "ep-bob-token"])
|
||||
).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
@@ -388,11 +388,13 @@ def test_quick_edit_has_an_agent_model_picker():
|
||||
assert "Recommended, not required" in AI_SETTINGS
|
||||
assert "function _getSelectedQuickEditAgentModel()" in EDITOR
|
||||
assert "getAgentModel: () => _getSelectedQuickEditAgentModel()," in EDITOR
|
||||
# Local Qwen3.5-9B VL is the default; server default is the alternative.
|
||||
# Local/hosted agent models are dynamically discovered via /api/editor/agent-models;
|
||||
# private Tailscale/CGNAT IPs must not be hardcoded in frontend source.
|
||||
assert "export function resolveQuickEditAgentModel()" in models
|
||||
assert "export const QUICK_EDIT_AGENT_OPTIONS" in models
|
||||
assert "Qwen3.5-9B VL · local" in models
|
||||
assert "http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B" in models
|
||||
assert "Server default (hosted planner)" in models
|
||||
assert "100.69.120.65" not in models
|
||||
assert "/api/editor/agent-models" in models
|
||||
assert "localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value)" in models
|
||||
assert "localStorage.getItem('ge-ai-command-agent-model')" in models
|
||||
# The request carries the agent model.
|
||||
|
||||
@@ -40,7 +40,10 @@ def test_flyout_triangle_css_is_visible():
|
||||
def test_brush_preset_rows_have_a_tip_icon():
|
||||
assert "const soft = typeof preset.softness === 'number' ? preset.softness : 100;" in TOOLBAR
|
||||
assert "const presetIcon = preset.pixel" in TOOLBAR
|
||||
assert '<span class="ge-tool-icon">${presetIcon}</span><span class="ge-tool-flyout-label">${preset.name}</span>' in TOOLBAR
|
||||
assert "icon.className = 'ge-tool-icon';" in TOOLBAR
|
||||
assert 'icon.innerHTML = presetIcon;' in TOOLBAR
|
||||
assert "label.className = 'ge-tool-flyout-label';" in TOOLBAR
|
||||
assert 'label.textContent = preset.name;' in TOOLBAR
|
||||
|
||||
|
||||
def test_flyout_is_clamped_inside_the_viewport():
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import asyncio
|
||||
import http.server
|
||||
import json
|
||||
import threading
|
||||
from typing import List, Tuple
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from routes.gallery.gallery_routes import setup_gallery_routes, _visible_image_endpoint_for_base
|
||||
|
||||
|
||||
class TrackingPollHandler(http.server.BaseHTTPRequestHandler):
|
||||
requests_received: List[str] = []
|
||||
|
||||
def do_GET(self):
|
||||
TrackingPollHandler.requests_received.append(self.path)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"available": true, "progress": 50}')
|
||||
|
||||
def do_POST(self):
|
||||
TrackingPollHandler.requests_received.append(self.path)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"images": ["data:image/png;base64,AAAA"]}')
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider_listener():
|
||||
TrackingPollHandler.requests_received = []
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), TrackingPollHandler)
|
||||
port = server.server_address[1]
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def create_gallery_request(method: str, path: str, query_params: dict = None, body: dict = None, user: str = "alice"):
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": method,
|
||||
"path": path,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"query_string": "&".join(f"{k}={v}" for k, v in (query_params or {}).items()).encode("utf-8"),
|
||||
"app": MagicMock(),
|
||||
}
|
||||
req = Request(scope)
|
||||
|
||||
async def mock_json():
|
||||
return body or {}
|
||||
|
||||
req.json = mock_json
|
||||
return req
|
||||
|
||||
|
||||
def test_edit_progress_disabled_endpoint_causes_no_outbound_polling(mock_provider_listener):
|
||||
"""GET /api/image/edit-progress with disabled endpoint causes zero outbound requests."""
|
||||
db = SessionLocal()
|
||||
ep_id = "ep-test-disabled-progress"
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name="Disabled Endpoint",
|
||||
base_url=mock_provider_listener,
|
||||
model_type="image",
|
||||
is_enabled=False,
|
||||
owner="alice",
|
||||
cached_models=json.dumps(["test-diff"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
router = setup_gallery_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
|
||||
|
||||
req = create_gallery_request(
|
||||
"GET",
|
||||
"/api/image/edit-progress",
|
||||
query_params={"request_id": "req-123", "endpoint_id": ep_id},
|
||||
user="alice",
|
||||
)
|
||||
|
||||
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
|
||||
resp = asyncio.run(route.endpoint(req))
|
||||
|
||||
assert isinstance(resp, dict)
|
||||
assert resp["available"] is False
|
||||
assert resp.get("reason") == "endpoint not found or disabled"
|
||||
# Zero outbound polling to the provider!
|
||||
assert len(TrackingPollHandler.requests_received) == 0
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_edit_progress_foreign_owned_endpoint_causes_no_outbound_polling(mock_provider_listener):
|
||||
"""GET /api/image/edit-progress with foreign-owned endpoint causes zero outbound requests."""
|
||||
db = SessionLocal()
|
||||
ep_id = "ep-test-foreign-progress"
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name="Victim Endpoint",
|
||||
base_url=mock_provider_listener,
|
||||
model_type="image",
|
||||
is_enabled=True,
|
||||
owner="victim_user",
|
||||
cached_models=json.dumps(["test-diff"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
router = setup_gallery_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
|
||||
|
||||
# Attacker user 'bob' requests victim's endpoint
|
||||
req = create_gallery_request(
|
||||
"GET",
|
||||
"/api/image/edit-progress",
|
||||
query_params={"request_id": "req-456", "endpoint_id": ep_id},
|
||||
user="bob",
|
||||
)
|
||||
|
||||
with patch("routes.gallery.gallery_routes.require_privilege", return_value="bob"):
|
||||
resp = asyncio.run(route.endpoint(req))
|
||||
|
||||
assert isinstance(resp, dict)
|
||||
assert resp["available"] is False
|
||||
assert resp.get("reason") == "endpoint not found or disabled"
|
||||
assert len(TrackingPollHandler.requests_received) == 0
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_edit_progress_rejects_chat_only_endpoint_causes_no_polling(mock_provider_listener):
|
||||
"""GET /api/image/edit-progress rejects chat-only endpoint ID with zero outbound requests."""
|
||||
db = SessionLocal()
|
||||
ep_id = "ep-test-chat-progress"
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name="Chat Endpoint",
|
||||
base_url=mock_provider_listener,
|
||||
model_type="llm", # chat only!
|
||||
is_enabled=True,
|
||||
owner="alice",
|
||||
cached_models=json.dumps(["llama-3-8b"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
router = setup_gallery_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
|
||||
|
||||
req = create_gallery_request(
|
||||
"GET",
|
||||
"/api/image/edit-progress",
|
||||
query_params={"request_id": "req-789", "endpoint_id": ep_id},
|
||||
user="alice",
|
||||
)
|
||||
|
||||
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
|
||||
resp = asyncio.run(route.endpoint(req))
|
||||
|
||||
assert isinstance(resp, dict)
|
||||
assert resp["available"] is False
|
||||
assert resp.get("reason") == "endpoint not found or disabled"
|
||||
# Zero outbound polling to the chat provider!
|
||||
assert len(TrackingPollHandler.requests_received) == 0
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_edit_progress_blocks_private_ip_when_policy_enabled(mock_provider_listener, monkeypatch):
|
||||
"""GET /api/image/edit-progress enforces IMAGE_BLOCK_PRIVATE_IPS policy."""
|
||||
monkeypatch.setenv("IMAGE_BLOCK_PRIVATE_IPS", "true")
|
||||
db = SessionLocal()
|
||||
ep_id = "ep-test-private-progress"
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name="Private Image Endpoint",
|
||||
base_url=mock_provider_listener, # 127.0.0.1 loopback
|
||||
model_type="image",
|
||||
is_enabled=True,
|
||||
owner="alice",
|
||||
cached_models=json.dumps(["sd-v1"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
router = setup_gallery_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
|
||||
|
||||
req = create_gallery_request(
|
||||
"GET",
|
||||
"/api/image/edit-progress",
|
||||
query_params={"request_id": "req-priv", "endpoint_id": ep_id},
|
||||
user="alice",
|
||||
)
|
||||
|
||||
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
|
||||
resp = asyncio.run(route.endpoint(req))
|
||||
|
||||
assert isinstance(resp, dict)
|
||||
assert resp["available"] is False
|
||||
assert "rejected" in str(resp.get("reason"))
|
||||
# Zero outbound polling because private IP was blocked by policy!
|
||||
assert len(TrackingPollHandler.requests_received) == 0
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_harmonize_rejects_chat_only_endpoint(mock_provider_listener):
|
||||
"""POST /api/image/harmonize rejects a chat-only endpoint that has no image capability."""
|
||||
db = SessionLocal()
|
||||
ep_id = "ep-test-chat-only"
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name="Chat Only Llama",
|
||||
base_url=mock_provider_listener,
|
||||
model_type="llm", # chat only
|
||||
is_enabled=True,
|
||||
owner="alice",
|
||||
cached_models=json.dumps(["meta-llama/Llama-3-8B-Instruct", "mistral-7b"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
router = setup_gallery_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/image/harmonize")
|
||||
|
||||
payload = {
|
||||
"image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
"_endpoint": ep_id,
|
||||
"_model": "meta-llama/Llama-3-8B-Instruct",
|
||||
}
|
||||
req = create_gallery_request("POST", "/api/image/harmonize", body=payload, user="alice")
|
||||
|
||||
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(route.endpoint(req))
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Choose a registered image endpoint" in exc_info.value.detail
|
||||
|
||||
# Zero outbound requests to chat provider
|
||||
assert len(TrackingPollHandler.requests_received) == 0
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_harmonize_accepts_eligible_image_model_on_mixed_endpoint(mock_provider_listener):
|
||||
"""POST /api/image/harmonize accepts a mixed endpoint when an image-capable model is selected."""
|
||||
db = SessionLocal()
|
||||
ep_id = "ep-test-mixed-endpoint"
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name="Mixed Gateway",
|
||||
base_url=mock_provider_listener,
|
||||
model_type="llm", # mixed gateway
|
||||
is_enabled=True,
|
||||
owner="alice",
|
||||
cached_models=json.dumps(["gpt-4o", "qwen-image-edit"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
# Testing capability helper directly with image model
|
||||
ep_resolved = _visible_image_endpoint_for_base(db, ep_id, "alice", "qwen-image-edit")
|
||||
assert ep_resolved is not None
|
||||
assert ep_resolved.id == ep_id
|
||||
|
||||
# Testing with chat model fails
|
||||
ep_chat = _visible_image_endpoint_for_base(db, ep_id, "alice", "gpt-4o")
|
||||
assert ep_chat is None
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_harmonize_preserves_legitimate_image_capable_endpoint(mock_provider_listener):
|
||||
"""Legitimate image endpoint (model_type == 'image') resolves and works properly."""
|
||||
db = SessionLocal()
|
||||
ep_id = "ep-test-sd-diffusion"
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name="Forge SD WebUI",
|
||||
base_url=mock_provider_listener,
|
||||
model_type="image",
|
||||
is_enabled=True,
|
||||
owner="alice",
|
||||
cached_models=json.dumps(["sd_xl_base_1.0.safetensors"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
ep_resolved = _visible_image_endpoint_for_base(db, ep_id, "alice", "sd_xl_base_1.0.safetensors")
|
||||
assert ep_resolved is not None
|
||||
assert ep_resolved.id == ep_id
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Service-worker cache policy for the /static/ scope.
|
||||
|
||||
The worker is registered at /static/sw.js without a scope, so it only controls
|
||||
/static/ clients. The SPA root is outside that scope, which means an ordinary
|
||||
page load never populates the worker's cache: anything the lazy editor graph
|
||||
imports must be in the precache lists, shared modules included. The real
|
||||
browser upgrade/offline proof lives in
|
||||
tests/e2e/photo-editor/offline-upgrade.spec.js.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC = ROOT / "static"
|
||||
JS_DIR = STATIC / "js"
|
||||
SW = (STATIC / "sw.js").read_text(encoding="utf-8")
|
||||
|
||||
_STATIC_IMPORT = re.compile(
|
||||
r"""(?:^|\n)\s*(?:import\s+(?:[^;'"()]*?\s+from\s+)?|export\s+(?:\*|\{[^}]*\})\s+from\s+)['"]([^'"]+)['"]"""
|
||||
)
|
||||
|
||||
|
||||
def _list(name: str) -> set[str]:
|
||||
block = re.search(rf"const {name} = \[(.*?)\];", SW, re.S)
|
||||
assert block, f"{name} not found in static/sw.js"
|
||||
return set(re.findall(r"'([^']+)'", block.group(1)))
|
||||
|
||||
|
||||
def _fetch_handler() -> str:
|
||||
start = SW.index("self.addEventListener('fetch'")
|
||||
return SW[start:]
|
||||
|
||||
|
||||
def _lazy_editor_graph() -> set[str]:
|
||||
panels = (JS_DIR / "panels.js").read_text(encoding="utf-8")
|
||||
entry = re.search(r"import\(['\"]\./(galleryEditor\.js(?:\?[^'\"]+)?)['\"]\)", panels)
|
||||
assert entry, "panels.js must lazily import galleryEditor.js"
|
||||
seen: set[str] = set()
|
||||
stack = [entry.group(1)]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if current in seen:
|
||||
continue
|
||||
seen.add(current)
|
||||
source = (JS_DIR / current.split("?")[0]).read_text(encoding="utf-8")
|
||||
for spec in _STATIC_IMPORT.findall(source):
|
||||
if not spec.startswith("."):
|
||||
continue
|
||||
path, _, query = spec.partition("?")
|
||||
target = os.path.normpath(os.path.join(os.path.dirname(current.split("?")[0]), path))
|
||||
stack.append(f"{target}?{query}" if query else target)
|
||||
return seen
|
||||
|
||||
|
||||
def test_every_module_in_the_lazy_editor_graph_is_precached_with_its_exact_url():
|
||||
graph = _lazy_editor_graph()
|
||||
assert len(graph) > 100, len(graph)
|
||||
precached = _list("PRECACHE") | _list("PANEL_PRECACHE")
|
||||
missing = sorted(f"/static/js/{module}" for module in graph if f"/static/js/{module}" not in precached)
|
||||
assert not missing, (
|
||||
"the editor imports these modules, but the /static/-scoped worker never "
|
||||
f"sees the SPA page load that would cache them: {missing}"
|
||||
)
|
||||
|
||||
|
||||
def test_worker_keeps_the_static_scope():
|
||||
index = (STATIC / "index.html").read_text(encoding="utf-8")
|
||||
registrations = re.findall(r"serviceWorker\.register\(([^)]*)\)", index)
|
||||
assert registrations == ["'/static/sw.js?v=20260916autoscroll1'"]
|
||||
assert "Service-Worker-Allowed" not in (ROOT / "app.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_fetch_handler_ignores_cross_origin_api_and_non_get_requests():
|
||||
handler = _fetch_handler()
|
||||
api_guard = handler.index("url.pathname.startsWith('/api/') || e.request.method !== 'GET') return;")
|
||||
origin_guard = handler.index("if (url.origin !== self.location.origin) return;")
|
||||
first_respond = handler.index("e.respondWith(")
|
||||
assert api_guard < origin_guard < first_respond
|
||||
|
||||
|
||||
def test_offline_fallback_ignores_only_an_origin_vary_inside_the_current_cache():
|
||||
helper = SW[SW.index("async function cachedStaticFile(request)"):SW.index("self.addEventListener('fetch'")]
|
||||
assert "const cache = await caches.open(CACHE_NAME);" in helper
|
||||
assert "await cache.match(request);" in helper
|
||||
assert "cache.matchAll(request, { ignoreVary: true })" in helper
|
||||
assert ".every(name => name === 'origin')" in helper
|
||||
assert "|| Response.error();" in helper
|
||||
# No other lookup may relax Vary or search caches left by other versions.
|
||||
assert SW.count("ignoreVary") == 1
|
||||
assert "caches.match(" not in _fetch_handler()
|
||||
assert ".catch(() => cachedStaticFile(e.request))" in _fetch_handler()
|
||||
|
||||
|
||||
def test_activation_removes_every_other_cache_version():
|
||||
assert re.search(r"const CACHE_NAME = 'odysseus-v\d+-[a-z0-9-]+';", SW)
|
||||
activate = SW[SW.index("self.addEventListener('activate'"):SW.index("async function cachedStaticFile")]
|
||||
assert "keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))" in activate
|
||||
assert "self.clients.claim()" in activate
|
||||
@@ -0,0 +1,244 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
import pytest
|
||||
|
||||
from scripts.sanitize_trace_artifacts import (
|
||||
sanitize_structured_obj,
|
||||
sanitize_jsonl_content,
|
||||
sanitize_url,
|
||||
sanitize_body_text,
|
||||
sanitize_trace_archive,
|
||||
sanitize_directory,
|
||||
)
|
||||
|
||||
|
||||
def test_sanitize_headers_and_inverted_keys():
|
||||
"""Inverted key order and non-Bearer schemes are safely redacted."""
|
||||
# Standard order
|
||||
h1 = {"name": "Authorization", "value": "Bearer my_super_secret_jwt_12345"}
|
||||
assert sanitize_structured_obj(h1)["value"] == "[REDACTED]"
|
||||
|
||||
# Inverted order {"value": ..., "name": ...}
|
||||
h2 = {"value": "Digest username=admin, nonce=abc, response=secret123", "name": "Authorization"}
|
||||
res2 = sanitize_structured_obj(h2)
|
||||
assert res2["value"] == "[REDACTED]"
|
||||
|
||||
# ApiKey scheme
|
||||
h3 = {"name": "Authorization", "value": "ApiKey secret_api_key_xyz"}
|
||||
assert sanitize_structured_obj(h3)["value"] == "[REDACTED]"
|
||||
|
||||
# Custom token header
|
||||
h4 = {"name": "X-Auth-Token", "value": "auth_token_999"}
|
||||
assert sanitize_structured_obj(h4)["value"] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_sanitize_cookies_arbitrary_names():
|
||||
"""Arbitrary cookie names and values are completely redacted."""
|
||||
cookie_entry = {
|
||||
"cookies": [
|
||||
{"name": "odysseus_session", "value": "secret_sess_abc"},
|
||||
{"name": "remember_me_custom", "value": "remember_secret_val"},
|
||||
{"name": "sso_auth_id", "value": "sso_uuid_12345"},
|
||||
]
|
||||
}
|
||||
sanitized = sanitize_structured_obj(cookie_entry)
|
||||
for c in sanitized["cookies"]:
|
||||
assert c["value"] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_sanitize_dom_snapshots_and_nested_action_params():
|
||||
"""DOM snapshot trees and deeply nested action params are redacted."""
|
||||
# 1. Action with nested options before value
|
||||
action_record = {
|
||||
"type": "action",
|
||||
"name": "fill",
|
||||
"params": {
|
||||
"selector": "#login-password",
|
||||
"options": {"force": True, "timeout": 5000},
|
||||
"value": "escaped\"quote\"password123!",
|
||||
},
|
||||
}
|
||||
res_action = sanitize_structured_obj(action_record)
|
||||
assert res_action["params"]["value"] == "[REDACTED]"
|
||||
|
||||
# 2. DOM snapshot input element
|
||||
dom_record = {
|
||||
"nodeName": "INPUT",
|
||||
"attributes": {
|
||||
"type": "password",
|
||||
"name": "user_password",
|
||||
"value": "super_secret_in_dom",
|
||||
},
|
||||
}
|
||||
res_dom = sanitize_structured_obj(dom_record)
|
||||
assert res_dom["attributes"]["value"] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_sanitize_url_tokens_and_authority():
|
||||
"""URL query parameters and userinfo credentials are scrubbed."""
|
||||
# Authority credentials
|
||||
url1 = "https://admin:my_secret_pw@example.com/api/v1/resource"
|
||||
assert sanitize_url(url1) == "https://admin:[REDACTED]@example.com/api/v1/resource"
|
||||
|
||||
# Query tokens
|
||||
url2 = "https://example.com/oauth/callback?code=auth_code_secret&state=xyz&token=access_token_123"
|
||||
sanitized2 = sanitize_url(url2)
|
||||
assert "auth_code_secret" not in sanitized2
|
||||
assert "access_token_123" not in sanitized2
|
||||
assert "code=%5BREDACTED%5D" in sanitized2 or "code=[REDACTED]" in sanitized2
|
||||
assert "token=%5BREDACTED%5D" in sanitized2 or "token=[REDACTED]" in sanitized2
|
||||
assert "state=xyz" in sanitized2
|
||||
|
||||
|
||||
def test_sanitize_request_bodies():
|
||||
"""JSON and urlencoded form request bodies are scrubbed."""
|
||||
# JSON body
|
||||
json_body = '{"username": "admin", "password": "superSecretPassword123!", "api_key": "sk-123456789"}'
|
||||
s_json = sanitize_body_text(json_body)
|
||||
assert "superSecretPassword123!" not in s_json
|
||||
assert "sk-123456789" not in s_json
|
||||
assert '"password": "[REDACTED]"' in s_json
|
||||
assert '"api_key": "[REDACTED]"' in s_json
|
||||
assert '"username": "admin"' in s_json
|
||||
|
||||
# Form-encoded body
|
||||
form_body = "username=alice&password=mypassword999&token=abcde&action=login"
|
||||
s_form = sanitize_body_text(form_body)
|
||||
assert "mypassword999" not in s_form
|
||||
assert "abcde" not in s_form
|
||||
assert "password=%5BREDACTED%5D" in s_form or "password=[REDACTED]" in s_form
|
||||
assert "token=%5BREDACTED%5D" in s_form or "token=[REDACTED]" in s_form
|
||||
assert "username=alice" in s_form
|
||||
|
||||
|
||||
def test_sanitize_valid_archive(tmp_path):
|
||||
"""Structured archive sanitization cleans HAR, trace, and masks source code without corruption."""
|
||||
zp = str(tmp_path / "valid_trace.zip")
|
||||
with zipfile.ZipFile(zp, "w") as z:
|
||||
# JSON trace record
|
||||
trace_record = {
|
||||
"type": "action",
|
||||
"name": "fill",
|
||||
"params": {"selector": "#password", "value": 'secret"quote"pass'},
|
||||
}
|
||||
z.writestr("0-trace.trace", json.dumps(trace_record).encode() + b"\n")
|
||||
|
||||
# Network record with inverted header keys and URL query
|
||||
net_record = {
|
||||
"type": "request",
|
||||
"request": {
|
||||
"url": "https://localhost:7013/api/auth/login?token=token_abc",
|
||||
"headers": [{"value": "Digest secret_digest", "name": "Authorization"}],
|
||||
"postData": '{"password": "my_request_pw"}',
|
||||
},
|
||||
}
|
||||
z.writestr("0-trace.network", json.dumps(net_record).encode() + b"\n")
|
||||
|
||||
# Embedded test source file
|
||||
z.writestr("resources/src@test_auth.js", b'const password = "admin_password";\n')
|
||||
|
||||
# Binary image screenshot
|
||||
z.writestr("screenshot.png", b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDRdiagnostic")
|
||||
|
||||
ok = sanitize_trace_archive(zp)
|
||||
assert ok is True
|
||||
assert os.path.exists(zp)
|
||||
|
||||
with zipfile.ZipFile(zp, "r") as z:
|
||||
# Verify trace record
|
||||
trace_data = json.loads(z.read("0-trace.trace").decode().strip())
|
||||
assert trace_data["params"]["value"] == "[REDACTED]"
|
||||
|
||||
# Verify network record
|
||||
net_data = json.loads(z.read("0-trace.network").decode().strip())
|
||||
assert "token_abc" not in net_data["request"]["url"]
|
||||
assert net_data["request"]["headers"][0]["value"] == "[REDACTED]"
|
||||
assert "my_request_pw" not in net_data["request"]["postData"]
|
||||
|
||||
# Verify embedded source code was masked safely
|
||||
src_content = z.read("resources/src@test_auth.js").decode()
|
||||
assert "admin_password" not in src_content
|
||||
assert "[SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]" in src_content
|
||||
assert "const password: [REDACTED]" not in src_content # No syntax error!
|
||||
|
||||
# Verify screenshot is unmodified
|
||||
png = z.read("screenshot.png")
|
||||
assert png == b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDRdiagnostic"
|
||||
|
||||
|
||||
def test_fail_closed_malformed_zip(tmp_path):
|
||||
zp = str(tmp_path / "corrupt.zip")
|
||||
with open(zp, "wb") as f:
|
||||
f.write(b"this is completely invalid zip data PK\x03\x04 corrupt")
|
||||
|
||||
ok = sanitize_trace_archive(zp)
|
||||
assert ok is False
|
||||
# Fail-closed: corrupted zip must be deleted immediately!
|
||||
assert not os.path.exists(zp)
|
||||
|
||||
|
||||
def test_fail_closed_partial_archive(tmp_path):
|
||||
zp = str(tmp_path / "partial.zip")
|
||||
with open(zp, "wb") as f:
|
||||
f.write(b"PK\x03\x04\x14\x00\x00\x00")
|
||||
|
||||
ok = sanitize_trace_archive(zp)
|
||||
assert ok is False
|
||||
assert not os.path.exists(zp)
|
||||
|
||||
|
||||
def test_fail_closed_empty_archive(tmp_path):
|
||||
zp = str(tmp_path / "empty.zip")
|
||||
with open(zp, "wb") as f:
|
||||
pass
|
||||
|
||||
ok = sanitize_trace_archive(zp)
|
||||
assert ok is False
|
||||
assert not os.path.exists(zp)
|
||||
|
||||
|
||||
def test_fail_closed_on_unhandled_exception(tmp_path, monkeypatch):
|
||||
zp = str(tmp_path / "valid.zip")
|
||||
with zipfile.ZipFile(zp, "w") as z:
|
||||
z.writestr("test.trace", b'{"name": "fill"}\n')
|
||||
|
||||
def broken_sanitize(obj):
|
||||
raise RuntimeError("Simulated crash")
|
||||
|
||||
monkeypatch.setattr("scripts.sanitize_trace_artifacts.sanitize_structured_obj", broken_sanitize)
|
||||
|
||||
ok = sanitize_trace_archive(zp)
|
||||
assert ok is False
|
||||
assert not os.path.exists(zp)
|
||||
assert not os.path.exists(zp + ".tmp")
|
||||
|
||||
|
||||
def test_multiple_trace_archives_in_directory(tmp_path):
|
||||
sub1 = tmp_path / "test1"
|
||||
sub2 = tmp_path / "test2"
|
||||
sub1.mkdir()
|
||||
sub2.mkdir()
|
||||
|
||||
z1 = str(sub1 / "trace.zip")
|
||||
z2 = str(sub2 / "trace.zip")
|
||||
|
||||
with zipfile.ZipFile(z1, "w") as z:
|
||||
z.writestr("0.trace", b'{"type": "action", "params": {"value": "secret1"}}\n')
|
||||
with zipfile.ZipFile(z2, "w") as z:
|
||||
z.writestr("0.trace", b'{"type": "action", "params": {"value": "secret2"}}\n')
|
||||
|
||||
count = sanitize_directory(str(tmp_path))
|
||||
assert count == 2
|
||||
|
||||
assert os.path.exists(z1)
|
||||
with zipfile.ZipFile(z1, "r") as z:
|
||||
content = json.loads(z.read("0.trace").decode().strip())
|
||||
assert content["params"]["value"] == "[REDACTED]"
|
||||
|
||||
assert os.path.exists(z2)
|
||||
with zipfile.ZipFile(z2, "r") as z:
|
||||
content = json.loads(z.read("0.trace").decode().strip())
|
||||
assert content["params"]["value"] == "[REDACTED]"
|
||||
Reference in New Issue
Block a user