mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
Merge current lab into agent runtime decomposition
This commit is contained in:
@@ -26,6 +26,9 @@ secrets.env.*
|
||||
|
||||
# Data — all user data stays local
|
||||
data/
|
||||
# Per-worktree runtime state written by `odysseus dev` (its own data dir,
|
||||
# logs and stop handle) — disposable, and never shared between checkouts.
|
||||
.odysseus-dev/
|
||||
!services/hwfit/data/
|
||||
!services/hwfit/data/hf_models.json
|
||||
logs/
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Computed-style snapshot harness for ``static/style.css``.
|
||||
|
||||
``static/style.css`` is a single 51k-line stylesheet whose rendered result
|
||||
depends on source order: hundreds of selectors are declared more than once and
|
||||
``!important`` is used throughout. Any restructuring - extracting a block into
|
||||
its own file, reordering ``<link>`` tags, moving an ``@media`` rule - can
|
||||
silently change which declaration wins, and nothing else in the suite would
|
||||
notice.
|
||||
|
||||
This module captures ``getComputedStyle`` for a fixed inventory of elements
|
||||
across pages, viewports, themes and density modes, hashes the result, and
|
||||
compares it against a committed baseline. It moves no CSS. It only makes a move
|
||||
falsifiable.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/css_snapshot.py --check # compare to the baseline
|
||||
python scripts/css_snapshot.py --write-baseline # re-record it
|
||||
python scripts/css_snapshot.py --dump before.json # raw values, for diffing
|
||||
|
||||
With no ``--origin`` the script serves the repository over loopback on an
|
||||
ephemeral port for the duration of the run, so it works standalone. Under
|
||||
pytest the session static server is reused instead.
|
||||
|
||||
To see *which property* moved rather than just which element::
|
||||
|
||||
python scripts/css_snapshot.py --dump after.json
|
||||
git stash && python scripts/css_snapshot.py --dump before.json && git stash pop
|
||||
diff <(python -m json.tool before.json) <(python -m json.tool after.json)
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socketserver
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SNAPSHOT_DIR = ROOT / "tests" / "css_snapshot"
|
||||
INVENTORY_PATH = SNAPSHOT_DIR / "inventory.json"
|
||||
BASELINE_PATH = SNAPSHOT_DIR / "baseline.json"
|
||||
CAPTURE_SCRIPT = SNAPSHOT_DIR / "capture.mjs"
|
||||
|
||||
# A capture is ~70 page loads; on a warm checkout it runs in well under a
|
||||
# minute, but a cold `npx playwright install` machine can be slow to start
|
||||
# Chromium the first time.
|
||||
CAPTURE_TIMEOUT_SECONDS = 900
|
||||
|
||||
# Hash prefix length. 16 hex characters is 64 bits - far past any accidental
|
||||
# collision risk for a few thousand entries, and short enough that the baseline
|
||||
# stays readable in a diff.
|
||||
HASH_LENGTH = 16
|
||||
|
||||
|
||||
def load_inventory(path=INVENTORY_PATH):
|
||||
"""Load the checked-in element inventory."""
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def load_baseline(path=BASELINE_PATH):
|
||||
"""Load the committed baseline digest."""
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _canonical(value):
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def _hash(value):
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()[:HASH_LENGTH]
|
||||
|
||||
|
||||
def node_available(node="node"):
|
||||
"""True when the node binary is on PATH."""
|
||||
return shutil.which(node) is not None
|
||||
|
||||
|
||||
def playwright_available(node="node", cwd=ROOT):
|
||||
"""True when node can resolve the playwright package from the repo root.
|
||||
|
||||
Playwright is a devDependency installed by ``npm ci``; a clean checkout
|
||||
that has not run it cannot drive a browser at all.
|
||||
"""
|
||||
if not node_available(node):
|
||||
return False
|
||||
result = subprocess.run(
|
||||
[node, "-e", "require.resolve('playwright')"],
|
||||
cwd=str(cwd), capture_output=True, text=True, check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def capture(origin, inventory=None, *, swap_rule=None, variants=None,
|
||||
node="node", cwd=ROOT, timeout=CAPTURE_TIMEOUT_SECONDS):
|
||||
"""Drive the browser capture and return ``{"snapshot": ..., "missing": ...}``.
|
||||
|
||||
``swap_rule`` swaps the first two top-level declarations of one selector
|
||||
before the stylesheet reaches the browser. It exists for the harness
|
||||
self-test: a snapshot that does not move when two conflicting rules trade
|
||||
places is not evidence of anything.
|
||||
|
||||
``variants`` restricts the run to the named variants, for a faster
|
||||
focused capture.
|
||||
"""
|
||||
inventory = inventory or load_inventory()
|
||||
selected = inventory["variants"]
|
||||
if variants:
|
||||
wanted = set(variants)
|
||||
selected = [v for v in selected if v["name"] in wanted]
|
||||
unknown = wanted - {v["name"] for v in inventory["variants"]}
|
||||
if unknown:
|
||||
raise ValueError(f"unknown variants: {sorted(unknown)}")
|
||||
job = {
|
||||
"origin": origin.rstrip("/"),
|
||||
"properties": inventory["properties"],
|
||||
"variants": selected,
|
||||
"pages": inventory["pages"],
|
||||
"swapRule": swap_rule,
|
||||
}
|
||||
result = subprocess.run(
|
||||
[node, str(CAPTURE_SCRIPT)],
|
||||
input=json.dumps(job), cwd=str(cwd),
|
||||
capture_output=True, text=True, check=False, timeout=timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"css snapshot capture failed:\n{result.stderr.strip()}")
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def summarize(snapshot):
|
||||
"""Reduce a raw capture to the committed digest shape.
|
||||
|
||||
Two orthogonal projections are stored rather than one hash per
|
||||
(element, variant) pair: hashing every pair would commit ~5,000 lines that
|
||||
nobody reads, while a single global digest would only ever say "something
|
||||
moved". Per-element and per-variant hashes localise a failure from both
|
||||
directions - which element drifted, and in which variant - for a file small
|
||||
enough to review.
|
||||
"""
|
||||
elements = {}
|
||||
variants = {}
|
||||
for page, per_variant in snapshot.items():
|
||||
element_values = {}
|
||||
variants[page] = {}
|
||||
for variant, measured in per_variant.items():
|
||||
variants[page][variant] = _hash(measured)
|
||||
for key, values in measured.items():
|
||||
element_values.setdefault(key, {})[variant] = values
|
||||
elements[page] = {key: _hash(values) for key, values in element_values.items()}
|
||||
return {
|
||||
"digest": _hash(snapshot),
|
||||
"elements": elements,
|
||||
"variants": variants,
|
||||
}
|
||||
|
||||
|
||||
def compare(baseline, current):
|
||||
"""Return the drift between a committed baseline and a fresh summary."""
|
||||
drift = {"digest_changed": baseline.get("digest") != current["digest"],
|
||||
"elements": [], "variants": []}
|
||||
for section in ("elements", "variants"):
|
||||
old = baseline.get(section, {})
|
||||
new = current.get(section, {})
|
||||
for page in sorted(set(old) | set(new)):
|
||||
old_page = old.get(page, {})
|
||||
new_page = new.get(page, {})
|
||||
for key in sorted(set(old_page) | set(new_page)):
|
||||
if old_page.get(key) != new_page.get(key):
|
||||
drift[section].append(f"{page}/{key}")
|
||||
return drift
|
||||
|
||||
|
||||
def serve_repository(root=ROOT):
|
||||
"""Serve the repository over loopback on an ephemeral port.
|
||||
|
||||
Mirrors the browser-test static server in ``tests/conftest.py`` so the CLI
|
||||
can run outside pytest. Returns ``(origin, shutdown)``.
|
||||
"""
|
||||
root = Path(root).resolve()
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=str(root), **kwargs)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def guess_type(self, path):
|
||||
if path.endswith(".js") or path.endswith(".mjs"):
|
||||
return "application/javascript"
|
||||
if path.endswith(".css"):
|
||||
return "text/css"
|
||||
return super().guess_type(path)
|
||||
|
||||
class Server(socketserver.TCPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
server = Server(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def shutdown():
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
return f"http://127.0.0.1:{server.server_address[1]}", shutdown
|
||||
|
||||
|
||||
def _describe(drift, limit=25):
|
||||
lines = []
|
||||
for section in ("elements", "variants"):
|
||||
items = drift[section]
|
||||
if not items:
|
||||
continue
|
||||
shown = items[:limit]
|
||||
suffix = f" (+{len(items) - limit} more)" if len(items) > limit else ""
|
||||
lines.append(f" {section} that moved ({len(items)}): {', '.join(shown)}{suffix}")
|
||||
return "\n".join(lines) or " (no per-element drift; the digest itself changed)"
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--origin", help="static server origin to capture against; "
|
||||
"one is started on an ephemeral port when omitted")
|
||||
parser.add_argument("--write-baseline", action="store_true",
|
||||
help=f"re-record {BASELINE_PATH.relative_to(ROOT)}")
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="compare against the committed baseline (default)")
|
||||
parser.add_argument("--dump", metavar="PATH",
|
||||
help="write the raw computed values, for property-level diffing")
|
||||
parser.add_argument("--swap-rule", metavar="SELECTOR",
|
||||
help="swap the first two top-level declarations of SELECTOR "
|
||||
"before capturing (harness self-test)")
|
||||
parser.add_argument("--variants", help="comma-separated variant names to restrict the run to")
|
||||
parser.add_argument("--node", default="node", help="node binary to use")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not playwright_available(args.node):
|
||||
parser.error("node with the playwright package is required; run `npm ci` first")
|
||||
|
||||
variants = [v.strip() for v in args.variants.split(",")] if args.variants else None
|
||||
shutdown = None
|
||||
origin = args.origin or os.environ.get("ODYSSEUS_TEST_STATIC_ORIGIN")
|
||||
if not origin:
|
||||
origin, shutdown = serve_repository()
|
||||
try:
|
||||
captured = capture(origin, swap_rule=args.swap_rule, variants=variants, node=args.node)
|
||||
finally:
|
||||
if shutdown:
|
||||
shutdown()
|
||||
|
||||
if captured["missing"]:
|
||||
print("inventory entries that matched no element:", file=sys.stderr)
|
||||
for scope, keys in sorted(captured["missing"].items()):
|
||||
print(f" {scope}: {', '.join(keys)}", file=sys.stderr)
|
||||
|
||||
summary = summarize(captured["snapshot"])
|
||||
|
||||
if args.dump:
|
||||
Path(args.dump).write_text(json.dumps(captured["snapshot"], indent=1, sort_keys=True) + "\n",
|
||||
encoding="utf-8")
|
||||
print(f"raw values written to {args.dump}")
|
||||
|
||||
if args.write_baseline:
|
||||
if variants or args.swap_rule:
|
||||
parser.error("--write-baseline needs a full, unmutated capture: "
|
||||
"drop --variants and --swap-rule")
|
||||
BASELINE_PATH.write_text(json.dumps(summary, indent=1, sort_keys=True) + "\n",
|
||||
encoding="utf-8")
|
||||
print(f"baseline written: digest {summary['digest']}")
|
||||
return 0
|
||||
|
||||
baseline = load_baseline()
|
||||
drift = compare(baseline, summary)
|
||||
if not drift["digest_changed"] and not drift["elements"] and not drift["variants"]:
|
||||
print(f"computed styles match the baseline (digest {summary['digest']})")
|
||||
return 0
|
||||
print(f"computed styles moved: baseline {baseline.get('digest')} -> {summary['digest']}")
|
||||
print(_describe(drift))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+853
@@ -0,0 +1,853 @@
|
||||
#!/usr/bin/env python3
|
||||
"""odysseus-dev — boot the checkout you are standing in, isolated from every other one.
|
||||
|
||||
`start-macos.sh` is the single-instance launcher: it owns the Homebrew
|
||||
deps, the venv, and the production-shaped boot. It deliberately shares
|
||||
whatever is already listening — an open ChromaDB port is a resource it
|
||||
adopts. That is right for one instance and wrong for N worktrees, where
|
||||
adopting a port means writing into another checkout's vector store.
|
||||
|
||||
This tool is the sibling that owns isolation instead:
|
||||
|
||||
- ports are derived from the worktree path, so two checkouts never
|
||||
pick the same ones and the same checkout always picks its own;
|
||||
- a ChromaDB we did not start is never adopted — we start our own on
|
||||
our own port against our own data dir, or fall closed to keyword
|
||||
mode and say so;
|
||||
- the data dir, the database and the browser-MCP cache all live under
|
||||
`.odysseus-dev/`, so a dev boot leaves `data/` — what a normal launch
|
||||
of this checkout owns — untouched;
|
||||
- readiness is `/api/ready` (database, writable data dir, storage
|
||||
metadata), never a TCP accept and never `/api/health`, which is
|
||||
liveness only;
|
||||
- the app runs detached with durable logs and a recorded stop handle,
|
||||
so closing the terminal does not decide the instance's lifetime.
|
||||
|
||||
odysseus dev up # boot this worktree, print URL + stop handle
|
||||
odysseus dev up --from-pr 42 # fetch PR 42 into a worktree and boot that
|
||||
odysseus dev status # what is running here (JSON)
|
||||
odysseus dev down # stop what `up` started here
|
||||
odysseus dev ports # the derived port set (JSON)
|
||||
odysseus dev env # shell exports for running tests in this worktree
|
||||
|
||||
Every subcommand acts on the checkout containing the current working
|
||||
directory, so a single copy on $PATH serves every worktree.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib"))
|
||||
from cli import quiet_logs, emit, fail, common_parser, run # noqa: E402
|
||||
|
||||
quiet_logs()
|
||||
|
||||
import hashlib # noqa: E402
|
||||
import json # noqa: E402
|
||||
import signal # noqa: E402
|
||||
import socket # noqa: E402
|
||||
import subprocess # noqa: E402
|
||||
import time # noqa: E402
|
||||
import urllib.error # noqa: E402
|
||||
import urllib.request # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
|
||||
# Everything this tool writes lives under one directory inside the
|
||||
# worktree, next to but never inside `data/` — a dev boot must not be
|
||||
# able to corrupt the data dir a normal `start-macos.sh` run owns.
|
||||
DEV_DIR_NAME = ".odysseus-dev"
|
||||
STATE_FILE_NAME = "run.json"
|
||||
|
||||
# Files that identify a checkout root, so `odysseus dev` from any
|
||||
# subdirectory finds the worktree it belongs to.
|
||||
ROOT_MARKERS = ("app.py", "setup.py", "requirements.txt")
|
||||
|
||||
# Port block derivation. Three consecutive ports per worktree (app,
|
||||
# ChromaDB, test static server) starting at 7200; the last block ends at
|
||||
# 7799. The range is chosen to exclude every port the project already
|
||||
# means something by, so a derived port can never collide with a normal
|
||||
# launch on the same machine.
|
||||
PORT_BLOCK_BASE = 7200
|
||||
PORT_BLOCK_COUNT = 200
|
||||
PORTS_PER_BLOCK = 3
|
||||
|
||||
# Ports this tool refuses to use even when asked explicitly, with the
|
||||
# reason each one is spoken for.
|
||||
RESERVED_PORTS = {
|
||||
7000: "the historical app default (and macOS AirPlay Receiver)",
|
||||
7011: "the app's own default bind and the compose APP_PORT",
|
||||
7860: "start-macos.sh's default, i.e. a normal launch of this app",
|
||||
8100: "the default CHROMADB_PORT, i.e. someone else's vector store",
|
||||
}
|
||||
|
||||
READY_PATH = "/api/ready"
|
||||
HEALTH_PATH = "/api/health"
|
||||
LOGIN_PATH = "/api/auth/login"
|
||||
SESSION_COOKIE = "odysseus_session"
|
||||
DEFAULT_READY_TIMEOUT = 180
|
||||
STOP_GRACE_SECONDS = 10
|
||||
|
||||
# `/api/ready` is not in app.py's AUTH_EXEMPT_EXACT set, so readiness is
|
||||
# only observable with a session. The launcher therefore owns the dev
|
||||
# admin account: it generates the password once, hands it to setup.py,
|
||||
# keeps it here, and prints it — otherwise a generated password scrolls
|
||||
# past on first boot and the instance is unusable afterwards.
|
||||
CREDENTIALS_FILE_NAME = "admin.json"
|
||||
VENV_FILE_NAME = "venv-path"
|
||||
DEV_ADMIN_USER = "admin"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Locating the worktree
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def find_repo_root(start):
|
||||
"""Return the checkout root at or above `start`, or None.
|
||||
|
||||
Resolved from the working directory rather than from this file, so a
|
||||
symlink on $PATH still boots the worktree the user is standing in.
|
||||
"""
|
||||
current = Path(start).resolve()
|
||||
for candidate in [current, *current.parents]:
|
||||
if all((candidate / marker).exists() for marker in ROOT_MARKERS):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def dev_dir(root):
|
||||
return Path(root) / DEV_DIR_NAME
|
||||
|
||||
|
||||
def state_path(root):
|
||||
return dev_dir(root) / STATE_FILE_NAME
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Ports
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def derive_ports(root):
|
||||
"""Map a worktree path to its own block of three ports.
|
||||
|
||||
Deterministic: the same checkout gets the same ports on every run, so
|
||||
a bookmarked URL keeps working, and two checkouts only collide if
|
||||
their paths hash into the same block — which `up` detects and refuses
|
||||
rather than papers over.
|
||||
"""
|
||||
digest = hashlib.blake2s(str(Path(root).resolve()).encode("utf-8"), digest_size=8).digest()
|
||||
block = int.from_bytes(digest, "big") % PORT_BLOCK_COUNT
|
||||
base = PORT_BLOCK_BASE + block * PORTS_PER_BLOCK
|
||||
return {"app": base, "chroma": base + 1, "test_static": base + 2}
|
||||
|
||||
|
||||
def reserved_reason(port):
|
||||
"""Return why `port` is off limits, or None if it is usable."""
|
||||
return RESERVED_PORTS.get(int(port))
|
||||
|
||||
|
||||
def port_bound(port, host="127.0.0.1", timeout=0.4):
|
||||
"""True if something already accepts connections on host:port."""
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def unused_port():
|
||||
"""Ask the OS for a free port and release it immediately.
|
||||
|
||||
Used only to point CHROMADB_PORT at something that will refuse the
|
||||
connection, which is how the app falls back to keyword mode.
|
||||
"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Refusing to boot where a real instance lives
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def service_unit_dirs():
|
||||
home = Path.home()
|
||||
if sys.platform == "darwin":
|
||||
return [
|
||||
home / "Library" / "LaunchAgents",
|
||||
Path("/Library/LaunchAgents"),
|
||||
Path("/Library/LaunchDaemons"),
|
||||
]
|
||||
return [
|
||||
home / ".config" / "systemd" / "user",
|
||||
Path("/etc/systemd/system"),
|
||||
Path("/usr/lib/systemd/system"),
|
||||
]
|
||||
|
||||
|
||||
def managed_by_service(root, unit_dirs=None):
|
||||
"""Return the unit file naming a path inside `root`, or None.
|
||||
|
||||
A checkout wired into launchd or systemd is somebody's running
|
||||
instance: booting a second process out of it would share its source
|
||||
tree and, on the first mistake, its data. We refuse rather than trust
|
||||
the user to remember which directory this is. The match is on the
|
||||
path, so a unit pointing anywhere inside the checkout counts.
|
||||
"""
|
||||
needle = str(Path(root).resolve())
|
||||
for directory in unit_dirs if unit_dirs is not None else service_unit_dirs():
|
||||
try:
|
||||
entries = sorted(Path(directory).iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for entry in entries:
|
||||
if entry.suffix not in (".plist", ".service"):
|
||||
continue
|
||||
try:
|
||||
text = entry.read_text(errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
if needle in text:
|
||||
return str(entry)
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Process ownership
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def pid_command(pid):
|
||||
"""Return the full command line of `pid`, or "" if it is not ours to see."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ps", "-o", "command=", "-p", str(int(pid))],
|
||||
capture_output=True, text=True, timeout=5, check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError, ValueError):
|
||||
return ""
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def pid_is_ours(pid, fingerprints):
|
||||
"""True only when `pid` is alive AND its command line still shows every
|
||||
fingerprint we recorded when we started it.
|
||||
|
||||
A pid alone proves nothing — the number is reused. Everything that
|
||||
kills or adopts a process goes through here.
|
||||
"""
|
||||
if not pid:
|
||||
return False
|
||||
command = pid_command(pid)
|
||||
if not command:
|
||||
return False
|
||||
return all(str(mark) in command for mark in fingerprints)
|
||||
|
||||
|
||||
def pid_alive(pid):
|
||||
try:
|
||||
os.kill(int(pid), 0)
|
||||
except (OSError, TypeError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def credentials(root):
|
||||
"""Return this worktree's dev admin account, generating it once.
|
||||
|
||||
Stored outside the data dir so `down`, a wiped database, or a fresh
|
||||
`up` all keep the same login.
|
||||
"""
|
||||
path = dev_dir(root) / CREDENTIALS_FILE_NAME
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
import secrets
|
||||
|
||||
account = {"username": DEV_ADMIN_USER, "password": secrets.token_urlsafe(18)}
|
||||
dev_dir(root).mkdir(parents=True, exist_ok=True)
|
||||
with open(os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600), "w",
|
||||
encoding="utf-8") as handle:
|
||||
json.dump(account, handle, indent=2)
|
||||
return account
|
||||
|
||||
|
||||
def read_state(root):
|
||||
try:
|
||||
with open(state_path(root), encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def write_state(root, state):
|
||||
dev_dir(root).mkdir(parents=True, exist_ok=True)
|
||||
with open(state_path(root), "w", encoding="utf-8") as handle:
|
||||
json.dump(state, handle, indent=2)
|
||||
|
||||
|
||||
def running_app(state):
|
||||
"""Return the recorded app entry if that exact process is still alive."""
|
||||
app = (state or {}).get("app") or {}
|
||||
if pid_is_ours(app.get("pid"), app.get("fingerprints") or []):
|
||||
return app
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# ChromaDB
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def start_chroma(venv_python, port, chroma_path, log_path):
|
||||
"""Start our own ChromaDB, or explain why we are going without one.
|
||||
|
||||
Returns (entry_or_None, note). Adopting a foreign server is not one of
|
||||
the outcomes: the caller has already established that the port is free.
|
||||
"""
|
||||
binary = Path(venv_python).parent / "chroma"
|
||||
if not binary.exists():
|
||||
return None, (
|
||||
"keyword-only mode: no `chroma` binary in this venv "
|
||||
"(requirements.txt pins chromadb-client, the HTTP client). "
|
||||
f"Install the server with `{venv_python} -m pip install chromadb` to enable vectors."
|
||||
)
|
||||
chroma_path.mkdir(parents=True, exist_ok=True)
|
||||
command = [
|
||||
str(binary), "run",
|
||||
"--host", "127.0.0.1",
|
||||
"--port", str(port),
|
||||
"--path", str(chroma_path),
|
||||
]
|
||||
with open(log_path, "ab") as log:
|
||||
process = subprocess.Popen(
|
||||
command, stdout=log, stderr=subprocess.STDOUT,
|
||||
start_new_session=True, cwd=str(chroma_path.parent),
|
||||
)
|
||||
entry = {
|
||||
"pid": process.pid,
|
||||
"port": port,
|
||||
"path": str(chroma_path),
|
||||
"log": str(log_path),
|
||||
# The data path is the identity: it is unique to this worktree
|
||||
# and appears in the command line whichever way ps resolves the
|
||||
# console script.
|
||||
"fingerprints": ["chroma", str(chroma_path)],
|
||||
}
|
||||
return entry, f"own server on 127.0.0.1:{port} against {chroma_path}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Readiness
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def http_json(port, path, payload=None, cookie=None, timeout=5.0):
|
||||
"""One request against the local instance. Returns (status, body)."""
|
||||
url = f"http://127.0.0.1:{port}{path}"
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
headers = {"Content-Type": "application/json"} if data else {}
|
||||
if cookie:
|
||||
headers["Cookie"] = f"{SESSION_COOKIE}={cookie}"
|
||||
request = urllib.request.Request(url, data=data, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.status, _decode(response.read()), response
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, _decode(exc.read()), exc
|
||||
except (OSError, ValueError) as exc:
|
||||
return 0, {"error": str(exc)}, None
|
||||
|
||||
|
||||
def _decode(raw):
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def login(port, account):
|
||||
"""Return a session cookie for the dev admin, or None."""
|
||||
status, _, response = http_json(port, LOGIN_PATH, payload={
|
||||
"username": account["username"], "password": account["password"],
|
||||
})
|
||||
if status != 200 or response is None:
|
||||
return None
|
||||
for header in response.headers.get_all("Set-Cookie") or []:
|
||||
if header.startswith(f"{SESSION_COOKIE}="):
|
||||
return header.split(";", 1)[0].split("=", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def probe_ready(port, cookie=None, timeout=5.0):
|
||||
"""GET /api/ready once. Returns (ready, payload).
|
||||
|
||||
/api/health only proves the process is alive. /api/ready is the one
|
||||
that checks the database, a writable data dir and storage metadata,
|
||||
and it answers 503 until all three hold — which is why a TCP accept
|
||||
is not what this tool waits for.
|
||||
"""
|
||||
status, body, _ = http_json(port, READY_PATH, cookie=cookie, timeout=timeout)
|
||||
body = dict(body or {})
|
||||
body.setdefault("status", status)
|
||||
return bool(body.get("ready")), body
|
||||
|
||||
|
||||
def wait_ready(port, process, timeout, log_path, account):
|
||||
"""Wait for liveness, authenticate, then wait for real readiness."""
|
||||
deadline = time.monotonic() + timeout
|
||||
|
||||
def alive():
|
||||
if process is not None and process.poll() is not None:
|
||||
fail(
|
||||
f"the app exited with code {process.returncode} before becoming ready.\n"
|
||||
f" last lines of {log_path}:\n{tail(log_path, 20)}"
|
||||
)
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
alive()
|
||||
if http_json(port, HEALTH_PATH, timeout=2.0)[0] == 200:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
# One login, not one per poll: the login route is rate limited.
|
||||
cookie, last = None, {}
|
||||
while time.monotonic() < deadline and cookie is None:
|
||||
alive()
|
||||
cookie = login(port, account)
|
||||
if cookie is None:
|
||||
time.sleep(3)
|
||||
if cookie is None:
|
||||
fail(
|
||||
f"could not log in as {account['username']} to read {READY_PATH}.\n"
|
||||
f" The recorded credentials may not match this data dir. Remove "
|
||||
f"{Path(log_path).parent.parent / CREDENTIALS_FILE_NAME} and the data dir "
|
||||
f"to start clean.\n"
|
||||
f" The app is running; stop it with `odysseus dev down`."
|
||||
)
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
alive()
|
||||
ready, last = probe_ready(port, cookie=cookie)
|
||||
if ready:
|
||||
return last
|
||||
time.sleep(1)
|
||||
fail(
|
||||
f"{READY_PATH} did not report ready within {timeout}s.\n"
|
||||
f" last response: {json.dumps(last, default=str)[:400]}\n"
|
||||
f" the app is still running; logs: {log_path}\n"
|
||||
f" stop it with `odysseus dev down`"
|
||||
)
|
||||
|
||||
|
||||
def tail(path, lines):
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||
return "".join(f" {line}" for line in handle.readlines()[-lines:])
|
||||
except OSError:
|
||||
return " (no log)"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# git helpers for --from-pr
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def git(root, *args, check=True):
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), *args],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
fail(f"git {' '.join(args)} failed: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def worktree_for_pr(root, number, remote):
|
||||
"""Fetch a pull request head into its own worktree and return its path.
|
||||
|
||||
`pull/<n>/head` is served by the repository the PR targets, so this
|
||||
works for forks without knowing anything about the fork layout.
|
||||
"""
|
||||
target = Path(root).resolve().parent / f"{Path(root).resolve().name}-pr{number}"
|
||||
if target.exists():
|
||||
sys.stdout.write(f" worktree for PR {number} already exists at {target}\n")
|
||||
return target
|
||||
git(root, "fetch", remote, f"pull/{number}/head")
|
||||
head = git(root, "rev-parse", "FETCH_HEAD")
|
||||
git(root, "worktree", "add", "--detach", str(target), head)
|
||||
sys.stdout.write(f" PR {number} checked out at {target} ({head[:8]})\n")
|
||||
return target
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Commands
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def resolve_root(args):
|
||||
root = find_repo_root(Path.cwd())
|
||||
if root is None:
|
||||
fail(
|
||||
"not inside an Odysseus checkout "
|
||||
f"(looked for {', '.join(ROOT_MARKERS)} from {Path.cwd()} upwards)",
|
||||
code=2,
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def resolve_ports(root, args):
|
||||
ports = derive_ports(root)
|
||||
for name, override in (("app", getattr(args, "port", None)),
|
||||
("chroma", getattr(args, "chroma_port", None))):
|
||||
if override:
|
||||
ports[name] = int(override)
|
||||
for name, port in ports.items():
|
||||
reason = reserved_reason(port)
|
||||
if reason:
|
||||
fail(f"port {port} is {reason}; refusing to use it as the {name} port")
|
||||
return ports
|
||||
|
||||
|
||||
def remembered_venv(root):
|
||||
"""The venv a previous `up` borrowed for this worktree, if any."""
|
||||
try:
|
||||
return Path(dev_dir(root).joinpath(VENV_FILE_NAME).read_text(encoding="utf-8").strip())
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def remember_venv(root, venv_root):
|
||||
dev_dir(root).mkdir(parents=True, exist_ok=True)
|
||||
dev_dir(root).joinpath(VENV_FILE_NAME).write_text(str(venv_root), encoding="utf-8")
|
||||
|
||||
|
||||
def resolve_venv(root, args):
|
||||
"""Pick the interpreter to run the app with. This tool never builds a
|
||||
venv — `--venv` pointing at a sibling worktree's environment is what
|
||||
makes booting a PR take seconds rather than minutes, and the choice is
|
||||
remembered so the next `up` in that worktree does not need the flag."""
|
||||
candidates = [
|
||||
Path(args.venv).expanduser().resolve() if args.venv else None,
|
||||
Path(root) / "venv",
|
||||
remembered_venv(root),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate and (candidate / "bin" / "python").exists():
|
||||
return candidate / "bin" / "python"
|
||||
if args.venv:
|
||||
fail(f"no interpreter at {Path(args.venv).expanduser().resolve() / 'bin' / 'python'}")
|
||||
fail(
|
||||
f"no venv at {Path(root) / 'venv'}.\n"
|
||||
f" build one with ./start-macos.sh, or reuse another worktree's "
|
||||
f"with --venv /path/to/worktree/venv"
|
||||
)
|
||||
|
||||
|
||||
def refuse_if_taken(root, args, ports, state):
|
||||
"""Stop before anything is started if this worktree cannot own the boot."""
|
||||
unit = managed_by_service(root)
|
||||
if unit:
|
||||
fail(
|
||||
f"{root} is run as a service by {unit}.\n"
|
||||
f" That is a real instance, not a scratch worktree. Boot a separate "
|
||||
f"checkout instead:\n"
|
||||
f" git worktree add ../odysseus-dev <ref> && cd ../odysseus-dev"
|
||||
)
|
||||
if port_bound(ports["app"]):
|
||||
fail(
|
||||
f"port {ports['app']} is already in use by a process we do not own.\n"
|
||||
f" This worktree derives that port from its path, so something else "
|
||||
f"took it.\n"
|
||||
f" Re-run with --port <n> to pick another."
|
||||
)
|
||||
|
||||
|
||||
def resolve_chroma(args, state, ports, venv_python, data_dir, log_dir):
|
||||
"""Decide what this worktree talks to for vectors.
|
||||
|
||||
Returns (state_entry, port, note). The one outcome this never
|
||||
produces is a port somebody else is serving: the whole tool exists
|
||||
because `start-macos.sh` treats that as a resource to adopt.
|
||||
"""
|
||||
if args.no_chroma:
|
||||
# Point at a port nothing is listening on rather than at the
|
||||
# derived one, which may be exactly the foreign server we are
|
||||
# refusing to touch. Connection refused is what makes the app
|
||||
# fall back to keyword search.
|
||||
return None, unused_port(), "disabled by --no-chroma"
|
||||
|
||||
if port_bound(ports["chroma"]):
|
||||
ours = (state or {}).get("chroma") or {}
|
||||
if pid_is_ours(ours.get("pid"), ours.get("fingerprints") or []):
|
||||
return ours, ours["port"], f"reusing the server we started earlier on {ours['port']}"
|
||||
fail(
|
||||
f"port {ports['chroma']} is serving a ChromaDB this worktree did not start.\n"
|
||||
f" Adopting it would read and write another checkout's vectors, so we "
|
||||
f"will not.\n"
|
||||
f" Re-run with --chroma-port <n>, or with --no-chroma to run in "
|
||||
f"keyword-only mode."
|
||||
)
|
||||
|
||||
entry, note = start_chroma(
|
||||
venv_python, ports["chroma"], data_dir / "chroma", log_dir / "chroma.log"
|
||||
)
|
||||
# If we could not start one, point the app at a port nothing is on
|
||||
# rather than at our derived one: otherwise a ChromaDB that binds
|
||||
# that port later would be adopted by a running app, which is the
|
||||
# exact failure this tool exists to prevent.
|
||||
return entry, (ports["chroma"] if entry else unused_port()), note
|
||||
|
||||
|
||||
def boot_environment(account, ports, chroma_port, data_dir):
|
||||
"""The environment that makes the child process this worktree's own."""
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
"ODYSSEUS_ADMIN_USER": account["username"],
|
||||
"ODYSSEUS_ADMIN_PASSWORD": account["password"],
|
||||
"APP_PORT": str(ports["app"]),
|
||||
"APP_BIND": "127.0.0.1",
|
||||
"ODYSSEUS_DATA_DIR": str(data_dir),
|
||||
"DATABASE_URL": f"sqlite:///{data_dir / 'app.db'}",
|
||||
# src/builtin_mcp.py derives this cache from a literal "data"
|
||||
# under the app root rather than from DATA_DIR, so without an
|
||||
# explicit value a dev boot would write into the checkout's
|
||||
# data/ after all. Pointing it at our own dir keeps the
|
||||
# isolation claim true.
|
||||
"ODYSSEUS_BROWSER_MCP_CACHE": str(data_dir / "playwright-mcp-cache"),
|
||||
"CHROMADB_HOST": "127.0.0.1",
|
||||
"CHROMADB_PORT": str(chroma_port),
|
||||
"ODYSSEUS_TEST_STATIC_PORT": str(ports["test_static"]),
|
||||
"ODYSSEUS_NO_OPEN": "1",
|
||||
"ODYSSEUS_SKIP_RUN_HINT": "1",
|
||||
"ODYSSEUS_SKIP_ADMIN_PROMPT": "1",
|
||||
})
|
||||
return env
|
||||
|
||||
|
||||
def run_setup(root, venv_python, env, data_dir, log_dir):
|
||||
"""Create the data dir, database and admin account. Idempotent."""
|
||||
sys.stdout.write(f" preparing {data_dir} (setup.py is idempotent)\n")
|
||||
setup = subprocess.run(
|
||||
[str(venv_python), "setup.py"], cwd=str(root), env=env,
|
||||
capture_output=True, text=True, stdin=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
log = log_dir / "setup.log"
|
||||
with open(log, "w", encoding="utf-8") as handle:
|
||||
handle.write(setup.stdout + setup.stderr)
|
||||
if setup.returncode != 0:
|
||||
fail(f"setup.py failed; see {log}\n{tail(log, 15)}")
|
||||
|
||||
|
||||
def borrow_venv_for_pr(root, args):
|
||||
"""A fresh PR worktree has no venv; the one we came from will do."""
|
||||
if args.venv or (root / "venv" / "bin" / "python").exists():
|
||||
return
|
||||
source_venv = find_repo_root(Path.cwd()) / "venv"
|
||||
if (source_venv / "bin" / "python").exists():
|
||||
args.venv = str(source_venv)
|
||||
sys.stdout.write(f" reusing {source_venv} (the PR worktree has none)\n")
|
||||
|
||||
|
||||
def cmd_up(args):
|
||||
root = resolve_root(args)
|
||||
if args.from_pr:
|
||||
root = worktree_for_pr(root, args.from_pr, args.remote)
|
||||
borrow_venv_for_pr(root, args)
|
||||
|
||||
state = read_state(root)
|
||||
already = running_app(state)
|
||||
if already:
|
||||
sys.stdout.write(
|
||||
f"already up: http://127.0.0.1:{already['port']} (pid {already['pid']})\n"
|
||||
f"stop it with `odysseus dev down`, or re-run after that to restart.\n"
|
||||
)
|
||||
return
|
||||
|
||||
ports = resolve_ports(root, args)
|
||||
refuse_if_taken(root, args, ports, state)
|
||||
venv_python = resolve_venv(root, args)
|
||||
remember_venv(root, venv_python.parent.parent)
|
||||
|
||||
data_dir = dev_dir(root) / "data"
|
||||
log_dir = dev_dir(root) / "logs"
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
app_log = log_dir / "app.log"
|
||||
|
||||
chroma_entry, chroma_port, chroma_note = resolve_chroma(
|
||||
args, state, ports, venv_python, data_dir, log_dir
|
||||
)
|
||||
account = credentials(root)
|
||||
env = boot_environment(account, ports, chroma_port, data_dir)
|
||||
run_setup(root, venv_python, env, data_dir, log_dir)
|
||||
|
||||
command = [
|
||||
str(venv_python), "-m", "uvicorn", "app:app",
|
||||
"--host", "127.0.0.1", "--port", str(ports["app"]),
|
||||
]
|
||||
if args.foreground:
|
||||
sys.stdout.write(f" starting in the foreground on http://127.0.0.1:{ports['app']}\n")
|
||||
os.execve(str(venv_python), command, env)
|
||||
|
||||
with open(app_log, "ab") as log:
|
||||
process = subprocess.Popen(
|
||||
command, cwd=str(root), env=env, stdout=log, stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL, start_new_session=True,
|
||||
)
|
||||
|
||||
state = {
|
||||
"root": str(root),
|
||||
"started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"commit": git(root, "rev-parse", "--short", "HEAD", check=False),
|
||||
"branch": git(root, "rev-parse", "--abbrev-ref", "HEAD", check=False),
|
||||
"venv": str(Path(venv_python).parent.parent),
|
||||
"data_dir": str(data_dir),
|
||||
"ports": ports,
|
||||
"app": {
|
||||
"pid": process.pid,
|
||||
"port": ports["app"],
|
||||
"log": str(app_log),
|
||||
# The interpreter path is not one of these on purpose: macOS
|
||||
# reports the framework binary a venv symlinks to, not the
|
||||
# venv path we launched. The port is derived per worktree, so
|
||||
# it is the part that actually identifies this instance.
|
||||
"fingerprints": ["uvicorn", "app:app", f"--port {ports['app']}"],
|
||||
},
|
||||
"chroma": chroma_entry,
|
||||
}
|
||||
write_state(root, state)
|
||||
|
||||
sys.stdout.write(f" waiting for {READY_PATH} (up to {args.timeout}s)\n")
|
||||
report = wait_ready(ports["app"], process, args.timeout, app_log, account)
|
||||
|
||||
sys.stdout.write(
|
||||
f"\nOdysseus is up — this worktree only.\n\n"
|
||||
f" URL http://127.0.0.1:{ports['app']}\n"
|
||||
f" Login {account['username']} / {account['password']}\n"
|
||||
f" Worktree {root} ({state['branch']} @ {state['commit']})\n"
|
||||
f" Data dir {data_dir}\n"
|
||||
f" ChromaDB {chroma_note}\n"
|
||||
f" Test port {ports['test_static']} (ODYSSEUS_TEST_STATIC_PORT; see `odysseus dev env`)\n"
|
||||
f" Logs {app_log}\n"
|
||||
f" Ready {json.dumps({k: v.get('ok') for k, v in report.get('checks', {}).items()})}\n"
|
||||
f" Stop with odysseus dev down\n"
|
||||
)
|
||||
|
||||
|
||||
def cmd_down(args):
|
||||
root = resolve_root(args)
|
||||
state = read_state(root)
|
||||
stopped, unclaimed = [], []
|
||||
for name in ("app", "chroma"):
|
||||
entry = (state or {}).get(name) or {}
|
||||
pid = entry.get("pid")
|
||||
if not pid_is_ours(pid, entry.get("fingerprints") or []):
|
||||
if pid_alive(pid):
|
||||
# Alive but no longer recognisable: signalling it would be
|
||||
# signalling a stranger. Say so and keep the record.
|
||||
unclaimed.append(f"{name} (pid {pid})")
|
||||
continue
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
deadline = time.monotonic() + STOP_GRACE_SECONDS
|
||||
while time.monotonic() < deadline and pid_is_ours(pid, entry.get("fingerprints") or []):
|
||||
time.sleep(0.2)
|
||||
if pid_is_ours(pid, entry.get("fingerprints") or []):
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
stopped.append(f"{name} (pid {pid})")
|
||||
if stopped:
|
||||
sys.stdout.write(f"stopped {', '.join(stopped)}.\n")
|
||||
elif not unclaimed:
|
||||
sys.stdout.write("nothing this worktree started is still running.\n")
|
||||
if unclaimed:
|
||||
sys.stdout.write(
|
||||
f"left alone: {', '.join(unclaimed)} — still alive but no longer matching "
|
||||
f"what we recorded. Check it before killing it; {state_path(root)} is kept.\n"
|
||||
)
|
||||
return
|
||||
try:
|
||||
state_path(root).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
root = resolve_root(args)
|
||||
state = read_state(root)
|
||||
app = running_app(state)
|
||||
chroma = (state or {}).get("chroma") or {}
|
||||
ready = False
|
||||
if app:
|
||||
ready = probe_ready(app["port"], cookie=login(app["port"], credentials(root)))[0]
|
||||
emit({
|
||||
"root": str(root),
|
||||
"running": bool(app),
|
||||
"url": f"http://127.0.0.1:{app['port']}" if app else None,
|
||||
"ready": ready,
|
||||
"chroma_running": pid_is_ours(chroma.get("pid"), chroma.get("fingerprints") or []),
|
||||
"ports": (state or {}).get("ports") or derive_ports(root),
|
||||
"state_file": str(state_path(root)),
|
||||
}, args)
|
||||
|
||||
|
||||
def cmd_ports(args):
|
||||
root = resolve_root(args)
|
||||
ports = derive_ports(root)
|
||||
emit({
|
||||
"root": str(root),
|
||||
"ports": ports,
|
||||
"in_use": {name: port_bound(port) for name, port in ports.items()},
|
||||
}, args)
|
||||
|
||||
|
||||
def cmd_env(args):
|
||||
"""Print the isolated environment as shell exports, so a test run in
|
||||
this worktree uses the same ports and data dir the app does."""
|
||||
root = resolve_root(args)
|
||||
ports = derive_ports(root)
|
||||
data = dev_dir(root) / "data"
|
||||
for key, value in (
|
||||
("APP_PORT", ports["app"]),
|
||||
("ODYSSEUS_TEST_STATIC_PORT", ports["test_static"]),
|
||||
("CHROMADB_PORT", ports["chroma"]),
|
||||
("ODYSSEUS_DATA_DIR", data),
|
||||
("DATABASE_URL", f"sqlite:///{data / 'app.db'}"),
|
||||
):
|
||||
sys.stdout.write(f"export {key}={value}\n")
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = common_parser("odysseus-dev", "Boot this worktree in isolation.")
|
||||
common = parser._common_parents[0]
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
|
||||
up = sub.add_parser("up", parents=[common], help="boot this worktree")
|
||||
up.add_argument("--port", type=int, help="override the derived app port")
|
||||
up.add_argument("--chroma-port", type=int, help="override the derived ChromaDB port")
|
||||
up.add_argument("--no-chroma", action="store_true",
|
||||
help="run without vectors (keyword mode) instead of starting a server")
|
||||
up.add_argument("--venv", help="use this venv instead of ./venv (e.g. a sibling worktree's)")
|
||||
up.add_argument("--from-pr", type=int, metavar="N",
|
||||
help="fetch pull request N into its own worktree and boot that")
|
||||
up.add_argument("--remote", default="origin", help="remote to fetch the PR from")
|
||||
up.add_argument("--foreground", action="store_true",
|
||||
help="run uvicorn in this terminal instead of detaching")
|
||||
up.add_argument("--timeout", type=int, default=DEFAULT_READY_TIMEOUT,
|
||||
help=f"seconds to wait for {READY_PATH} (default: {DEFAULT_READY_TIMEOUT})")
|
||||
up.set_defaults(func=cmd_up)
|
||||
|
||||
down = sub.add_parser("down", parents=[common], help="stop what `up` started here")
|
||||
down.set_defaults(func=cmd_down)
|
||||
|
||||
status = sub.add_parser("status", parents=[common], help="what is running in this worktree")
|
||||
status.set_defaults(func=cmd_status)
|
||||
|
||||
ports = sub.add_parser("ports", parents=[common], help="the derived port set")
|
||||
ports.set_defaults(func=cmd_ports)
|
||||
|
||||
env = sub.add_parser("env", parents=[common], help="shell exports for this worktree")
|
||||
env.set_defaults(func=cmd_env)
|
||||
|
||||
parser.set_defaults(func=lambda args: parser.print_help())
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser()))
|
||||
+5
-3
@@ -136,6 +136,8 @@ The Settings finder and navigation are registry-backed, hide admin-only destinat
|
||||
|
||||
Existing frontend coverage is a mix of Node-executed helper tests, `.mjs` tests, static DOM/CSS/source-shape tests, browser exploration specs, and app/static tests. Many tests are useful source-shape regressions but do not replace browser/module-graph execution.
|
||||
|
||||
`tests/test_css_computed_style_snapshot.py` pins `getComputedStyle` for a fixed element inventory across pages, viewports, themes and density modes, so a `static/style.css` restructuring that changes which declaration wins fails a test instead of shipping; see `tests/css_snapshot/README.md` for what it does and does not cover.
|
||||
|
||||
Recent focused coverage includes model-key matching under Node, document-library counters, chat resend/delete/mobile Enter/ArrowUp, scoped approval continuation and compare routing, route provenance, live-thinking throttling, startup shell/history hydration, shared app-config caching/invalidation, settings registry/navigation/finder/lifecycle, lazy panel loading/offline editor precache, vendored lazy KaTeX/Mermaid rendering, email read dedup/prewarm, Markdown restoration, malformed keybinds, currency-safe inline math, notes/calendar/modal/manifest/admin-log behavior, Markdown XSS helpers, and CardDAV unchanged-password handling.
|
||||
|
||||
Missing coverage includes:
|
||||
@@ -143,7 +145,7 @@ Missing coverage includes:
|
||||
- SPA route/static auth and no-cache headers;
|
||||
- CSP header contents and nonce injection for `/` and `/login`;
|
||||
- service-worker API/non-GET bypass and cache strategy;
|
||||
- service-worker precache versus `index.html` script/module tags, including query strings;
|
||||
- service-worker precache versus `index.html` script/module tags, including query strings (stylesheet links and their `?v=` strings are covered by `tests/test_static_stylesheet_manifest.py`; script and module tags are not);
|
||||
- ongoing manifest/icon reference drift;
|
||||
- module graph/load-order validation;
|
||||
- degraded vendor-library/browser API behavior, including Pyodide's remaining CDN path.
|
||||
@@ -151,8 +153,8 @@ Missing coverage includes:
|
||||
## Current Gaps
|
||||
|
||||
- `static/style.css` and large coordinators remain high-risk owners: `static/js/document.js`, `static/js/settings.js`, `static/js/chat.js`, and `static/app.js`.
|
||||
- There is no build-time type checking, module graph validation, script-order validation, or service-worker precache validation.
|
||||
- There is no build-time type checking, module graph validation, or script-order validation. Service-worker precache validation exists for stylesheets only.
|
||||
- Frontend state is mostly module/global/localStorage driven, so cross-session and cross-user behavior needs explicit care.
|
||||
- `window.*` compatibility bridges remain widespread.
|
||||
- PWA/static-serving behavior may deserve a separate spec if service worker, manifests, route-specific icons, and cache policy keep growing.
|
||||
- A static asset/route manifest regression should verify files referenced by `index.html`, `manifest.json`, `sw.js`, and app-owned HTML routes actually exist.
|
||||
- The static asset/route manifest regression covers stylesheets referenced by app-owned HTML and `sw.js`; scripts, modules and `manifest.json` icon references are still unverified.
|
||||
|
||||
@@ -119,6 +119,12 @@ def _browser_pid_file_candidates(
|
||||
)
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
# Linux exposes one command line per pid under /proc; macOS and Windows do not.
|
||||
# Kept as a module attribute so the procfs-dependent paths stay testable on a
|
||||
# host that has no procfs, and on one that does.
|
||||
_PROC_ROOT = Path("/proc")
|
||||
|
||||
_SCHOLARLY_METADATA_CUE_RE = re.compile(
|
||||
r"\b(?:accept(?:ed|ance)?|publish(?:ed|ing|cation)?|venue|conference|"
|
||||
r"journal|proceedings|doi)\b",
|
||||
@@ -2316,8 +2322,14 @@ class PrivateBrowserTool:
|
||||
except OSError:
|
||||
return
|
||||
profile_prefix = str(tmpdir / "agent-browser-chrome-")
|
||||
if not _PROC_ROOT.is_dir():
|
||||
# Without procfs there is no way to match a reparented Chrome by
|
||||
# its command line, and the sweep is an optimisation rather than a
|
||||
# correctness requirement. Leave those trees to the daemon's own
|
||||
# lifecycle instead of failing the whole shutdown path.
|
||||
return
|
||||
pids: list[int] = []
|
||||
for entry in Path("/proc").iterdir():
|
||||
for entry in _PROC_ROOT.iterdir():
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,962 @@
|
||||
/* Document library and editor, image gallery, image editor.
|
||||
*
|
||||
* Split out of style.css for maintainability, not for performance: this file
|
||||
* is loaded eagerly from index.html immediately after style.css, so the rules
|
||||
* land in the cascade in exactly the order they had inside the single file.
|
||||
* Nothing here is lazy-loaded.
|
||||
*
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. The <link> tags in static/index.html and
|
||||
* the PRECACHE list in static/sw.js must both list the stylesheets in this
|
||||
* order:
|
||||
* 1. static/style.css
|
||||
* 2. static/css/documents-gallery-editor.css
|
||||
* 3. static/css/email-calendar-notes-tasks.css
|
||||
* 4. static/css/cookbook-research-memory-settings.css
|
||||
*
|
||||
* Rules were moved verbatim. A rule stays in style.css when its selector group
|
||||
* covers more than one panel, when the class is shared design language used by
|
||||
* modules outside this panel, or when moving it would change which of two
|
||||
* equally specific rules wins.
|
||||
*/
|
||||
|
||||
/* Document library search-term highlight — clear, high-contrast. */
|
||||
mark.doclib-search-hl {
|
||||
background: var(--accent, #e8a830);
|
||||
color: var(--bg, #1a1a1a);
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Research bulk bar — right-align the action buttons (keep All + count on
|
||||
the left). Cancel now lives on the Select toggle, so Archive anchors. */
|
||||
#doclib-research-bulk #doclib-research-bulk-archive {
|
||||
margin-left: auto;
|
||||
}
|
||||
/* Drafts bulk bar defaults to justify-content:flex-end (whole row hugs the
|
||||
right). Reset it so All + count sit on the left and only the action button
|
||||
is pushed right — matching every other bulk bar. */
|
||||
#gallery-editor-drafts-bulk {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.doc-divider.doc-divider-handle-idle .doc-divider-collapse {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.doc-divider:hover .doc-divider-collapse { opacity: 0.92; }
|
||||
/* Smooth entrance — slide in from the left + fade up so it doesn't snap into
|
||||
place when fullscreen activates. The chevron's vertical centering uses
|
||||
translateY(-50%), so the animation has to keep that part. */
|
||||
@keyframes doc-fs-chevron-in {
|
||||
from { opacity: 0; transform: translateY(-50%) translateX(-18px); }
|
||||
to { opacity: 0.85; transform: translateY(-50%) translateX(0); }
|
||||
}
|
||||
.doc-editor-pane.doc-pane-leaving {
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes doc-pane-leave {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(24px); opacity: 0; }
|
||||
}
|
||||
.doc-tab-scroll.is-at-left {
|
||||
-webkit-mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 18px), transparent 100%);
|
||||
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 18px), transparent 100%);
|
||||
}
|
||||
.doc-tab-scroll.is-at-right {
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 18px, #000 100%);
|
||||
mask-image: linear-gradient(to right, transparent 0, #000 18px, #000 100%);
|
||||
}
|
||||
.doc-tab-scroll.is-at-left.is-at-right {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
.doc-tab-scroll::-webkit-scrollbar { display: none; }
|
||||
#doc-tab-right,
|
||||
#doc-tab-left {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
}
|
||||
/* Ghost tab shown in the empty state (new, not-yet-saved document). Muted +
|
||||
italic so it reads as a placeholder, and non-interactive so clicking it
|
||||
can't hit the tab handlers (it has no data-doc-id). */
|
||||
.doc-tab.doc-tab-ghost {
|
||||
/* New, not-yet-saved doc tab. It already carries .active, so it shows the
|
||||
accent underline like any active tab — that's all we want. The old dashed
|
||||
border + italic/dim "pending" styling looked weird, so they're gone. */
|
||||
pointer-events: none;
|
||||
}
|
||||
.doc-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--red);
|
||||
}
|
||||
@keyframes doc-stream-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.doc-updated-flash {
|
||||
animation: doc-update-flash 0.6s ease-out;
|
||||
}
|
||||
@keyframes doc-update-flash {
|
||||
0% { box-shadow: inset 0 0 0 2px var(--accent); }
|
||||
100% { box-shadow: inset 0 0 0 2px transparent; }
|
||||
}
|
||||
.doc-langpicker-trigger.doc-langpicker-email-compact {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
padding: 0 0 0 6px;
|
||||
justify-content: flex-start;
|
||||
gap: 0;
|
||||
}
|
||||
/* The "T" icon stays centered in the fixed-size button so it grows
|
||||
symmetrically (from the center) instead of jumping around as the size
|
||||
changes. The S/M/L letter is pinned to the bottom-right corner, absolutely
|
||||
positioned so it never shifts the icon either. */
|
||||
#doc-fontsize-btn { transform: translateY(-1px); }
|
||||
#doc-fontsize-btn svg { flex: 0 0 auto; }
|
||||
.doc-overflow-wrapper { order: -1; }
|
||||
.doc-overflow-toggle { opacity: 0.5; }
|
||||
.doc-md-toolbar.md-preview-active,
|
||||
.doc-md-toolbar.md-write-active {
|
||||
overflow: visible;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
z-index: 3;
|
||||
}
|
||||
.doc-md-toolbar #doc-render-view-toggle .md-view-opt {
|
||||
height: 28px;
|
||||
}
|
||||
/* Some toolbar buttons baked in svg{opacity:0.7} via inline styles, which
|
||||
compounded with the button's own opacity to look almost invisible.
|
||||
Force the inline svg opacity back to 1 inside the toolbar — the button
|
||||
wrapper's 0.85 is the only fade we need. */
|
||||
.doc-md-toolbar button svg[style*="opacity"] { opacity: 1 !important; }
|
||||
/* No black tap-flash / focus background on the doc toolbar + tab controls —
|
||||
they're icon buttons, the opacity change is the only feedback we want. */
|
||||
.doc-md-toolbar button,
|
||||
.doc-tab-new,
|
||||
.doc-tab-arrow,
|
||||
.doc-tab-play {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
/* Pull the icon down toward the "sign" label so they read as one unit. */
|
||||
#doc-pdf-add-sign-btn svg { transform: translateY(2px); }
|
||||
.doc-outline-count {
|
||||
opacity: 0.48;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.doc-outline-target {
|
||||
animation: doc-outline-target-flash 1.05s ease-out;
|
||||
}
|
||||
@keyframes doc-outline-target-flash {
|
||||
0%, 28% { background-color: color-mix(in srgb, var(--accent, var(--red)) 24%, transparent); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
.doc-rich-slash-count {
|
||||
opacity: 0.48;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.doc-rich-slash-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Find marks live in the syntax-highlight overlay, which sits at
|
||||
z-index:0 under a transparent textarea — so they're always visible
|
||||
through the text layer. The previous color-mix variant could
|
||||
compute to near-invisible on themes with dark/desaturated accents;
|
||||
forced solid colors + !important so it ALWAYS pops. */
|
||||
mark.doc-find-mark {
|
||||
background: var(--accent) !important;
|
||||
color: var(--bg) !important;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
.doc-editor-highlight::-webkit-scrollbar { display: none; }
|
||||
/* Arrow pointing toward the editor */
|
||||
.doc-suggestion-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: -10px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--panel);
|
||||
border-left: 2px solid var(--accent);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
transform: rotate(45deg);
|
||||
z-index: 1;
|
||||
}
|
||||
.doc-suggestion-close::before {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: block;
|
||||
background: currentColor;
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2 2L8 8M8 2L2 8' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2 2L8 8M8 2L2 8' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
}
|
||||
.doc-suggestion-accept:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.doc-editor-wrap.animating::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px; right: 8px;
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
animation: doc-pulse 0.8s ease-in-out infinite;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes doc-pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
.doc-diff-content { }
|
||||
@media (hover: none) {
|
||||
.doc-preview-hover-edit {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
#doclib-research-search { position: relative; top: -1.5px; }
|
||||
/* Restore sits on the right of the Delete in archive previews. */
|
||||
.doclib-chat-restore-btn { margin-left: auto; }
|
||||
/* Delete + Archive pin to the left of the actions row; the
|
||||
`margin-right: auto` on the last left-side button (Archive) pushes
|
||||
Copy + Open to the right. Delete sits furthest left. */
|
||||
.doclib-chat-archive-btn { margin-right: auto; }
|
||||
@keyframes doclib-expand {
|
||||
from { opacity: 0.5; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
/* Hide the section headers while a card is expanded (full-screen reader). */
|
||||
.doclib-grid:has(.doclib-card-expanded) > .skills-section-header { display: none; }
|
||||
.skill-card:not(.doclib-card-expanded) .skill-card-tags { display: none; }
|
||||
.doclib-chip-scroll-frame > .chip-scroll-strip:empty {
|
||||
display: none;
|
||||
}
|
||||
.skill-card.doclib-card-expanded .skill-kebab-btn { display: none; }
|
||||
.skill-card.doclib-card-expanded .skill-chevron-up { display: inline-flex; }
|
||||
/* Size the expanded skill card to its content (footer sits right under the
|
||||
preview) but cap at the modal height so a long SKILL.md scrolls instead
|
||||
of overflowing. The inherited `.doclib-card-expanded { flex: 1 }` forced
|
||||
the card to fill the whole modal, which left a big empty void between a
|
||||
short skill's text and the footer. */
|
||||
.skill-card.doclib-card-expanded {
|
||||
flex: 0 1 auto !important;
|
||||
max-height: 100% !important;
|
||||
}
|
||||
.doclib-inline-load-more::before {
|
||||
display: none;
|
||||
}
|
||||
#doclib-selected-count {
|
||||
color: color-mix(in srgb, var(--fg) 50%, transparent);
|
||||
font-size: 10px;
|
||||
}
|
||||
.gallery-modal-content:has(#gallery-editor-container[style*="flex"]) .gallery-editor-landing {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#gallery-bulk-count,
|
||||
#gallery-editor-drafts-bulk-count {
|
||||
top: -1px !important;
|
||||
}
|
||||
#gallery-select-btn { position: relative; top: 4px !important; font-size: 12px; padding: 10px 11px 12px; min-width: 76px; justify-content: center; }
|
||||
#gallery-albums-search { top: -4px; }
|
||||
#gallery-albums-select-btn { top: -4px !important; }
|
||||
#gallery-bulk-delete,
|
||||
#gallery-albums-bulk-delete,
|
||||
#gallery-editor-drafts-bulk-delete {
|
||||
color: var(--color-error, #f44) !important;
|
||||
}
|
||||
.gallery-detail-image video {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
display: block;
|
||||
}
|
||||
.gallery-card:hover .gallery-fav-btn { opacity: 1; }
|
||||
.gallery-card:hover .gallery-dl-btn { opacity: 1; }
|
||||
@media (hover: none) {
|
||||
.gallery-dl-btn { opacity: 0.55; }
|
||||
}
|
||||
/* Space the user's tag chips off the "add tag" input below them. */
|
||||
#gallery-user-tag-chips { margin-bottom: 4px; }
|
||||
.gallery-user-chip:hover .gallery-tag-x { opacity: 0.85; }
|
||||
@keyframes gallery-skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.gallery-card:hover .gallery-card-overlay,
|
||||
.gallery-card.gallery-card-focus .gallery-card-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Strong accent ring on the whole photo when selected. Paint it as a
|
||||
foreground pseudo-element so the thumbnail cannot cover the feedback. */
|
||||
.gallery-card:has(.gallery-select-dot.selected) {
|
||||
border-color: var(--accent-primary, var(--red));
|
||||
}
|
||||
.gallery-card:has(.gallery-select-dot.selected)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
border: 3px solid var(--accent-primary, var(--red));
|
||||
border-radius: inherit;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gallery-card:has(.gallery-select-dot.selected) .gallery-select-dot {
|
||||
z-index: 4;
|
||||
}
|
||||
.gallery-detail-action.danger:hover {
|
||||
border-color: var(--color-error, #e55);
|
||||
}
|
||||
.gallery-detail-image img,
|
||||
.gallery-detail-image video,
|
||||
.gallery-detail-img-frame img,
|
||||
.gallery-detail-img-frame video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.gallery-detail-image:hover .gallery-detail-nav { opacity: 0.85; }
|
||||
@media (min-width: 769px) {
|
||||
.gallery-modal-content:has(#gallery-detail[style*="flex"]) {
|
||||
height: 92vh;
|
||||
}
|
||||
.gallery-modal-content:has(#gallery-detail[style*="flex"]) > .modal-body,
|
||||
.gallery-images-container:has(> #gallery-detail[style*="flex"]),
|
||||
#gallery-images-container:has(> #gallery-detail[style*="flex"]) > #gallery-detail {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
.gallery-detail-image:hover .gallery-detail-rotate { opacity: 0.85; }
|
||||
.gallery-detail-nav-disabled {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.gallery-detail-nav { opacity: 0.7; }
|
||||
}
|
||||
.gallery-detail-inline-link:hover { text-decoration: underline; }
|
||||
.gallery-detail-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.gallery-tag-input-wrap .gallery-tag-input { padding-right: 24px; }
|
||||
.gallery-name-wrap:focus-within .gallery-name-enter { display: inline-flex; }
|
||||
.gallery-name-wrap:focus-within .gallery-detail-name-input { padding-right: 28px; }
|
||||
.gallery-album-cover img {
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.gallery-editor-landing p {
|
||||
margin: 0;
|
||||
opacity: 0.6;
|
||||
font-size: 13px;
|
||||
max-width: 320px;
|
||||
}
|
||||
.gallery-editor-landing-actions .gallery-select-btn:hover { opacity: 1; }
|
||||
/* Saved-project selection uses the same bulk-bar rhythm as Photos. */
|
||||
#gallery-editor-drafts-bulk {
|
||||
margin-top: 0;
|
||||
}
|
||||
.gallery-editor-draft-card.selected {
|
||||
outline: 2px solid var(--accent, var(--red));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.gallery-editor-draft-card.select-mode { cursor: pointer; }
|
||||
.gallery-drafts-view-toggle button svg {
|
||||
display: block;
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-card {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) 24px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 48px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete-x { display: none; }
|
||||
.gallery-editor-draft-card:focus-visible {
|
||||
outline: 2px solid var(--accent, var(--red));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.gallery-editor-draft-card:hover .gallery-editor-draft-delete { opacity: 1; }
|
||||
.ge-topbar.ge-topbar-menu-open { z-index: 10006; }
|
||||
/* Toolbar (left) */
|
||||
@keyframes ge-layer-action-flash {
|
||||
from { box-shadow: inset 0 0 0 2px var(--red); background-color: color-mix(in srgb, var(--red) 24%, transparent); }
|
||||
to { box-shadow: inset 0 0 0 2px transparent; }
|
||||
}
|
||||
.ge-layer-action-flash { animation: ge-layer-action-flash 650ms ease-out; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ge-layer-action-flash { animation-duration: 1ms; }
|
||||
}
|
||||
.ge-tool-btn .ge-tool-label {
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ge-tool-btn:hover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 2px 0 -2px;
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
border-radius: 6px;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ge-tool-btn.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* Highlight pill shifted 2 px left of the button bounds so it sits
|
||||
under the icon + label (which are also nudged left). Same total
|
||||
width as before — just slid. */
|
||||
inset: 0 2px 0 -2px;
|
||||
background: color-mix(in srgb, var(--red) 18%, transparent);
|
||||
border-radius: 6px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ge-btn-ai:hover .ge-btn-ai-mark { opacity: 1; }
|
||||
.ge-tool-btn.has-selection .ge-tool-clear { display: inline-flex; }
|
||||
.ge-canvas-area.ge-rulers-visible {
|
||||
padding: 18px 0 0 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ge-main-canvas {
|
||||
touch-action: none;
|
||||
}
|
||||
.ge-canvas-area.ge-pan-ready,
|
||||
.ge-canvas-area.ge-pan-ready .ge-main-canvas {
|
||||
cursor: grab !important;
|
||||
}
|
||||
.ge-canvas-area.ge-panning,
|
||||
.ge-canvas-area.ge-panning .ge-main-canvas {
|
||||
cursor: grabbing !important;
|
||||
user-select: none;
|
||||
}
|
||||
.ge-ai-command-collapsed .ge-ai-command-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
.ge-ai-command-collapsed .ge-ai-command-head,
|
||||
.ge-ai-command-collapsed .ge-ai-command-form,
|
||||
.ge-ai-command-collapsed .ge-ai-command-suggestions,
|
||||
.ge-ai-command-collapsed .ge-ai-command-status {
|
||||
display: none;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.ge-ai-command-collapsed {
|
||||
bottom: 8px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
.ge-ai-command-clear[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.ge-ai-command-busy .ge-ai-command-run::after {
|
||||
content: '';
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid color-mix(in srgb, #fff 42%, transparent);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: ge-ai-command-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes ge-ai-command-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.ge-ai-command-suggestions[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.ge-ai-command-status:empty {
|
||||
display: none;
|
||||
}
|
||||
/* Each shortcut row: key chips + description. The key chips are usually
|
||||
chained with "+" so we use a small horizontal gap between siblings,
|
||||
plus a larger left margin on the trailing text via the kbd:last-of-type
|
||||
sibling rule — keeps "Ctrl+Shift+D Deselect" readable instead of glued. */
|
||||
.ge-shortcuts-col > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
opacity: 0.85;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* Push the description text away from the last kbd by adding margin
|
||||
to any text node that follows a kbd. Flex+wrap handles wrap on narrow. */
|
||||
.ge-shortcuts-col > div kbd + kbd { margin-left: 0; }
|
||||
.ge-shortcuts-col > div kbd:last-of-type { margin-right: 6px; }
|
||||
.ge-shortcuts-card kbd,
|
||||
#ge-shortcuts-popover kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #cc6a3a) 55%, transparent);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--accent, #cc6a3a) 14%, transparent);
|
||||
color: var(--accent, #cc6a3a);
|
||||
min-width: 18px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ge-quick-mask-bar[hidden] { display: none; }
|
||||
.ge-marquee-dimensions[hidden] { display: none; }
|
||||
.ge-layer-geometry-section .ge-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ge-layer-geometry-grid input::-webkit-inner-spin-button,
|
||||
.ge-layer-geometry-grid input::-webkit-outer-spin-button { appearance: none; }
|
||||
#ge-brush-controls select,
|
||||
#ge-eyedropper-section select {
|
||||
height: 26px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
.ge-gradient-extra-stop .ge-text-field { min-width: 0; }
|
||||
.ge-shape-gradient-add-stop { align-self: flex-start; }
|
||||
.ge-text-font-field { grid-column: span 1; }
|
||||
#ge-text-rasterize { align-self: flex-end; }
|
||||
.ge-shape-types .ge-text-toggle { flex: 1 1 25%; }
|
||||
#ge-shape-rasterize { align-self: flex-end; }
|
||||
.ge-direct-text-editor:focus { outline: 1px solid color-mix(in srgb, var(--red) 55%, transparent); }
|
||||
.ge-transform-popup.ge-transform-popup-dragging .ge-transform-popup-head { cursor: grabbing; }
|
||||
/* Force the title to take all middle space so the [_][×] cluster
|
||||
stays pinned to the right edge. */
|
||||
.ge-transform-popup-head .ge-adj-title { flex: 1 1 auto !important; }
|
||||
.ge-transform-popup-minimised .ge-transform-popup-body,
|
||||
.ge-transform-popup-minimised .ge-transform-popup-hint {
|
||||
display: none;
|
||||
}
|
||||
.ge-transform-popup-input::-webkit-outer-spin-button,
|
||||
.ge-transform-popup-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
display: none;
|
||||
}
|
||||
.ge-transform-position-field .ge-transform-popup-input { width: 68px; }
|
||||
.ge-transform-input-locked {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ge-transform-input-locked + .ge-transform-spin { display: none; }
|
||||
.ge-transform-preserve-field[hidden] { display: none; }
|
||||
/* Hide the controls wrapper entirely when every tool section inside is
|
||||
hidden — otherwise the padding + border draws an empty band above the
|
||||
layers panel even when no tool needs sliders/buttons. */
|
||||
.ge-controls:not(:has(> *:not([style*="display: none"]):not([style*="display:none"]))) {
|
||||
display: none;
|
||||
}
|
||||
/* Strip the native <input type="color"> chrome (border + padding around
|
||||
the swatch in each engine) so the visible color fills the circle. */
|
||||
.ge-color-picker::-webkit-color-swatch-wrapper { padding: 0; }
|
||||
.ge-color-picker::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.ge-color-picker::-moz-color-swatch {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.ge-color-picker.cp-swatch-input::selection { background: transparent; }
|
||||
.ge-size-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 9px; height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
border: none; cursor: pointer;
|
||||
}
|
||||
.ge-size-slider:hover::-webkit-slider-thumb,
|
||||
.ge-size-slider.is-using::-webkit-slider-thumb,
|
||||
.ge-size-slider:active::-webkit-slider-thumb {
|
||||
width: 16px; height: 16px;
|
||||
}
|
||||
.ge-size-slider::-moz-range-thumb {
|
||||
width: 9px; height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
border: none; cursor: pointer;
|
||||
}
|
||||
.ge-size-slider:hover::-moz-range-thumb,
|
||||
.ge-size-slider.is-using::-moz-range-thumb,
|
||||
.ge-size-slider:active::-moz-range-thumb {
|
||||
width: 16px; height: 16px;
|
||||
}
|
||||
.ge-eraser-row label > span[id$="-label"]:hover {
|
||||
background: color-mix(in srgb, var(--fg) 10%, transparent);
|
||||
}
|
||||
.ge-eraser-row > span[id$="-label"].ge-slider-value:hover {
|
||||
background: color-mix(in srgb, var(--fg) 10%, transparent);
|
||||
opacity: 1;
|
||||
}
|
||||
.ge-merge-menu[hidden] { display: none; }
|
||||
/* Inpaint Brush size slider — slightly larger thumb than the rest of
|
||||
the panel since it's the primary interactive control. */
|
||||
#ge-inpaint-brush-slider::-webkit-slider-thumb { width: 12px; height: 12px; }
|
||||
#ge-inpaint-brush-slider:hover::-webkit-slider-thumb,
|
||||
#ge-inpaint-brush-slider.is-using::-webkit-slider-thumb,
|
||||
#ge-inpaint-brush-slider:active::-webkit-slider-thumb { width: 18px; height: 18px; }
|
||||
#ge-inpaint-brush-slider::-moz-range-thumb { width: 12px; height: 12px; }
|
||||
#ge-inpaint-brush-slider:hover::-moz-range-thumb,
|
||||
#ge-inpaint-brush-slider.is-using::-moz-range-thumb,
|
||||
#ge-inpaint-brush-slider:active::-moz-range-thumb { width: 18px; height: 18px; }
|
||||
.ge-adj-popup .ge-adj-title { flex: 1 1 auto; }
|
||||
.ge-adj-popup .ge-adj-head:active { cursor: grabbing; }
|
||||
.ge-fx-dock-label { overflow: hidden; text-overflow: ellipsis; }
|
||||
.ge-history-head:active { cursor: grabbing; }
|
||||
@keyframes ge-canvas-spin { to { transform: rotate(360deg); } }
|
||||
.ge-inline-swatch::-webkit-color-swatch-wrapper { padding: 0; }
|
||||
.ge-inline-swatch::-webkit-color-swatch { border: none; border-radius: 2px; }
|
||||
.ge-inline-swatch::-moz-color-swatch { border: none; border-radius: 2px; }
|
||||
.ge-adj-cb-row .ge-adj-cb-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
display: inline-block; flex-shrink: 0;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fg) 20%, transparent);
|
||||
}
|
||||
.ge-adj-cb-tone-picker {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.ge-adj-hist-details {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ge-adj-hist-details > summary {
|
||||
font-size: 11px;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.ge-adj-hist-details > summary::-webkit-details-marker { display: none; }
|
||||
.ge-adj-hist-details > summary::before {
|
||||
content: '▸ ';
|
||||
display: inline-block;
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.ge-mask-sub-item.active .ge-layer-name { opacity: 1; font-style: normal; font-weight: 600; }
|
||||
.ge-mask-sub-item.active .ge-mask-inline-thumb { opacity: 1; }
|
||||
/* Opacity preview — translucent disk */
|
||||
#ge-eraser-preview-opacity { opacity: 0.5; }
|
||||
/* Flow preview — dotted ring to suggest discrete stamping */
|
||||
#ge-eraser-preview-flow {
|
||||
background: none;
|
||||
border: 2px dotted var(--fg);
|
||||
opacity: 0.7;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
/* Softness preview — blurred edge */
|
||||
#ge-eraser-preview-softness {
|
||||
background: radial-gradient(circle, var(--fg) 0%, var(--fg) 30%, transparent 75%);
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* Wand feather preview — solid disk that visibly softens at its edge
|
||||
as the user drags the Feather slider. JS sets --feather-blur. */
|
||||
#ge-wand-feather-preview {
|
||||
filter: blur(var(--feather-blur, 0px));
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ge-mask-vis-btn.visible:hover { opacity: 1; }
|
||||
.ge-topbar .ge-btn,
|
||||
.ge-topbar .ge-btn-sm { text-transform: uppercase; }
|
||||
.ge-stacked-btn .ge-stacked-glyph {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 14px;
|
||||
}
|
||||
.ge-layer-selection-bar[hidden] { display: none; }
|
||||
.ge-layer-selection-bar .ge-icon-btn:hover { opacity: 1; }
|
||||
.ge-save-menu[hidden] { display: none; }
|
||||
.ge-stacked-btn .ge-stacked-glyph .ge-save-state-icon[hidden] { display: none !important; }
|
||||
.ge-stacked-btn .ge-stacked-glyph .ge-save-state-icon:not([hidden]) { display: block; }
|
||||
.ge-resize-menu[hidden],
|
||||
.ge-edge-menu[hidden],
|
||||
.ge-view-menu[hidden],
|
||||
.ge-image-menu[hidden],
|
||||
.ge-selection-menu[hidden],
|
||||
.ge-filter-menu[hidden] { display: none; }
|
||||
.ge-selection-save-row .ge-icon-btn { width: 28px; height: 27px; }
|
||||
.ge-export-matte .ge-color-picker { width: 28px; height: 28px; }
|
||||
.ge-export-footer > div { display: flex; gap: 6px; }
|
||||
.ge-export-estimate { opacity: 0.6; font-variant-numeric: tabular-nums; }
|
||||
.ge-canvas-prompt-custom-label { margin-top: 14px; }
|
||||
.ge-canvas-anchor::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border-radius: 1px;
|
||||
background: color-mix(in srgb, var(--fg) 38%, transparent);
|
||||
}
|
||||
.ge-canvas-anchor.active::after { background: var(--red); }
|
||||
.ge-canvas-lock-option input { accent-color: var(--red); }
|
||||
.ge-canvas-preset-icon { margin-bottom: 1px; }
|
||||
/* Center grip dots so the handle reads as draggable. */
|
||||
.ge-panel-resize::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
background: color-mix(in srgb, var(--fg) 35%, transparent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.ge-slider-bubble[hidden] { display: none; }
|
||||
/* Tail pointing DOWN at the slider (bubble sits above). */
|
||||
.ge-slider-bubble::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -4px;
|
||||
transform: translateX(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--red);
|
||||
clip-path: polygon(0 0, 100% 0, 50% 100%);
|
||||
}
|
||||
.ge-layer-thumb canvas {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ge-layers-merge-menu[hidden] { display: none; }
|
||||
.ge-layer-item:not(.active) .ge-layer-inline-thumb { opacity: 0.86; }
|
||||
.ge-layer-opacity-row .ge-layer-lock-btn { flex: 0 0 24px; width: 24px; }
|
||||
.ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item) > .ge-layer-controls {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
}
|
||||
.ge-layer-item.grouped { padding-left: calc(20px + (var(--layer-group-depth, 1) - 1) * 14px); }
|
||||
.ge-layer-vis.visible:hover { opacity: 1; }
|
||||
.ge-layer-item.clipped .ge-layer-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
/* Shrink the slider thumb — the native default is ~16-20 px which looks
|
||||
massive next to a 3 px track in the compact layer row. */
|
||||
.ge-layer-opacity::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
/* Match the track: shrink only after a 0.5s grace period. */
|
||||
transition: width 0.12s ease 0.5s, height 0.12s ease 0.5s;
|
||||
}
|
||||
.ge-layer-opacity::-moz-range-thumb {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: width 0.12s ease 0.5s, height 0.12s ease 0.5s;
|
||||
}
|
||||
.ge-layer-opacity:hover::-webkit-slider-thumb,
|
||||
.ge-layer-opacity:active::-webkit-slider-thumb,
|
||||
.ge-layer-opacity.dragging::-webkit-slider-thumb,
|
||||
.ge-layer-opacity:hover::-moz-range-thumb,
|
||||
.ge-layer-opacity:active::-moz-range-thumb,
|
||||
.ge-layer-opacity.dragging::-moz-range-thumb {
|
||||
transition: width 0.12s ease 0s, height 0.12s ease 0s;
|
||||
}
|
||||
.ge-layer-controls .ge-mask-density {
|
||||
width: 26px;
|
||||
min-width: 24px;
|
||||
height: 4px;
|
||||
flex: 0 0 26px;
|
||||
align-self: center;
|
||||
}
|
||||
.ge-mask-properties > summary {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
color: var(--fg);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ge-mask-properties > summary::-webkit-details-marker { display: none; }
|
||||
.ge-mask-sub-item:has(.ge-mask-properties[open]) {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
}
|
||||
.ge-mask-property-row output { text-align: right; opacity: .7; }
|
||||
.ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item) > .ge-layer-controls,
|
||||
.ge-layers-list > .ge-layer-group-row > .ge-layer-controls {
|
||||
display: none;
|
||||
}
|
||||
.ge-layer-tools[hidden] { display: none; }
|
||||
.ge-layer-tools .ge-layer-btn { min-width: 26px; }
|
||||
@media (max-width: 700px) {
|
||||
/* Preserve the compact grip while giving long-press reorder a usable
|
||||
touch target. */
|
||||
.ge-layer-drag {
|
||||
width: 28px;
|
||||
min-height: 30px;
|
||||
margin-left: -6px;
|
||||
margin-right: -4px;
|
||||
}
|
||||
}
|
||||
.ge-layer-align-menu .ge-layer-lock-option { grid-template-columns: minmax(0, 1fr); }
|
||||
.ge-crop-apply input[type="number"]::-webkit-outer-spin-button,
|
||||
.ge-crop-apply input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.ge-brush-cursor::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 3px; height: 3px;
|
||||
margin: -1.5px 0 0 -1.5px;
|
||||
background: rgba(255,255,255,0.8);
|
||||
border-radius: 50%;
|
||||
}
|
||||
/* Marks remain solid so they pop through the otherwise-invisible
|
||||
overlay; outlined extra-bright for the currently-focused one. */
|
||||
body.doc-find-active mark.doc-find-mark {
|
||||
background: var(--accent) !important;
|
||||
color: var(--bg) !important;
|
||||
}
|
||||
body.doc-find-active mark.doc-find-mark.current {
|
||||
background: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px var(--fg) !important;
|
||||
outline: 1px solid var(--fg) !important;
|
||||
}
|
||||
.doc-email-header:not(.doc-email-header-collapsed) .doc-email-collapse-summary {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.doc-email-header.doc-email-header-collapsed {
|
||||
gap: 0;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.doc-email-header.doc-email-header-collapsed .doc-email-fields {
|
||||
display: none;
|
||||
}
|
||||
/* Documents footer — X + Undo stay on the LEFT, Copy/Export group pushed
|
||||
to the RIGHT. Email composer keeps its original flex-end layout. */
|
||||
#doc-actions-footer {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.doc-save-button[data-save-state="dirty"] .doc-save-state-dirty,
|
||||
.doc-save-button[data-save-state="saving"] .doc-save-state-saving,
|
||||
.doc-save-button[data-save-state="saved"] .doc-save-state-saved,
|
||||
.doc-save-button[data-save-state="error"] .doc-save-state-error { display: block; }
|
||||
.doc-save-button[data-save-state="saving"] .doc-save-state-saving {
|
||||
animation: doc-save-spin 800ms linear infinite;
|
||||
}
|
||||
@keyframes doc-save-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.doc-save-button[data-save-state="saving"] .doc-save-state-saving { animation: none; }
|
||||
}
|
||||
.doc-stats-popover[hidden] { display: none; }
|
||||
.doc-stats-row strong {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.doc-email-richbody:empty::before {
|
||||
content: "Write your email\2026";
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.doc-rich-empty-import {
|
||||
top: 106px;
|
||||
}
|
||||
}
|
||||
.doc-email-richbody.richtext-mode:empty::before {
|
||||
content: "Start writing\2026";
|
||||
}
|
||||
.doc-email-richbody.richtext-mode ul,
|
||||
.doc-email-richbody.richtext-mode ol {
|
||||
padding-left: 1.75em;
|
||||
}
|
||||
.doc-email-richbody.richtext-mode ul:not(.rich-checklist) > li,
|
||||
.doc-email-richbody.richtext-mode ol > li {
|
||||
padding-inline-start: 0.2em;
|
||||
}
|
||||
.doc-email-richbody.richtext-mode pre code {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
}
|
||||
.doc-email-richbody figure.richtext-image .richtext-image-caption {
|
||||
margin-top: 6px;
|
||||
max-width: 100%;
|
||||
color: color-mix(in srgb, var(--fg) 68%, transparent);
|
||||
font-size: 0.84em;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.doc-email-richbody a { color: var(--accent, #4af); }
|
||||
.doc-email-richbody blockquote {
|
||||
margin: 6px 0; padding-left: 10px;
|
||||
border-left: 3px solid var(--border); opacity: 0.8;
|
||||
}
|
||||
.doc-email-richbody p { margin: 0.5em 0; }
|
||||
#doc-email-more-btn svg { opacity: 0.8; }
|
||||
@media (min-width: 769px) {
|
||||
#doc-email-header #doc-email-collapse-btn.doc-email-collapse-btn {
|
||||
display: none !important;
|
||||
}
|
||||
#doc-email-header.doc-email-header-collapsed .doc-email-fields {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
.doclib-section-title-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 6px;
|
||||
color: var(--accent, var(--red));
|
||||
vertical-align: -2px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -247,6 +247,13 @@
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260921chatgptusage2">
|
||||
<!-- Panel stylesheets split out of style.css for maintainability. They are
|
||||
eager and their order is load-bearing: the cascade is the concatenation of
|
||||
these files in this order, so they must stay in this order here and in the
|
||||
PRECACHE list in sw.js. None of them is lazy-loaded. -->
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260923csssplit1">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260923csssplit2">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260923csssplit3">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260921chatgptusage2">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
|
||||
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
|
||||
|
||||
+7
-3902
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -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-v651-chatgpt-usage-collapse';
|
||||
const CACHE_NAME = 'odysseus-v654-css-split-3';
|
||||
|
||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||
// gives offline math fallback glyphs instead of proper typesetting.
|
||||
@@ -40,6 +40,9 @@ const KATEX_FONTS = [
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/static/style.css?v=20260921chatgptusage2',
|
||||
'/static/css/documents-gallery-editor.css?v=20260923csssplit1',
|
||||
'/static/css/email-calendar-notes-tasks.css?v=20260923csssplit2',
|
||||
'/static/css/cookbook-research-memory-settings.css?v=20260923csssplit3',
|
||||
'/static/app.js?v=20260921chatgptusage2',
|
||||
'/static/js/storage.js',
|
||||
'/static/js/appConfig.js',
|
||||
|
||||
@@ -145,6 +145,25 @@ The runner propagates pytest's exit code, so it composes with normal local
|
||||
workflows; "report-only" means it is not a CI gate, not that failures are
|
||||
swallowed.
|
||||
|
||||
## CSS computed-style snapshot
|
||||
|
||||
`tests/test_css_computed_style_snapshot.py` pins the rendered result of
|
||||
`static/style.css` - one 51k-line file whose behavior depends on source order -
|
||||
by hashing `getComputedStyle` over a fixed element inventory across pages,
|
||||
viewports, themes and density modes. Any PR that moves CSS has to produce an
|
||||
identical digest or explain why it did not.
|
||||
|
||||
```bash
|
||||
./venv/bin/python -m pytest tests/test_css_computed_style_snapshot.py
|
||||
./venv/bin/python scripts/css_snapshot.py --check # standalone, no pytest
|
||||
./venv/bin/python scripts/css_snapshot.py --write-baseline # re-record, deliberately
|
||||
```
|
||||
|
||||
The inventory, the baseline and the capture live in `tests/css_snapshot/`;
|
||||
`tests/css_snapshot/README.md` documents what is covered, what is deliberately
|
||||
not, and how to find the property that moved when it fails. The run takes about
|
||||
21 seconds and skips when `npm ci` has not been run.
|
||||
|
||||
## Core principles
|
||||
|
||||
- Keep PRs small and homogeneous: one kind of change per PR.
|
||||
@@ -161,6 +180,23 @@ The helpers below live under `tests/helpers/`. They exist to remove repeated
|
||||
boilerplate that already appeared across multiple tests. Reach for one only when
|
||||
your test matches its intended use; do not stretch a helper to cover a new case.
|
||||
|
||||
### `tests.helpers.stylesheets.app_css`
|
||||
|
||||
Use when a test asserts on a CSS rule.
|
||||
|
||||
- Returns every app stylesheet concatenated in the order `static/index.html`
|
||||
loads them, which is the order the cascade actually has.
|
||||
- Panel styles no longer all live in `static/style.css`; reading that file
|
||||
alone ties the test to whichever file a rule sits in today, so it goes red
|
||||
when a rule moves without the rendered page changing.
|
||||
- `stylesheet_paths()` and `stylesheet_urls()` are there when a test needs the
|
||||
files or the request URLs rather than their contents.
|
||||
`stylesheet_link_tags()` returns the `<link>` markup for a synthetic page
|
||||
driven through Playwright, so it gets the whole cascade instead of only
|
||||
`style.css`.
|
||||
- All of them fail loudly if `index.html` links a stylesheet that is missing.
|
||||
- Not for vendored CSS under `static/lib/`, which they deliberately skip.
|
||||
|
||||
### `tests.helpers.cli_loader.load_script`
|
||||
|
||||
Use when a test needs to import a script under `scripts/` without repeating
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""The isolation contract of `odysseus dev`.
|
||||
|
||||
The launcher exists so that two checkouts on one machine cannot share
|
||||
runtime state by accident. Every test here pins one of the guarantees
|
||||
that makes that true: derived ports never land on a port the project
|
||||
already means something by, a ChromaDB we did not start is refused
|
||||
rather than adopted, a checkout wired into a service manager is not
|
||||
bootable, and nothing is signalled on the strength of a pid alone.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.cli_loader import load_script
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli():
|
||||
return load_script("odysseus-dev")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worktree(tmp_path):
|
||||
"""A directory shaped enough like a checkout for the launcher to accept it."""
|
||||
for marker in ("app.py", "setup.py", "requirements.txt"):
|
||||
(tmp_path / marker).write_text("")
|
||||
(tmp_path / "venv" / "bin").mkdir(parents=True)
|
||||
(tmp_path / "venv" / "bin" / "python").write_text("")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def up_args(**overrides):
|
||||
defaults = dict(
|
||||
port=None, chroma_port=None, no_chroma=False, venv=None, from_pr=None,
|
||||
remote="origin", foreground=False, timeout=5, pretty=False,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
def test_derived_ports_are_stable_distinct_and_never_reserved(cli):
|
||||
first = cli.derive_ports("/checkouts/alpha")
|
||||
assert first == cli.derive_ports("/checkouts/alpha")
|
||||
assert first != cli.derive_ports("/checkouts/beta")
|
||||
assert first["chroma"] == first["app"] + 1
|
||||
assert first["test_static"] == first["app"] + 2
|
||||
|
||||
# No path can derive onto a port the project already owns — 7860 is a
|
||||
# normal start-macos.sh launch, 8100 somebody else's vector store.
|
||||
for index in range(500):
|
||||
for port in cli.derive_ports(f"/checkouts/w{index}").values():
|
||||
assert cli.reserved_reason(port) is None, port
|
||||
|
||||
|
||||
def test_reserved_ports_are_refused_even_when_asked_for(cli, worktree, monkeypatch):
|
||||
monkeypatch.chdir(worktree)
|
||||
with pytest.raises(SystemExit):
|
||||
cli.resolve_ports(worktree, up_args(port=7860))
|
||||
with pytest.raises(SystemExit):
|
||||
cli.resolve_ports(worktree, up_args(chroma_port=8100))
|
||||
|
||||
|
||||
def test_root_is_resolved_from_the_working_directory(cli, worktree):
|
||||
nested = worktree / "static" / "js"
|
||||
nested.mkdir(parents=True)
|
||||
assert cli.find_repo_root(nested) == worktree.resolve()
|
||||
assert cli.find_repo_root(worktree.parent) is None
|
||||
|
||||
|
||||
def test_a_checkout_run_by_a_service_manager_is_not_bootable(cli, worktree, monkeypatch):
|
||||
units = worktree.parent / "units"
|
||||
units.mkdir()
|
||||
(units / "com.odysseus.server.plist").write_text(
|
||||
f"<plist><string>{worktree.resolve()}/start-macos.sh</string></plist>"
|
||||
)
|
||||
assert cli.managed_by_service(worktree, unit_dirs=[units]).endswith(".plist")
|
||||
unrelated = worktree.parent / "somewhere-else"
|
||||
unrelated.mkdir()
|
||||
assert cli.managed_by_service(unrelated, unit_dirs=[units]) is None
|
||||
|
||||
monkeypatch.chdir(worktree)
|
||||
monkeypatch.setattr(cli, "service_unit_dirs", lambda: [units])
|
||||
with pytest.raises(SystemExit):
|
||||
cli.cmd_up(up_args())
|
||||
|
||||
|
||||
def test_a_chromadb_we_did_not_start_is_refused_not_adopted(cli, worktree, monkeypatch):
|
||||
monkeypatch.chdir(worktree)
|
||||
monkeypatch.setattr(cli, "service_unit_dirs", list)
|
||||
ports = cli.derive_ports(worktree)
|
||||
|
||||
foreign = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
foreign.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
foreign.bind(("127.0.0.1", ports["chroma"]))
|
||||
except OSError:
|
||||
pytest.skip(f"derived chroma port {ports['chroma']} is unavailable on this host")
|
||||
foreign.listen(1)
|
||||
try:
|
||||
with pytest.raises(SystemExit):
|
||||
cli.cmd_up(up_args())
|
||||
finally:
|
||||
foreign.close()
|
||||
|
||||
# Refused means refused: nothing was started and no state was recorded.
|
||||
assert not cli.state_path(worktree).exists()
|
||||
|
||||
|
||||
def test_no_chroma_points_the_app_at_a_port_nothing_answers(cli):
|
||||
port = cli.unused_port()
|
||||
assert not cli.port_bound(port)
|
||||
assert cli.reserved_reason(port) is None
|
||||
|
||||
|
||||
def test_every_chroma_outcome_leaves_the_app_off_a_port_we_do_not_own(cli, worktree):
|
||||
"""The decision has three endings and none of them is "use theirs"."""
|
||||
ports = cli.derive_ports(worktree)
|
||||
data, logs = worktree / "data", worktree / "logs"
|
||||
data.mkdir()
|
||||
logs.mkdir()
|
||||
venv_python = worktree / "venv" / "bin" / "python"
|
||||
|
||||
# 1. Asked to go without: a port nothing answers on, not the derived
|
||||
# one, which is where a foreign server may appear later.
|
||||
entry, port, note = cli.resolve_chroma(
|
||||
up_args(no_chroma=True), {}, ports, venv_python, data, logs
|
||||
)
|
||||
assert (entry, port != ports["chroma"], cli.port_bound(port)) == (None, True, False)
|
||||
assert "no-chroma" in note
|
||||
|
||||
# 2. No server to start (this venv has no `chroma` binary, which is
|
||||
# the stock requirements.txt): keyword mode, and again not the
|
||||
# derived port.
|
||||
entry, port, note = cli.resolve_chroma(
|
||||
up_args(), {}, ports, venv_python, data, logs
|
||||
)
|
||||
assert entry is None and port != ports["chroma"]
|
||||
assert "keyword-only" in note
|
||||
|
||||
|
||||
def test_a_pid_is_never_trusted_without_its_command_line(cli, worktree, monkeypatch):
|
||||
own_pid = os.getpid()
|
||||
assert cli.pid_is_ours(own_pid, ["definitely-not-in-this-command-line"]) is False
|
||||
assert cli.pid_is_ours(None, []) is False
|
||||
assert cli.pid_is_ours(own_pid, [cli.pid_command(own_pid).split()[0]]) is True
|
||||
|
||||
# `down` must not signal a live process whose fingerprints disagree —
|
||||
# here, this very test run — and must keep the record so the pid can
|
||||
# be investigated rather than lost.
|
||||
cli.write_state(worktree, {"app": {"pid": own_pid, "fingerprints": ["uvicorn --port 1"]}})
|
||||
monkeypatch.chdir(worktree)
|
||||
monkeypatch.setattr(os, "kill", _forbidden_kill)
|
||||
cli.cmd_down(up_args())
|
||||
assert cli.state_path(worktree).exists()
|
||||
|
||||
|
||||
def test_down_forgets_an_instance_that_is_gone(cli, worktree, monkeypatch):
|
||||
cli.write_state(worktree, {"app": {"pid": 2 ** 31 - 1, "fingerprints": ["uvicorn"]}})
|
||||
monkeypatch.chdir(worktree)
|
||||
cli.cmd_down(up_args())
|
||||
assert not cli.state_path(worktree).exists()
|
||||
|
||||
|
||||
def _forbidden_kill(pid, sig):
|
||||
"""Liveness probes (signal 0) are fine; anything that would actually
|
||||
reach the process is the failure this test is about."""
|
||||
if sig == 0:
|
||||
return None
|
||||
raise AssertionError(f"cmd_down sent signal {sig} to a process it does not own")
|
||||
+33
-17
@@ -98,26 +98,23 @@ def pytest_collection_modifyitems(config, items):
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _serve_test_static():
|
||||
"""Serve this worktree's assets; non-browser runs can request an ephemeral port."""
|
||||
import socket
|
||||
"""Serve static assets on loopback for the browser integration tests.
|
||||
|
||||
Binds an ephemeral port so several worktrees can run their own suite at the
|
||||
same time, and publishes the resulting origin through
|
||||
``ODYSSEUS_TEST_STATIC_ORIGIN``. The browser tests shell out to node, which
|
||||
inherits the environment, so the snippets read the origin from
|
||||
``process.env`` instead of hardcoding a port.
|
||||
|
||||
Set ``ODYSSEUS_TEST_STATIC_PORT`` to pin a specific port when something
|
||||
outside pytest has to reach this server.
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
import http.server
|
||||
import socketserver
|
||||
from pathlib import Path
|
||||
|
||||
port = int(os.environ.get("ODYSSEUS_TEST_STATIC_PORT", "7011"))
|
||||
if not 0 <= port <= 65535:
|
||||
raise ValueError("ODYSSEUS_TEST_STATIC_PORT must be between 0 and 65535")
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
is_bound = (sock.connect_ex(("127.0.0.1", port)) == 0) if port else False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
if is_bound:
|
||||
raise RuntimeError(f"port {port} is already in use; browser tests require this worktree's static server")
|
||||
|
||||
root_dir = Path(__file__).resolve().parent.parent
|
||||
|
||||
class _Handler(http.server.SimpleHTTPRequestHandler):
|
||||
@@ -137,11 +134,30 @@ def _serve_test_static():
|
||||
class _Server(socketserver.TCPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
server = _Server(("127.0.0.1", port), _Handler)
|
||||
requested = int(os.environ.get("ODYSSEUS_TEST_STATIC_PORT") or 0)
|
||||
if not 0 <= requested <= 65535:
|
||||
raise ValueError("ODYSSEUS_TEST_STATIC_PORT must be between 0 and 65535")
|
||||
try:
|
||||
server = _Server(("127.0.0.1", requested), _Handler)
|
||||
except OSError as exc:
|
||||
# Port 0 cannot collide, so this only fires for an explicit pin.
|
||||
raise RuntimeError(
|
||||
f"ODYSSEUS_TEST_STATIC_PORT={requested} is not bindable; unset it to "
|
||||
"let the browser tests pick an ephemeral port"
|
||||
) from exc
|
||||
|
||||
origin = f"http://127.0.0.1:{server.server_address[1]}"
|
||||
previous_origin = os.environ.get("ODYSSEUS_TEST_STATIC_ORIGIN")
|
||||
os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"] = origin
|
||||
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield
|
||||
yield origin
|
||||
finally:
|
||||
if previous_origin is None:
|
||||
os.environ.pop("ODYSSEUS_TEST_STATIC_ORIGIN", None)
|
||||
else:
|
||||
os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"] = previous_origin
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Computed-style snapshot harness
|
||||
|
||||
`static/style.css` is 51,425 lines in one file. Hundreds of selectors are
|
||||
declared more than once and `!important` appears throughout, so the rendered
|
||||
result is a function of **source order**. Extracting a block into its own file,
|
||||
reordering `<link>` tags, or moving an `@media` rule can silently change which
|
||||
declaration wins, and nothing else in the suite would notice.
|
||||
|
||||
This harness makes that falsifiable. It captures `getComputedStyle` over a
|
||||
fixed element inventory, hashes the result, and compares it to a committed
|
||||
baseline. It moves no CSS itself.
|
||||
|
||||
## What it covers
|
||||
|
||||
| Dimension | Values |
|
||||
|---|---|
|
||||
| Pages | `static/index.html` (app shell, 76 elements), `static/login.html` (14), the bench (586 selectors) |
|
||||
| Viewports | 1440x900, 820x1000, 768x1024 (touch), 390x844 (touch) |
|
||||
| Themes | dark (default) and `:root.light` |
|
||||
| Density | default, `:root.density-compact`, `:root.density-spacious` |
|
||||
| Properties | 122 pinned properties per element, plus every custom property on `:root` and `body` |
|
||||
|
||||
That is 676 elements x 24 variants = 16,224 element snapshots per run, in
|
||||
about 21 seconds.
|
||||
|
||||
The **app shell** page measures real elements in the markup the server sends,
|
||||
including modals - each one revealed on its own and re-hidden straight after,
|
||||
so the measurements stay independent.
|
||||
|
||||
The **bench** page measures one synthesised element per selector, built from
|
||||
the selector itself. Its selector list is evidence-driven: every selector
|
||||
declared **more than once** in `style.css` that can be expressed as a static
|
||||
compound chain (551 of them), plus a curated set covering chat, documents,
|
||||
email, notes, calendar, settings, cookbook and gallery. Redeclared selectors
|
||||
are the ones a reorder can actually flip, so they are the ones worth benching.
|
||||
A bench element pins the cascade for that class combination; it does not pin
|
||||
the markup that the JS produces.
|
||||
|
||||
Selectors the bench grammar cannot express are the gap: selector lists
|
||||
(`a, b`), pseudo-elements, pseudo-classes, `:not()` and `:has()`. They are
|
||||
skipped rather than approximated.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `inventory.json` | The fixed inventory: properties, variants, pages, elements, bench selectors |
|
||||
| `baseline.json` | The committed digest plus per-element and per-variant hashes |
|
||||
| `capture.mjs` | Playwright capture; raw values on stdout |
|
||||
| `bench.html` | Empty page that loads the stylesheet; the capture mounts bench nodes into it |
|
||||
| `../test_css_computed_style_snapshot.py` | The regression test |
|
||||
| `../../scripts/css_snapshot.py` | Hashing, comparison, and the CLI |
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
./venv/bin/python -m pytest tests/test_css_computed_style_snapshot.py
|
||||
./venv/bin/python scripts/css_snapshot.py --check # same comparison, standalone
|
||||
./venv/bin/python scripts/css_snapshot.py --write-baseline # re-record
|
||||
```
|
||||
|
||||
The CLI serves the repository on an ephemeral port itself, so it does not need
|
||||
pytest. Under pytest the session static server is reused through
|
||||
`ODYSSEUS_TEST_STATIC_ORIGIN`.
|
||||
|
||||
`npm ci` is required: the capture drives Playwright's Chromium. Without it the
|
||||
browser tests skip.
|
||||
|
||||
## When the test fails
|
||||
|
||||
The failure names the elements and the variants whose hashes moved. To see
|
||||
which *property* moved, capture both sides and diff:
|
||||
|
||||
```bash
|
||||
./venv/bin/python scripts/css_snapshot.py --dump after.json
|
||||
git stash && ./venv/bin/python scripts/css_snapshot.py --dump before.json && git stash pop
|
||||
diff <(python -m json.tool before.json) <(python -m json.tool after.json)
|
||||
```
|
||||
|
||||
Re-record the baseline only when the change in rendered style is **intended**
|
||||
and reviewed. On a mechanical CSS extraction it never should be: an extraction
|
||||
that preserves order produces an identical digest, and one that does not has
|
||||
changed the UI.
|
||||
|
||||
## Determinism
|
||||
|
||||
The digest is only worth having if an unchanged stylesheet always produces the
|
||||
same bytes, so the capture:
|
||||
|
||||
- strips every `<script>` from the document, leaving exactly the markup the
|
||||
server sends - no app module can mutate classes underneath the measurement;
|
||||
- injects the theme and density classes into `<html>` *before* first paint
|
||||
rather than toggling them afterwards, so no CSS transition is ever
|
||||
mid-interpolation while `getComputedStyle` runs;
|
||||
- aborts images, fonts and media, which cost time and change nothing in the
|
||||
pinned property set;
|
||||
- hides scrollbars, so a platform's scrollbar width cannot change the width
|
||||
that percentages and `auto` resolve against;
|
||||
- pins `prefers-reduced-motion`, `forced-colors`, `prefers-color-scheme` and
|
||||
the device pixel ratio.
|
||||
|
||||
## What it does not cover
|
||||
|
||||
- **Layout geometry.** `width`, `height`, `top`/`left`/`right`/`bottom`,
|
||||
`transform` and `grid-template-*` resolve to used values that depend on text
|
||||
layout, so they are excluded rather than risk a baseline that only holds on
|
||||
one machine. A reorder that changes a size through a property in the pinned
|
||||
set is still caught; one that changes it only through an excluded property is
|
||||
not.
|
||||
- **Pseudo-class states.** `:hover`, `:focus` and `:active` are not driven.
|
||||
- **JS-applied classes.** State the app adds at runtime (collapsed sidebar,
|
||||
open panels, active tabs) is not represented beyond what the served markup
|
||||
and the bench selectors already carry.
|
||||
- **Cross-platform equality has not been measured.** The baseline was recorded
|
||||
on macOS. The self-hosted Fira Code face means text metrics should not differ
|
||||
from CI's Linux Chromium, and the layout-derived properties are excluded, but
|
||||
until a Linux run confirms it, treat a CI-only drift as a possible harness
|
||||
artifact and diff the dumps before assuming the CSS moved.
|
||||
- **The stylesheet is only one of the inputs.** `static/login.html` styles
|
||||
itself from an inline `<style>` block; it is in the inventory so the
|
||||
hand-mirrored token values there are pinned too.
|
||||
|
||||
## Adding coverage
|
||||
|
||||
Add an entry to `inventory.json` - an `{key, selector}` object under a page's
|
||||
`elements` (with `"unhide": true` if it ships hidden, `"custom": true` to
|
||||
include custom properties), or a selector string under `bench` - then
|
||||
re-record the baseline. `test_baseline_covers_every_inventory_entry` fails if
|
||||
the two go out of sync.
|
||||
@@ -0,0 +1,767 @@
|
||||
{
|
||||
"digest": "e8ef2a81ebd7a4ab",
|
||||
"elements": {
|
||||
"app-shell": {
|
||||
"app-loader": "f04ad5bf6312d53f",
|
||||
"attach-strip": "a6474951e2481214",
|
||||
"body": "58df17f0afa190b9",
|
||||
"chat-container": "b00a7c6be4e107b1",
|
||||
"chat-context-pill": "dd905b87245a9a71",
|
||||
"chat-history": "e65bdc51f4d1675f",
|
||||
"confirm-btn-primary": "c906e0be9fe73bef",
|
||||
"cookbook-modal": "918b17b2e2a311dc",
|
||||
"cookbook-modal-content": "73700bbe8be47774",
|
||||
"custom-preset-modal": "9345624780009d14",
|
||||
"custom-system-prompt": "ef839a436cd42975",
|
||||
"export-dl-btn": "d3fe28e931b66974",
|
||||
"export-dropdown-item": "d90f6f513d06a6f7",
|
||||
"export-dropdown-menu": "0793e06a6aae6a7a",
|
||||
"hamburger-btn": "34714f6eef3734d8",
|
||||
"icon-rail": "e3f72205260a4a5f",
|
||||
"icon-rail-btn": "54ffb10e056ebf44",
|
||||
"incognito-btn": "ee42bd3f6a32576b",
|
||||
"input-icon-btn": "3d1c773acd84a825",
|
||||
"memory-close-btn": "c3f4ba99098a6e4b",
|
||||
"memory-list": "da9a37a3de3c1a31",
|
||||
"memory-modal": "5efc81537eab4526",
|
||||
"memory-modal-content": "3cadc8581361436e",
|
||||
"memory-modal-header": "a8c822fe8c6d20e7",
|
||||
"memory-search-input": "8b0fbb33401750d4",
|
||||
"memory-toolbar-btn": "9f985efa7c896f90",
|
||||
"message-ghost": "ce9144a7705ee903",
|
||||
"message-textarea": "ae703a5c12022f08",
|
||||
"mobile-backdrop": "b2ed3a84835d4916",
|
||||
"mode-toggle-active": "9dfc0fe8866d86c2",
|
||||
"mode-toggle-idle": "4a221dded6bec4d6",
|
||||
"model-picker-btn": "d44aa4f424c3447e",
|
||||
"model-picker-list": "23a89bcc30d0ccee",
|
||||
"model-picker-menu": "f3320494f37c1156",
|
||||
"overflow-menu": "e40a9742168a2a8b",
|
||||
"overflow-menu-item": "1a7cad32047dc976",
|
||||
"pinned-tools-bar": "bb0f88ce29ae7637",
|
||||
"rail-new-chat": "f8f1c2f6be426ab9",
|
||||
"reasoning-effort-btn": "800d3d3edff6bf85",
|
||||
"rename-session-modal": "9345624780009d14",
|
||||
"root": "e3897afe51e821cc",
|
||||
"save-custom-preset": "443da70ef5fe9b88",
|
||||
"scroll-bottom-btn": "24602d72e2949991",
|
||||
"search-input": "53b94f3e5fdb384d",
|
||||
"search-overlay": "87d5f21c20bdde98",
|
||||
"session-list-bootstrap": "aaa28ed4edac84a9",
|
||||
"session-name-input": "c435986f102bee53",
|
||||
"settings-admin-card": "fcf3c80f20175a3c",
|
||||
"settings-btn-add": "82aeb52b9fc7de0f",
|
||||
"settings-btn-sm": "c31bcb9bdfbae10d",
|
||||
"settings-fallbacks": "e339280590935fe3",
|
||||
"settings-logs-tab": "e470140373597143",
|
||||
"settings-modal": "5efc81537eab4526",
|
||||
"settings-nav-search": "f4484b2980f2ccc5",
|
||||
"settings-row": "de568724268cec6e",
|
||||
"settings-select": "a96e8430b0ec6668",
|
||||
"settings-sidebar-toggle": "6be9025ab24bc434",
|
||||
"sidebar": "77ec42a646a8db66",
|
||||
"sidebar-brand": "d3d7ef4b21514e80",
|
||||
"sidebar-new-chat": "567fae30048b7ff3",
|
||||
"sidebar-resize-handle": "6607b819ee45409d",
|
||||
"sidebar-section": "a46f059ca4b0b4b5",
|
||||
"sidebar-section-header-btn": "eff26568c49f8b40",
|
||||
"sidebar-user-bar": "b77372dbdcb069ef",
|
||||
"theme-grid": "4d4b2aeb8952cc6f",
|
||||
"theme-io-btn": "859f222c79a8c721",
|
||||
"theme-modal": "0a65ca916656cdbb",
|
||||
"theme-popup": "3aa65e6f313bbc65",
|
||||
"theme-tabs": "0cc4987f39876dce",
|
||||
"toast": "c60c6a60d4982005",
|
||||
"tool-indicator": "0dee45eaca8b703a",
|
||||
"user-bar-avatar": "b989bbab5aab3988",
|
||||
"user-bar-settings": "2d91a65e0ebdd049",
|
||||
"welcome-screen": "a1dc93c81a7d1899",
|
||||
"welcome-sub": "89cdafbb95b7c845",
|
||||
"welcome-tip": "875045a9d2ec1055"
|
||||
},
|
||||
"bench": {
|
||||
"#agent-indicator": "09a1b8d94b1487ee",
|
||||
"#agent-indicator.active": "67e2ad3fa48acd3b",
|
||||
"#compare-model-overlay .modal-body": "0b1ee357313551b9",
|
||||
"#compare-model-overlay .modal-content": "56883147d181bd9c",
|
||||
"#cookbook-modal .cookbook-body": "20bd8a7268cee788",
|
||||
"#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card": "48f6787a3df3f19e",
|
||||
"#cookbook-modal .modal-content": "0c4e1e686df0990e",
|
||||
"#cookbook-modal .modal-content.cookbook-modal-entering": "fba9bbbb406a70c4",
|
||||
"#cookbook-modal .modal-header": "1445de059f94beb9",
|
||||
"#cookbook-server-add.cal-add-btn-text": "36d5d25f82aad559",
|
||||
"#doc-actions-footer": "a517afe6dd10dbf3",
|
||||
"#doc-actions-footer #doc-copy-export-split": "70b267bc3460ba24",
|
||||
"#doc-actions-footer #doc-footer-copy-btn": "b339e77a5e9730c1",
|
||||
"#email-lib-compose-btn": "3b21baa4a3d6bec8",
|
||||
"#email-lib-compose-btn svg": "6decb15adb02a12b",
|
||||
"#email-lib-modal .admin-card": "fb6ad83c8330d140",
|
||||
"#email-lib-modal .doclib-grid": "99515ed7d3c12dc8",
|
||||
"#email-lib-modal .email-lib-fab.fab-revealed": "9668afab75e5e45d",
|
||||
"#email-lib-modal .email-lib-sync-status": "6d1a0374a16cf7f4",
|
||||
"#email-lib-modal .email-reader-atts": "c5d68ecda3c14bb8",
|
||||
"#email-lib-modal .email-reader-header": "85afca11c8cbd38e",
|
||||
"#email-lib-modal .modal-content": "d8b8fff38c613c95",
|
||||
"#email-lib-settings-btn": "9ae15bf04b6a22ce",
|
||||
"#email-unread-dot.sidebar-notif-dot": "c80f1e03592983b6",
|
||||
"#export-compact-btn": "7d0325a38e51134b",
|
||||
"#gallery-editor-drafts-bulk": "9d8c00a765d6689e",
|
||||
"#gallery-editor-drafts-select": "694fb627310c9295",
|
||||
"#ge-history-panel": "26ec3f6b3e6d2751",
|
||||
"#hwfit-cache-select": "62ab492dd8fad73d",
|
||||
"#memory-modal .doclib-card.skill-card.doclib-card-expanded > .doclib-card-preview": "10834da1800d529b",
|
||||
"#message": "018b0ab2128d9d34",
|
||||
"#modal-dock": "bc81bafabc09440c",
|
||||
"#mode-agent-btn": "5cccc6edda975b2a",
|
||||
"#mode-chat-btn": "77af83dd6bfcd26c",
|
||||
"#recording-indicator": "e856ac42970d1a8c",
|
||||
"#recording-indicator.error": "8a1bcc599c3c237b",
|
||||
"#recording-indicator.hidden": "c8b3476b1107b2e2",
|
||||
"#research-pane": "e465bba8b1555f51",
|
||||
"#research-pane #research-past-list .research-job-card": "721dde2eb52cb7ed",
|
||||
"#research-pane #research-past-list .research-job-header": "c4d23f4b8bc102a8",
|
||||
"#research-pane #research-past-list .research-job-more": "414c2b05ad7e45e0",
|
||||
"#research-pane .research-history-card": "e9d829f0fda26f94",
|
||||
"#research-pane .research-history-toolbar-row": "33ce111a10432f3b",
|
||||
"#research-pane .research-history-toolbar-row .memory-sort-select": "6054d616490a49eb",
|
||||
"#research-pane .research-pane-body": "7e10ca158f86d408",
|
||||
"#session-select-all-dot": "d72b24787bef1c6a",
|
||||
"#session-sort-btn": "bb0f88ce29ae7637",
|
||||
"#sessions-section": "02a1d6939b9ef639",
|
||||
"#sessions-section #session-list": "5f7dfc0825eec2ee",
|
||||
"#sessions-section .chats-manage-btn": "63fe8c42ed236f21",
|
||||
"#sidebar-backdrop": "f46fd74d98873484",
|
||||
"#stop-recording": "d5489a601cfabf69",
|
||||
"#styled-confirm-overlay": "64466ea74bcf53ed",
|
||||
"#theme-popup": "f22c8010b180b42d",
|
||||
"#theme-tab-customize .color-row": "e3721ab86ea18eef",
|
||||
"#theme-tab-customize .theme-custom": "9b8fe83f16e039ee",
|
||||
"#welcome-screen": "7024d434f65886c9",
|
||||
"#welcome-screen .welcome-name": "01cffb1d086a1ab3",
|
||||
"#welcome-screen .welcome-sub": "89cdafbb95b7c845",
|
||||
"#welcome-screen .welcome-tip": "875045a9d2ec1055",
|
||||
"#welcome-screen .welcome-version": "3a850e9283976c34",
|
||||
".admin-btn-add": "8a933cbf8ac270ce",
|
||||
".admin-btn-sm": "1419bf7301e47b66",
|
||||
".admin-card": "0ae3d86c8cfeb751",
|
||||
".admin-model-form-row input": "1d820446629f79c4",
|
||||
".admin-slider": "836a69285985c958",
|
||||
".admin-switch": "876abcc0f10c2d55",
|
||||
".agent-thread": "f6198eddb1a9969f",
|
||||
".agent-thread-cmd": "54d5ece7fa9de68c",
|
||||
".agent-thread-content": "c60aadf77a2a5883",
|
||||
".agent-thread-dot": "d59e25a8c69eeac4",
|
||||
".agent-thread-header": "a336602ef4d31c1c",
|
||||
".agent-tool-diff summary": "4128de76cb424c63",
|
||||
".agent-tool-output pre": "530700f4a0699704",
|
||||
".archive-list": "a180dd2654ef8229",
|
||||
".archive-menu-btn": "9f508f063b881978",
|
||||
".ask-user-card": "f2dee20264814c84",
|
||||
".ask-user-card-attached": "e300c7fb5bc70b1f",
|
||||
".ask-user-option": "531ea450e6af3121",
|
||||
".ask-user-option-desc": "22de16b97f42ea38",
|
||||
".ask-user-option-label": "f555c9bd471d5421",
|
||||
".ask-user-other": "0b2855d42c5d7f32",
|
||||
".ask-user-other-send": "2144fc24a8bf91c2",
|
||||
".ask-user-question": "05a91b0c244960fc",
|
||||
".attach-image-preview": "b5dd397be5ad69aa",
|
||||
".attach-ocr-btn": "b0d7e3be6781d365",
|
||||
".attach-ocr-btn svg": "8b09b0b7140aae98",
|
||||
".attach-strip": "a6474951e2481214",
|
||||
".btn-spinner": "14215771d1d171fb",
|
||||
".cal-add-label": "72fd3ef86c3bbfcb",
|
||||
".cal-add-plus": "a1b0414e63b6ac40",
|
||||
".cal-day": "4ca6a688a40d4db2",
|
||||
".cal-day-num": "a4745da9f0b703bc",
|
||||
".cal-detail-header": "c81d6378318b5cb9",
|
||||
".cal-event-item": "e631fe656b59f308",
|
||||
".cal-event-row": "8dca80590c370f70",
|
||||
".cal-filter-item": "131d083db2c9c116",
|
||||
".cal-filter-toggle": "757cfe6e18bc90eb",
|
||||
".cal-filters": "0e423e393699903b",
|
||||
".cal-form-bespoke": "91129760df1ca566",
|
||||
".cal-form-mobile-cancel": "bde3b168ed685bb0",
|
||||
".cal-grid": "92c99ce3581e92c9",
|
||||
".cal-hero-time": "7474b3c587ca5d98",
|
||||
".cal-modal-container": "3ed41f999d50bd8f",
|
||||
".cal-multiday": "bc5ae2259e9db38a",
|
||||
".cal-quickadd-row": "b90bdc13d6453de7",
|
||||
".cal-splitter": "871cd8b0eae95d23",
|
||||
".cal-splitter-grip": "73e88032078aac16",
|
||||
".cal-title": "162d427104c49b46",
|
||||
".cal-toolbar": "77d67b21b75d89fc",
|
||||
".cal-toolbar-nav": "855c82a6eb672a85",
|
||||
".cal-toolbar-right": "b6059b5f1a77c077",
|
||||
".cal-wk-block": "5763c2f0c31ff3d8",
|
||||
".cal-wk-block-name": "edc7443f13567025",
|
||||
".cal-wk-block-time": "2fe325de785343a0",
|
||||
".cal-wk-wrap": "de61e4d3f59cf949",
|
||||
".cal-wk-zoom": "e5edf7c699287e58",
|
||||
".cal-year": "76d643a419e7656e",
|
||||
".cal-year-cell": "25a1642b5b698c0b",
|
||||
".chat-container": "b00a7c6be4e107b1",
|
||||
".chat-container.welcome-active .chat-input-bar": "c6510a7083e92ce8",
|
||||
".chat-history": "4dd22e235be04a73",
|
||||
".chat-input-bar": "fa08ce1f4b0cdb46",
|
||||
".chat-meta-overlay": "fc44623332db5eac",
|
||||
".chat-meta-overlay #current-meta": "1ae4281cd2a008d4",
|
||||
".chat-top-bar": "28bc79270a878362",
|
||||
".close-btn": "9c3b6334c7b25fee",
|
||||
".compare-add-flap": "bcc20ba15ee5b0ed",
|
||||
".compare-add-flap span": "b40d213f00c806d3",
|
||||
".compare-grid": "f6f69aa41d74c616",
|
||||
".compare-grid.sequential-layout .compare-pane .pane-header": "235ba192ba9c7f3e",
|
||||
".compare-mobile-tabs": "62c5cbb34f339509",
|
||||
".compare-mode-tabs": "e2177d8b26c19145",
|
||||
".compare-pane .pane-header": "07ab4c430c694f8a",
|
||||
".compare-probe-card": "06a67cf386d5bdec",
|
||||
".compare-probe-detail": "db787590d19ef4cc",
|
||||
".compare-probe-detail-message": "57eeaebb2a9b0a4f",
|
||||
".compare-probe-list": "78deccee6c00025e",
|
||||
".compare-toggle-label": "e200b7dff04bf81b",
|
||||
".confirm-btn": "a8518c6834fb7ac0",
|
||||
".cookbook-body": "1a10500fe1e306f0",
|
||||
".cookbook-btn": "40ad2e8df76f2d9c",
|
||||
".cookbook-card-desc": "f6ef9079e5e15a2d",
|
||||
".cookbook-card-title": "3df0f721555953a0",
|
||||
".cookbook-checkbox-label": "aac8b53d01d979cf",
|
||||
".cookbook-cmd-preview": "c565845df485518e",
|
||||
".cookbook-dep-build-deps": "15280be2979ad180",
|
||||
".cookbook-dep-row": "57e3a98d97f7b9c8",
|
||||
".cookbook-dep-tag": "97d68d0162f39744",
|
||||
".cookbook-dl-btn": "cc7fe1bb0c79630a",
|
||||
".cookbook-dl-repo": "50b7fc43bce8f35c",
|
||||
".cookbook-field-input": "a12fc90dcf7e9de3",
|
||||
".cookbook-field-label": "afc196d6dccbbc6f",
|
||||
".cookbook-gpu-btn": "52b45862dd394f30",
|
||||
".cookbook-gpu-btn.active": "5bff77f9adcb7448",
|
||||
".cookbook-official-filter": "6bdea60315f7d0da",
|
||||
".cookbook-output-pre": "40fc0611f762b039",
|
||||
".cookbook-run-btn": "c816359f8655bef2",
|
||||
".cookbook-saved-host": "cf6b4983fa9bebd0",
|
||||
".cookbook-saved-name": "d434036dd310ca6a",
|
||||
".cookbook-section-header .cookbook-clear-btn": "e19b1a13059aa249",
|
||||
".cookbook-section-title": "c7c5399d03a5b9c2",
|
||||
".cookbook-serve-preset-meta": "1e2786a24b30d019",
|
||||
".cookbook-serve-preset-name": "abf2efbb491f8eb9",
|
||||
".cookbook-serve-title": "dbc48ae1de4db056",
|
||||
".cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-dot": "ff38b51b211440c0",
|
||||
".cookbook-settings-hint": "cc67f7db1e73911c",
|
||||
".cookbook-settings-input": "a6e3b4a498232fcb",
|
||||
".cookbook-settings-label": "9f811c48f43831c3",
|
||||
".cookbook-settings-stack": "eb21f8352c23e9d0",
|
||||
".cookbook-slot-btn": "185943b4d681c18e",
|
||||
".cookbook-tab": "cbac98e770f1b5c7",
|
||||
".cookbook-tab-error-dot": "083ff5e013f0f752",
|
||||
".cookbook-task-check": "203f3f00e300b69a",
|
||||
".cookbook-task-header": "17633a41a71fb15c",
|
||||
".cookbook-task-menu-btn": "fbec9d85e73f99c2",
|
||||
".cookbook-task-name": "b5e36c1e6dfc2196",
|
||||
".cookbook-task-status": "832d485fa467a2c9",
|
||||
".cookbook-task-sub": "e67555ec0e2edcfb",
|
||||
".cookbook-task-wave": "8fdc66c19c2f2169",
|
||||
".cp-popover": "a2aa1ac1ca1ba392",
|
||||
".cp-sl": "3332f2879d861c24",
|
||||
".diff-chunk-btn": "31f68158a063bfc1",
|
||||
".diff-toolbar": "28e32a3239b7b603",
|
||||
".diff-toolbar-btn": "14e8f6a294cf4088",
|
||||
".doc-action-icon-btn": "392100f636cc0f05",
|
||||
".doc-docx-paper": "c6d5777d00f18064",
|
||||
".doc-docx-preview": "a25a0a2fc4de255d",
|
||||
".doc-editor-header": "253267e87c976100",
|
||||
".doc-email-collapse-btn": "417a42c5c91f9a22",
|
||||
".doc-email-header": "0cf44218df62de2a",
|
||||
".doc-email-richbody.doc-font-m": "613a4e9d172bb44d",
|
||||
".doc-language-select": "90a29013c434753c",
|
||||
".doc-line-numbers": "a7aa45b481331f5b",
|
||||
".doc-md-toolbar": "cf5013815486fa9b",
|
||||
".doc-md-toolbar button": "7790f81a78a6e4cb",
|
||||
".doc-mobile-footer": "b2ed3a84835d4916",
|
||||
".doc-outline-item": "5e404c8d680cfc69",
|
||||
".doc-outline-menu": "f29f6f5a8e53fd57",
|
||||
".doc-overflow-item": "d10da3bab3e43ce4",
|
||||
".doc-preview-hover-edit": "b78f58de2e582325",
|
||||
".doc-rich-empty-import": "7ee18d60aed92aab",
|
||||
".doc-rich-selection-toolbar": "4f6431c0ab8b4b08",
|
||||
".doc-rich-selection-toolbar .doc-rich-selection-close": "1df07aa1a7ea60b7",
|
||||
".doc-rich-selection-toolbar button": "73704ff1c15f0013",
|
||||
".doc-rich-slash-item": "d8c5cbb3e3e3e5d6",
|
||||
".doc-rich-slash-menu": "2e31f63219c116aa",
|
||||
".doc-save-button[data-save-state=\"saving\"] .doc-save-state-saving": "84ecc420b557e35d",
|
||||
".doc-selection-clear": "b0d9428152a09e39",
|
||||
".doc-suggestion-card": "b1db7167c4231257",
|
||||
".doc-suggestion-close": "8fbd4d5f6ba460b6",
|
||||
".doc-suggestion-nav-btn": "befb117a2fd3fa7d",
|
||||
".doc-tab": "8f563b5e946c20be",
|
||||
".doc-tab-bar": "0b65732476210fd4",
|
||||
".doc-tab-close": "6c1e0e8793444210",
|
||||
".doc-tab-lang": "8b0efacad728c610",
|
||||
".doc-tab-new": "def25060720b95fc",
|
||||
".doc-version-panel": "1073efb40f164f6b",
|
||||
".doclib-btn-hint": "1e7b736920f0afa7",
|
||||
".doclib-card": "6771693fee18c8b3",
|
||||
".doclib-card-action-btn": "4e2f069505c5dbb5",
|
||||
".doclib-card-expanded-actions": "33a86d25e6b02b9c",
|
||||
".doclib-card-header": "af0f97b1f3004360",
|
||||
".doclib-card-preview": "2c6e654086abaa34",
|
||||
".doclib-card.doclib-card-expanded": "1ecfff38fa7abc2c",
|
||||
".doclib-card.email-card-removing": "e379729bdba2a0eb",
|
||||
".doclib-chat-row.doclib-card-expanded": "dfd424102537f179",
|
||||
".doclib-chat-row.doclib-card-expanded .doclib-chat-preview": "dabb220508f9eae3",
|
||||
".doclib-chat-row.doclib-card-expanded .doclib-chat-preview-messages": "d9e01bfd21a07b37",
|
||||
".doclib-chip-scroll-arrow": "40757af6c9e795fb",
|
||||
".doclib-expanded-delete-btn": "bb0f88ce29ae7637",
|
||||
".doclib-grid": "34c18c37757a199f",
|
||||
".doclib-lang-chips": "6d351e1534d7b081",
|
||||
".doclib-toolbar": "dc9ec6073f2c3605",
|
||||
".dropdown": "bed9ac24c6696903",
|
||||
".dropdown-cancel-mobile": "e3b3786fda598403",
|
||||
".dropdown-divider": "5738d6943e2b555b",
|
||||
".dropdown-item": "5c8fd729a604d69a",
|
||||
".dropdown-item-compact": "984fa882578bfc56",
|
||||
".dropdown-item-compact .dropdown-icon": "b75dab733d89dbc1",
|
||||
".dropdown-item-compact .dropdown-icon svg": "b39764818e19ca4c",
|
||||
".email-attach-toggle-inline": "18470eba1fd88166",
|
||||
".email-attachment-chip.is-expanded": "833a4ff805e57c88",
|
||||
".email-attachment-open": "b2e7d5021587cf3b",
|
||||
".email-bubble": "84c884b461b7bfd6",
|
||||
".email-bubble-avatar": "642766e3dbe32509",
|
||||
".email-card-unread-dot": "33dea788cbd97824",
|
||||
".email-compose-chip .compose-chip-name": "58ca58105c731e0b",
|
||||
".email-draft-btn": "f256eb68fc8b17e3",
|
||||
".email-field": "981e2a2bef7d2401",
|
||||
".email-field #doc-email-subject": "0864bcaf3b3dffb0",
|
||||
".email-field input": "b4b0542ee0ce5b2a",
|
||||
".email-field label": "c6076082752aed3e",
|
||||
".email-filter-btn": "cc290eac8042b6f7",
|
||||
".email-filter-menu": "dc5d1dfacdd26733",
|
||||
".email-filter-picker": "9ae15bf04b6a22ce",
|
||||
".email-inline-image-actions": "891b1549d5e6a2ab",
|
||||
".email-inline-image-skeleton": "0d20c32f79dbe7f7",
|
||||
".email-odysseus-attach-list": "fc3cf3fa0fe725dd",
|
||||
".email-odysseus-attach-menu": "ca6e3ea838c14777",
|
||||
".email-quote-fold .email-fold-summary": "38395d57d7800777",
|
||||
".email-reader": "bb0f88ce29ae7637",
|
||||
".email-reader-body": "8ce048cdcb667db7",
|
||||
".email-reader-body .email-inline-image-placeholder": "deebf724fa3a2a5d",
|
||||
".email-reader-header": "066a5ffcb140bbc9",
|
||||
".email-reader-header > .email-reader-actions": "0331e25e809e0d0b",
|
||||
".email-reader-meta": "6d84db440dcf1dcf",
|
||||
".email-reader-meta .recipient-chips": "c9ceda20ba24a94b",
|
||||
".email-reader-meta > .email-reader-actions-inline": "06ecc1c60b199e44",
|
||||
".email-reader-meta-row strong": "0af50deb999967c3",
|
||||
".email-reminder-toggle-inline": "18470eba1fd88166",
|
||||
".email-search-options-menu button": "f112d02b7f4b2a3f",
|
||||
".email-search-row .memory-search-input": "f5edf94bbb3a2730",
|
||||
".email-send-btn": "8cd36039dde7b23a",
|
||||
".email-settings-auto-reply-section .email-settings-section-body": "a09c1e68706d8dc3",
|
||||
".email-settings-auto-reply-section .email-settings-section-desc": "8d8ce3cc398fc912",
|
||||
".email-settings-auto-reply-title-row": "bfc6bb8acd83b4c3",
|
||||
".email-settings-clean-btn": "398f211c2e62b7be",
|
||||
".email-settings-display-section .email-settings-section-desc": "f540b150a9ba3b16",
|
||||
".email-settings-grid": "eee5ec449a8f214e",
|
||||
".email-settings-section[open] > .email-settings-section-body": "88b6ae868ea5a387",
|
||||
".email-tags-toggle-inline": "8b5bcb3c17df370e",
|
||||
".email-undone-toggle-inline": "18470eba1fd88166",
|
||||
".export-dl-btn": "fc2a9d706e560a26",
|
||||
".export-dropdown-item": "8ec724a091aa55c8",
|
||||
".export-dropdown-item .dropdown-icon": "4e597b835e44680b",
|
||||
".export-dropdown-menu": "11a65ef24743b950",
|
||||
".gallery-album-chips": "9c85df6af61b4447",
|
||||
".gallery-albums-grid": "19900300e1bee43f",
|
||||
".gallery-chip": "3df2d7f6ea8bf569",
|
||||
".gallery-detail-body": "2ccc994e7a9fc0bc",
|
||||
".gallery-detail-image": "bb4a5fa517c1d077",
|
||||
".gallery-detail-meta-grid": "0804ee3dd5a92578",
|
||||
".gallery-detail-nav": "265fadf513a58d78",
|
||||
".gallery-detail-sidebar": "b47067b4c540d312",
|
||||
".gallery-detail-title": "3a61a73ed392c545",
|
||||
".gallery-dl-btn": "afa5e7125e483700",
|
||||
".gallery-editor-draft-delete": "501e6db6e97bd5fc",
|
||||
".gallery-editor-drafts": "e28e1aa9423d297b",
|
||||
".gallery-editor-drafts-header": "14467c19ae89fd16",
|
||||
".gallery-editor-drafts-search": "456f4e599d692064",
|
||||
".gallery-editor-drafts-title": "07556469d7e5bfcd",
|
||||
".gallery-editor-landing": "a5ae5067720f044d",
|
||||
".gallery-editor-landing-actions": "dec9a0fd207ba1bc",
|
||||
".gallery-editor-landing-actions .gallery-select-btn": "c92cc6790f517732",
|
||||
".gallery-editor-template-options": "9b8c6bae4cc423a7",
|
||||
".gallery-editor-template-picker": "0a04414510047d6f",
|
||||
".gallery-grid": "9193704237593d45",
|
||||
".gallery-search": "499b1d5db75d8637",
|
||||
".gallery-search-enter-hint": "59988183b5e2231e",
|
||||
".gallery-search-wrap": "a3a65cb89f837b1a",
|
||||
".gallery-select-btn": "6062fab15408c8d2",
|
||||
".gallery-tab": "856ba5e8322b2ac3",
|
||||
".gallery-tabs": "6e77ea3e6cf981c9",
|
||||
".gallery-toolbar": "cbee92b65715f862",
|
||||
".gallery-toolbar-break": "b2ed3a84835d4916",
|
||||
".ge-adj-icon": "405c77944c2f21c7",
|
||||
".ge-adj-popup": "e1a93233ab11f043",
|
||||
".ge-adj-row": "fed828d535ba710b",
|
||||
".ge-adj-row > label": "21bd09c7cf6167e5",
|
||||
".ge-adj-row input[type=\"range\"]": "3f1690aad291a7da",
|
||||
".ge-adj-title": "7609b8e4f862ad23",
|
||||
".ge-ai-command-collapsed": "35a892b0246538ba",
|
||||
".ge-alpha-badge": "59eb1bb7eddf6df0",
|
||||
".ge-canvas-area": "b6a7426fcd6ad230",
|
||||
".ge-canvas-prompt": "97ddee153cdaf1f2",
|
||||
".ge-canvas-prompt-footer": "153de6a15c89d60d",
|
||||
".ge-clone-hint-mobile": "fde6e603285602e2",
|
||||
".ge-controls": "004ef864b98d552f",
|
||||
".ge-edge-menu": "6734a5a4c658fb59",
|
||||
".ge-editor-body": "96e859c8c9bfe4f9",
|
||||
".ge-export-body": "9746be0da055b9cf",
|
||||
".ge-export-dialog": "07273df75fec40a5",
|
||||
".ge-export-overlay": "71118be46a543136",
|
||||
".ge-export-preview": "d15f3deb0fea3bca",
|
||||
".ge-export-preview-wrap": "022e7646bf83adb1",
|
||||
".ge-fx-menu": "34ba0908e3d27a7d",
|
||||
".ge-gradient-color-row": "ae1866697d4fb492",
|
||||
".ge-inpaint-popup": "375180aa2e257450",
|
||||
".ge-inpaint-popup-input": "82d3fa4cb683d69e",
|
||||
".ge-layer-action-flash": "3adf53342ca69b3f",
|
||||
".ge-layer-drag": "bec7d2f34c0e079b",
|
||||
".ge-layer-geometry-grid": "d478f4137955e85a",
|
||||
".ge-layer-group-row .ge-group-inline-thumb": "bb0f88ce29ae7637",
|
||||
".ge-layer-group-row .ge-layer-controls": "da1a0cafc90aff7f",
|
||||
".ge-layer-group-row .ge-layer-group-drag": "33cf63103dbea379",
|
||||
".ge-layer-group-row .ge-layer-group-name": "48642695eec50f01",
|
||||
".ge-layer-group-row .ge-layer-group-toggle": "cb04950c0a50aacb",
|
||||
".ge-layer-group-row .ge-layer-lock-btn": "bb0f88ce29ae7637",
|
||||
".ge-layer-group-row .ge-layer-opacity-row": "e697b236b3b4ba6e",
|
||||
".ge-layer-group-row .ge-layer-vis": "2e6dbbab8d823b4f",
|
||||
".ge-layer-lock-menu": "528deb6e31022e89",
|
||||
".ge-layer-lock-option": "38a3da4a8785330a",
|
||||
".ge-layers-grab": "094e764489b1b069",
|
||||
".ge-layers-header": "03ee9ca6c3fc912b",
|
||||
".ge-layers-list": "18de5e01e079682f",
|
||||
".ge-main-canvas": "274650d62945ac12",
|
||||
".ge-mask-sub-item": "546f03f9c46eb507",
|
||||
".ge-right-panel": "3d0aed961f2df331",
|
||||
".ge-section-help": "8bd5ecd734f48ca2",
|
||||
".ge-shortcuts-grid": "84d3ab6453dab9b3",
|
||||
".ge-slider-bubble": "2bc54e40357f68cf",
|
||||
".ge-tool-btn": "fd54cca726e51429",
|
||||
".ge-tool-sep": "4ae61e4e4eddd504",
|
||||
".ge-toolbar": "ca827cf012ad8863",
|
||||
".ge-topbar": "6c39a6ade412d5fa",
|
||||
".ge-transform-field": "76a785f630632652",
|
||||
".ge-transform-field > input.ge-transform-popup-input": "b870e4e9d5fc2d91",
|
||||
".ge-transform-popup-body": "d9d70292e5be7acc",
|
||||
".ge-transform-popup-head .ge-adj-icon": "88ac29d44343e6fa",
|
||||
".ge-transform-popup-head .ge-adj-title": "1d6ca66f2c39c9ef",
|
||||
".ge-transform-popup-head .ge-head-btns": "ecd0a09cb27407d7",
|
||||
".ge-transform-popup-hint": "402aac74aea3c779",
|
||||
".ge-transform-popup-suffix": "d55491303f919f61",
|
||||
".ge-transform-quick-btn": "e74ea16e29b9b001",
|
||||
".ge-transform-spin": "22dc5f2bfab2ec9a",
|
||||
".ge-transform-spin button": "527810c9f1ac1be5",
|
||||
".ge-transform-spin button[data-spin=\"down\"]": "c0aff9fcdb755ed2",
|
||||
".ge-transform-spin button[data-spin=\"up\"]": "4fccf8fbe6c04fa9",
|
||||
".hamburger": "2b0e4a6cb3333ded",
|
||||
".hamburger span": "6e2fee4770672a20",
|
||||
".hamburger-btn": "dc0f91fb82ccd472",
|
||||
".hljs": "6ecc4f2f88eac570",
|
||||
".hwfit-cached-item .hwfit-serve-panel": "14fffc9c4952ddd5",
|
||||
".hwfit-cached-menu-btn svg": "f7ad8ff47d5e5a9e",
|
||||
".hwfit-context-calc-btn": "30ab84fd32699c1a",
|
||||
".hwfit-context-control": "31f335a5f5f7093b",
|
||||
".hwfit-context-control .hwfit-sf[data-field=\"ctx\"]": "c4367757132248ae",
|
||||
".hwfit-ctx-control": "d175d7225fe97089",
|
||||
".hwfit-ctx-control input[type=\"range\"]": "947b1970077d4f4a",
|
||||
".hwfit-ctx-control output": "9a1f476037c00dc2",
|
||||
".hwfit-ctx-control span": "66e420440d60beee",
|
||||
".hwfit-help-chip": "d51bfab71de7b80e",
|
||||
".hwfit-hw-chip-toggle": "e0e58ca582f813cd",
|
||||
".hwfit-numstep": "0ec0a0c4db53c2cd",
|
||||
".hwfit-panel-fields": "4e6d730d4a665d2e",
|
||||
".hwfit-panel-fields input[type=\"text\"]": "caeeb82204c15e3e",
|
||||
".hwfit-preset-model": "f86026efe0d3498c",
|
||||
".hwfit-sched-day-chip": "0a2f0ddb74896f9f",
|
||||
".hwfit-schedule-row": "ee03e04722554137",
|
||||
".hwfit-serve-cmd-details[open] > .hwfit-serve-cmd-summary": "4952b4e93e125c16",
|
||||
".hwfit-serve-preset-row": "c6c4d6a2f3c3e6c3",
|
||||
".hwfit-serve-row .cookbook-serve-slots": "a7be413a7490cc01",
|
||||
".hwfit-serve-row-core .hwfit-context-label": "a0b969fbae15de72",
|
||||
".hwfit-serve-topline": "d9feede09cc16f87",
|
||||
".hwfit-serve-topline .hwfit-serve-runtime-note": "15b3fef4e5ceaffc",
|
||||
".hwfit-serve-topline .hwfit-serve-runtime-text": "57c6822b0d13147d",
|
||||
".hwfit-spec-group .hwfit-spec-method": "5d1c329116272cf1",
|
||||
".icon-rail": "e3f72205260a4a5f",
|
||||
".icon-rail-btn": "a5f207c3be15401e",
|
||||
".incognito-btn": "9833a2e86fab6a94",
|
||||
".incognito-indicator": "b3945c46844c7278",
|
||||
".input-icon-btn": "2e073a8e266f201e",
|
||||
".item-drag-handle": "5f889f0059232495",
|
||||
".list-item": "0653eb1e34c6b7d1",
|
||||
".list-item .hamburger": "2092eaea70b49de6",
|
||||
".loading-dot": "686e1bb53f792ac5",
|
||||
".loading-dots": "997b347b61484fc0",
|
||||
".loading-indicator": "eb9f3c301fbb4976",
|
||||
".md-scroll-arrow svg": "6f1ae7652f2dd51a",
|
||||
".md-toolbar-items #doc-pdf-add-text-btn": "950a49c2ef935d9e",
|
||||
".md-toolbar-sep": "146c50b38436d02a",
|
||||
".md-view-toggle": "b092a4e770904171",
|
||||
".md-view-toggle .md-view-opt": "3192fcae7d1f0ab6",
|
||||
".md-view-toggle .md-view-opt.active": "3ee0bf92dfab3072",
|
||||
".memory-item": "85cccfa273eae409",
|
||||
".memory-item-actions": "8642d181c8fe3c2a",
|
||||
".memory-item-actions .memory-item-btn": "7a9214669a7cc7b6",
|
||||
".memory-item-title": "d50a89fe377bbe0d",
|
||||
".memory-menu-btn": "d7378a97a127f815",
|
||||
".memory-tab-panel[data-memory-panel=\"skills\"]": "f18376de38243808",
|
||||
".memory-toolbar": "9285742f00c21d49",
|
||||
".memory-toolbar-btn": "a67a54d4043e34ab",
|
||||
".minimize-btn": "d22769aec9c30099",
|
||||
".minimized-dock-chip": "af70d8f0eb594924",
|
||||
".minimized-dock-chip svg": "5f94d9cfc3935c34",
|
||||
".mobile-new-chat-btn": "8505c445195e7b15",
|
||||
".modal": "9345624780009d14",
|
||||
".modal-content": "e5b005cecef5d140",
|
||||
".modal-content.modal-closing": "8bdccec5cd43b27b",
|
||||
".modal-header": "2c8f003ed4b092ad",
|
||||
".mode-toggle": "43526722c32158c5",
|
||||
".mode-toggle-btn": "08df82025829cf7b",
|
||||
".mode-toggle.mode-toggle-three [data-llama-mode=\"unified\"] > span": "adf60064ba19f5de",
|
||||
".model-picker-list .model-switch-item": "9077ef9414c73080",
|
||||
".model-picker-list .mp-fav-dot": "cde6ff8162ad99db",
|
||||
".models-row": "d882bd9517efbca7",
|
||||
".msg": "e874630644527764",
|
||||
".msg .body": "454c7e06516e1201",
|
||||
".msg .role": "2aa3fc47cc98be04",
|
||||
".msg-action-btn[data-action=\"shorten\"]": "39875219ab81b49e",
|
||||
".msg-actions": "015e01ce5213f013",
|
||||
".msg-ai": "ca278b97f3b29560",
|
||||
".msg-user": "3964d2a2b7460aa6",
|
||||
".note-card": "1f346ea173c883c6",
|
||||
".note-card-colors": "329743d4521bb459",
|
||||
".note-card-copy-corner": "7759db4e507df9aa",
|
||||
".note-card-corner-copy": "0874439a31308004",
|
||||
".note-card-corner-copy svg": "c514aac06948c53e",
|
||||
".note-card-done": "c1c08dfe20a2319f",
|
||||
".note-card-edit-corner": "28358acd0d495d56",
|
||||
".note-card-image": "2d43d9998c0054e2",
|
||||
".note-check-text": "bf79d1927ab1354c",
|
||||
".note-color-blue": "a639c83faed486a5",
|
||||
".note-color-green": "2ec9b93d7e537274",
|
||||
".note-color-orange": "d6b28667f64299ed",
|
||||
".note-color-purple": "09413154ae0539b7",
|
||||
".note-color-red": "3578124008036b4c",
|
||||
".note-color-yellow": "0375be5e8a17c052",
|
||||
".note-form-actions-group": "01325dedac4f2135",
|
||||
".note-form-header": "981e2a2bef7d2401",
|
||||
".note-form-meta .note-form-label": "4523cb29977907ee",
|
||||
".note-form-remind-btn": "3d8e742995aab275",
|
||||
".note-form-type-pill": "c2e1cdc092ce35d2",
|
||||
".note-fullscreen-overlay .note-cl-add-row .note-cl-add": "6c5a9e646b44c5ad",
|
||||
".notes-header-btn-label": "6756fa87daa244e9",
|
||||
".notes-header-text-btn": "0bc6208299bf6f01",
|
||||
".notes-pane-title": "66bcc3f992df720f",
|
||||
".notes-pane.notes-view-grid .note-card": "a327d50c6b0bf852",
|
||||
".notes-pane.notes-view-grid .note-form": "f393ab7b10692ea2",
|
||||
".notes-pane.notes-view-grid .notes-labels-bar": "71aff777c51126c6",
|
||||
".notes-pane.notes-view-grid .notes-pane-body": "65a4c9d6644c1c37",
|
||||
".notes-pane.notes-view-grid .notes-quick-add": "87b5d77fec70f8bd",
|
||||
".notes-quick-add": "b56d9585019420b5",
|
||||
".notes-quick-icon": "83f7f73487cc23da",
|
||||
".notes-quick-input": "8760b2b8588274fd",
|
||||
".notes-quick-type-pill": "55c9f6527ebc8b3d",
|
||||
".notes-quick-type-pill svg": "a73e69725b9d97b6",
|
||||
".notes-quick-type-seg": "de7a4f92c908db4e",
|
||||
".pdf-export-overlay .modal-content": "3b5f35912509daf2",
|
||||
".preset-btn": "11527aa662becc84",
|
||||
".preset-btn.active": "2fc1631701e917c2",
|
||||
".preset-range": "6c05bf7f8a880758",
|
||||
".private-browser-preview-frame img": "b1866aa1b67e4626",
|
||||
".reasoning-effort-prefix": "51c4c138641d61e2",
|
||||
".recipient-chip": "f88380e8f4c09cc5",
|
||||
".recipient-chips": "83fad85374d3c475",
|
||||
".recording-content": "d5e0c053ceaf4f29",
|
||||
".recording-error": "5a2f9e8095e1b45e",
|
||||
".recording-icon": "cb60d94934ee919a",
|
||||
".recording-text": "58e99b27946bafd6",
|
||||
".red-text": "a93f1ed45d8646c0",
|
||||
".research-badge": "fcf661967461fc70",
|
||||
".research-cat": "a523b00cdffd4546",
|
||||
".research-category-row": "09701efa1598810c",
|
||||
".research-job-action[data-action=\"delete\"]": "d1bf97c182a9a746",
|
||||
".research-job-card": "c71a3e1a36a8c265",
|
||||
".research-new-job h2": "5e67de78656d65dc",
|
||||
".research-sb-dot": "3fd3b71cb6c521ce",
|
||||
".research-start-btn": "89df2d121b24f52b",
|
||||
".research-synapse .rs-meta": "59320709dd4454db",
|
||||
".research-synapse .rs-meta .rs-status": "2e1a6f4a3fda05dc",
|
||||
".scroll-nav-btn": "436360e8d744c897",
|
||||
".search-result-snippet": "fac8292c8f391904",
|
||||
".section": "37fc733d59519a08",
|
||||
".section-header-btn": "5d949e304c239db4",
|
||||
".section-header-flex": "bfcb656a86ca5a6a",
|
||||
".send-btn": "b29fc333cc66ed9d",
|
||||
".send-btn.anim-launch svg": "25d7e30ad49d3374",
|
||||
".session-bulk-bar": "a2833fd7ce35e639",
|
||||
".session-bulk-btn": "827a6bb9524080c9",
|
||||
".session-loading-state": "95adb7c1b1e67832",
|
||||
".session-menu-btn": "43014a6ecb68472a",
|
||||
".session-skeleton-bubble": "23878c71363d5db7",
|
||||
".session-skeleton-bubble.is-user": "b0e090855a4865be",
|
||||
".session-skeleton-line": "05960e43aaa5fa1c",
|
||||
".settings-layout": "9e37e5723ec05246",
|
||||
".settings-nav-item": "1db63b881ad2eba4",
|
||||
".settings-panels": "728f16c852c993b6",
|
||||
".settings-row": "1a84bec9dc21c226",
|
||||
".settings-select": "13a40c3922ce8015",
|
||||
".settings-sidebar": "2b39df3565e83790",
|
||||
".settings-sidebar-content": "88544f44fdb2ca73",
|
||||
".settings-sidebar-divider": "cdadb5f574b44236",
|
||||
".settings-sidebar.settings-sidebar-collapsed": "d9d8f25df95d9ad8",
|
||||
".settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content": "608e51170864586a",
|
||||
".settings-system-logs-tab": "d0d90a13ff327ace",
|
||||
".sidebar": "77ec42a646a8db66",
|
||||
".sidebar .section": "37fc733d59519a08",
|
||||
".sidebar-brand-title": "2b20f75768b50346",
|
||||
".sidebar-header": "0d58816ca65b7216",
|
||||
".sidebar-inner": "ad5e28fedbfeb5db",
|
||||
".sidebar-notif-dot": "2673e215d0cd84a6",
|
||||
".sidebar-user-bar": "b77372dbdcb069ef",
|
||||
".sidebar.hidden": "0df301b638e2f93f",
|
||||
".sidebar.right-side": "2b6bcc7d59f7b6bd",
|
||||
".skill-card > .skill-card-header": "e2408e762818d2fb",
|
||||
".skill-card-tags": "a327c85e053e6d3f",
|
||||
".skill-card-textcol": "f1059ed37c120b60",
|
||||
".skill-card.doclib-card-expanded": "b7f5d0edd75b1422",
|
||||
".skill-kebab-btn": "5cd557d02656d079",
|
||||
".spinner": "e403b24f35c58e53",
|
||||
".stop-recording-btn": "d5489a601cfabf69",
|
||||
".styled-confirm-box": "90a27b200f7179c1",
|
||||
".styled-confirm-box .modal-body p": "39733c5420c1521b",
|
||||
".styled-confirm-box .modal-footer": "0d1b29f6f7487848",
|
||||
".styled-prompt-box": "1f3925c0236d652e",
|
||||
".styled-prompt-input": "a4e24959b76553cf",
|
||||
".task-card .memory-item-actions": "33e67907b02e543c",
|
||||
".task-log-name": "f29203861c94fb42",
|
||||
".theme-grid": "0f53a4921d5a478c",
|
||||
".thumb button": "323943969cfbb194",
|
||||
".thumb-collapsed": "c967b682b6c604e4",
|
||||
".thumb.thumb-image .thumb-img": "4a4da275eee6cc70",
|
||||
".toast": "c60c6a60d4982005",
|
||||
".toast-action-hint": "13c8621842a92195",
|
||||
"a": "bb0f88ce29ae7637",
|
||||
"body": "a402cd1690151695",
|
||||
"body.notes-drag-mode .note-card": "38ee1aee83dccef6",
|
||||
"body.notes-view .chat-container": "8ba89017b94cf407",
|
||||
"button.cal-add-btn.cal-add-btn-text": "7e6817fca9a759dc",
|
||||
"button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm": "d5d12fbae3d58592",
|
||||
"button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm .cal-add-label": "9b674e66a8ae6380",
|
||||
"button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm .cal-add-plus": "69dca313be48c31e",
|
||||
"button.cal-nav.cal-toolbar-arrow": "7c252b6e2024fdb4",
|
||||
"code": "0b8a26334c2cdc21",
|
||||
"details": "1979b4f98bd52f98",
|
||||
"details a": "5a744551046b2383",
|
||||
"h2": "8a4d59fbdab8c1ad",
|
||||
"pre": "a5f2d86c696dc650",
|
||||
"pre .copy-code": "a9489a5797fcf296",
|
||||
"pre .edit-code": "a9489a5797fcf296",
|
||||
"pre .run-code": "a9489a5797fcf296",
|
||||
"table": "fc1e26a096450665",
|
||||
"td": "b782f3027093fc82",
|
||||
"th": "fd81ff4190b2f023"
|
||||
},
|
||||
"login": {
|
||||
"body": "64975bdadce9f6e5",
|
||||
"card": "5139f16a929fa893",
|
||||
"error": "3eb05963b0d792b6",
|
||||
"logo": "15751ee911f586da",
|
||||
"password": "42f0c35485f59dea",
|
||||
"pw-toggle": "9e5f0475eb7b6c57",
|
||||
"remember-dot": "62b8fa66ceaf590e",
|
||||
"remember-toggle": "87bed84dfe9061e8",
|
||||
"root": "07533d1e7958a57a",
|
||||
"setup-note": "028127c742bf5f87",
|
||||
"submit": "5e371dbe68196bfd",
|
||||
"toggle-link": "289ae7be0ef6e564",
|
||||
"username": "a8d417a9a08b204c",
|
||||
"version-label": "cc443cc7790ff3b4"
|
||||
}
|
||||
},
|
||||
"variants": {
|
||||
"app-shell": {
|
||||
"desktop-dark-comfortable": "21afac735b96f31e",
|
||||
"desktop-dark-compact": "c1690c3291f79cef",
|
||||
"desktop-dark-spacious": "7dbd8d97ad28a540",
|
||||
"desktop-light-comfortable": "9e88a1c7a8dfc1d2",
|
||||
"desktop-light-compact": "4c882ebf427c8ff7",
|
||||
"desktop-light-spacious": "f74cff517a61dad9",
|
||||
"laptop-dark-comfortable": "95c85f14bfde85e3",
|
||||
"laptop-dark-compact": "d7c8522e5f70cc07",
|
||||
"laptop-dark-spacious": "b7245ea652815c27",
|
||||
"laptop-light-comfortable": "b34f4282a5ef51b7",
|
||||
"laptop-light-compact": "38887a9d17175a65",
|
||||
"laptop-light-spacious": "8f3ad89ae046dfe9",
|
||||
"phone-dark-comfortable": "1a8c2a51ed18c0cc",
|
||||
"phone-dark-compact": "2a11e3b08826edef",
|
||||
"phone-dark-spacious": "1a8c2a51ed18c0cc",
|
||||
"phone-light-comfortable": "5b0af830cb78676b",
|
||||
"phone-light-compact": "3ffe9d863f213a49",
|
||||
"phone-light-spacious": "5b0af830cb78676b",
|
||||
"tablet-dark-comfortable": "f2ddb9a92ac7c0f6",
|
||||
"tablet-dark-compact": "8ef6c7958cabb3ad",
|
||||
"tablet-dark-spacious": "f2ddb9a92ac7c0f6",
|
||||
"tablet-light-comfortable": "2c846ae8e56f881b",
|
||||
"tablet-light-compact": "f4bfea7dbcd7df5f",
|
||||
"tablet-light-spacious": "2c846ae8e56f881b"
|
||||
},
|
||||
"bench": {
|
||||
"desktop-dark-comfortable": "2471a82979dfd8d8",
|
||||
"desktop-dark-compact": "b2807c0c54345c3e",
|
||||
"desktop-dark-spacious": "41a6d588192a02ed",
|
||||
"desktop-light-comfortable": "631778e05aca46ef",
|
||||
"desktop-light-compact": "340c1bbce11d0b32",
|
||||
"desktop-light-spacious": "e21051317e9d346d",
|
||||
"laptop-dark-comfortable": "4c931615f7151fc3",
|
||||
"laptop-dark-compact": "181a9bb9f0add9a0",
|
||||
"laptop-dark-spacious": "ad24cee14e456d04",
|
||||
"laptop-light-comfortable": "d635b3b801ff803d",
|
||||
"laptop-light-compact": "dbe6446fad9cc2f0",
|
||||
"laptop-light-spacious": "c81a4f935d0270a3",
|
||||
"phone-dark-comfortable": "ae0aa5695982a188",
|
||||
"phone-dark-compact": "8ca1949b9817e3c4",
|
||||
"phone-dark-spacious": "912fe8e2d490162f",
|
||||
"phone-light-comfortable": "ccd790243858a150",
|
||||
"phone-light-compact": "2b008b092bec6fb1",
|
||||
"phone-light-spacious": "c2465ac50f71a035",
|
||||
"tablet-dark-comfortable": "1d96addb759bc3e3",
|
||||
"tablet-dark-compact": "53294ba127a61959",
|
||||
"tablet-dark-spacious": "c26663bc163aa446",
|
||||
"tablet-light-comfortable": "b66904a1f69417b7",
|
||||
"tablet-light-compact": "9f0836a37f087c2e",
|
||||
"tablet-light-spacious": "645c09fa0e1ddb59"
|
||||
},
|
||||
"login": {
|
||||
"desktop-dark-comfortable": "d3d0512f5223397e",
|
||||
"desktop-dark-compact": "d3d0512f5223397e",
|
||||
"desktop-dark-spacious": "d3d0512f5223397e",
|
||||
"desktop-light-comfortable": "d3d0512f5223397e",
|
||||
"desktop-light-compact": "d3d0512f5223397e",
|
||||
"desktop-light-spacious": "d3d0512f5223397e",
|
||||
"laptop-dark-comfortable": "d614fc150b017e6e",
|
||||
"laptop-dark-compact": "d614fc150b017e6e",
|
||||
"laptop-dark-spacious": "d614fc150b017e6e",
|
||||
"laptop-light-comfortable": "d614fc150b017e6e",
|
||||
"laptop-light-compact": "d614fc150b017e6e",
|
||||
"laptop-light-spacious": "d614fc150b017e6e",
|
||||
"phone-dark-comfortable": "c7f59e5d8c979f02",
|
||||
"phone-dark-compact": "c7f59e5d8c979f02",
|
||||
"phone-dark-spacious": "c7f59e5d8c979f02",
|
||||
"phone-light-comfortable": "c7f59e5d8c979f02",
|
||||
"phone-light-compact": "c7f59e5d8c979f02",
|
||||
"phone-light-spacious": "c7f59e5d8c979f02",
|
||||
"tablet-dark-comfortable": "ee0918e00caa6875",
|
||||
"tablet-dark-compact": "ee0918e00caa6875",
|
||||
"tablet-dark-spacious": "ee0918e00caa6875",
|
||||
"tablet-light-comfortable": "ee0918e00caa6875",
|
||||
"tablet-light-compact": "ee0918e00caa6875",
|
||||
"tablet-light-spacious": "ee0918e00caa6875"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>CSS snapshot bench</title>
|
||||
<!-- Replaced at capture time by whatever <link rel="stylesheet"> tags
|
||||
static/index.html ships, in shell order, so the bench keeps measuring
|
||||
the real stylesheet set after style.css is split. This fallback is only
|
||||
for opening the page by hand; it is deliberately unversioned so the
|
||||
cache-bust string has one home (tests/test_static_stylesheet_manifest.py
|
||||
pins the shipped ones). -->
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Intentionally empty. tests/css_snapshot/capture.mjs mounts one subtree
|
||||
per bench selector from tests/css_snapshot/inventory.json, measures the
|
||||
leaf, and removes it again - so adding coverage is a one-line inventory
|
||||
change and this file never drifts from the selector list. -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,266 @@
|
||||
// Computed-style capture for the CSS snapshot harness.
|
||||
//
|
||||
// Reads a capture job as JSON on stdin, drives headless Chromium over the
|
||||
// loopback static server, and writes the raw computed values as JSON on
|
||||
// stdout. Hashing, comparison and baseline storage live on the Python side
|
||||
// (scripts/css_snapshot.py) so there is exactly one canonicalisation.
|
||||
//
|
||||
// Determinism rules that matter here, because the digest is only useful if an
|
||||
// unchanged stylesheet always produces the same bytes:
|
||||
// - every <script> is stripped from the document, so the DOM stays exactly
|
||||
// what the server sends and no app module can mutate classes underneath us;
|
||||
// - the theme/density classes are injected into the <html> tag *before* the
|
||||
// first paint instead of toggled afterwards, so no CSS transition is ever
|
||||
// mid-interpolation while getComputedStyle runs;
|
||||
// - images, fonts and media are aborted: they cost time and change nothing
|
||||
// in the pinned property set;
|
||||
// - scrollbars are hidden, so a platform's scrollbar width cannot change the
|
||||
// width that percentage and auto values resolve against.
|
||||
|
||||
import process from 'node:process';
|
||||
|
||||
function readStdin() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let raw = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => { raw += chunk; });
|
||||
process.stdin.on('end', () => resolve(raw));
|
||||
process.stdin.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Swap the first two blocks that declare `selector` at the same nesting level.
|
||||
// Used by the harness self-test: if reordering two conflicting declarations of
|
||||
// the same selector does not move the digest, the harness is not watching
|
||||
// anything worth watching.
|
||||
function swapRuleOccurrences(css, selector) {
|
||||
const blocks = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < css.length; i += 1) {
|
||||
const ch = css[i];
|
||||
if (ch === '{') {
|
||||
if (depth === 0) {
|
||||
const sel = css.slice(start, i).trim().replace(/\s+/g, ' ');
|
||||
blocks.push({ selector: sel, start, bodyStart: i });
|
||||
}
|
||||
depth += 1;
|
||||
} else if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
blocks[blocks.length - 1].end = i + 1;
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const matches = blocks.filter(b => b.selector === selector && b.end !== undefined);
|
||||
if (matches.length < 2) {
|
||||
throw new Error(`swap-rule: need two top-level blocks for "${selector}", found ${matches.length}`);
|
||||
}
|
||||
const [a, b] = matches;
|
||||
const textA = css.slice(a.start, a.end);
|
||||
const textB = css.slice(b.start, b.end);
|
||||
return css.slice(0, a.start) + textB + css.slice(a.end, b.start) + textA + css.slice(b.end);
|
||||
}
|
||||
|
||||
// The whole measurement runs inside one page function: Playwright serialises
|
||||
// the function source, so anything it calls has to be declared inside it.
|
||||
//
|
||||
// buildBenchNode is a minimal selector-to-DOM builder for the bench page. It
|
||||
// supports descendant and child combinators over compound selectors made of a
|
||||
// tag, an id, classes and [attr=value] pairs - which is what the
|
||||
// high-redeclaration selectors in style.css are made of. Anything else is
|
||||
// reported as missing rather than silently benched as the wrong element.
|
||||
function pageMeasure(job) {
|
||||
function buildBenchNode(selector) {
|
||||
const parts = selector.split(/\s*>\s*|\s+/).filter(Boolean);
|
||||
let root = null;
|
||||
let parent = null;
|
||||
let leaf = null;
|
||||
for (const part of parts) {
|
||||
const m = part.match(/^([a-zA-Z][\w-]*)?((?:[#.][\w-]+|\[[^\]]+\])*)$/);
|
||||
if (!m || (!m[1] && !m[2])) throw new Error('unsupported bench selector: ' + selector);
|
||||
const el = document.createElement(m[1] || 'div');
|
||||
const tokens = (m[2] || '').match(/[#.][\w-]+|\[[^\]]+\]/g) || [];
|
||||
for (const token of tokens) {
|
||||
if (token[0] === '#') el.id = token.slice(1);
|
||||
else if (token[0] === '.') el.classList.add(token.slice(1));
|
||||
else {
|
||||
const attr = token.slice(1, -1);
|
||||
const eq = attr.indexOf('=');
|
||||
if (eq === -1) el.setAttribute(attr, '');
|
||||
else el.setAttribute(attr.slice(0, eq), attr.slice(eq + 1).replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
}
|
||||
if (parent) parent.appendChild(el); else root = el;
|
||||
parent = el;
|
||||
leaf = el;
|
||||
}
|
||||
if (!leaf) throw new Error('empty bench selector');
|
||||
return { root, leaf };
|
||||
}
|
||||
|
||||
function readStyle(el, pseudo, properties, wantCustom) {
|
||||
const cs = getComputedStyle(el, pseudo || undefined);
|
||||
const values = {};
|
||||
for (const prop of properties) values[prop] = cs.getPropertyValue(prop);
|
||||
if (wantCustom) {
|
||||
const names = [];
|
||||
for (let i = 0; i < cs.length; i += 1) {
|
||||
const name = cs.item(i);
|
||||
if (name.startsWith('--')) names.push(name);
|
||||
}
|
||||
names.sort();
|
||||
for (const name of names) values[name] = cs.getPropertyValue(name).trim();
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
// Modals and menus ship hidden in the served markup. Revealing one element
|
||||
// at a time - and putting the class back straight after - keeps each
|
||||
// measurement independent of the others.
|
||||
function reveal(el) {
|
||||
const undo = [];
|
||||
let node = el;
|
||||
while (node && node !== document.documentElement) {
|
||||
if (node.classList && node.classList.contains('hidden')) {
|
||||
const target = node;
|
||||
target.classList.remove('hidden');
|
||||
undo.push(() => target.classList.add('hidden'));
|
||||
}
|
||||
if (node.hasAttribute && node.hasAttribute('hidden')) {
|
||||
const target = node;
|
||||
target.removeAttribute('hidden');
|
||||
undo.push(() => target.setAttribute('hidden', ''));
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return () => { for (const fn of undo.reverse()) fn(); };
|
||||
}
|
||||
|
||||
const measured = {};
|
||||
const missing = [];
|
||||
|
||||
for (const entry of (job.elements || [])) {
|
||||
const el = document.querySelector(entry.selector);
|
||||
if (!el) { missing.push(entry.key); continue; }
|
||||
const restore = entry.unhide ? reveal(el) : null;
|
||||
// Reading a layout property forces the style and layout pass before the
|
||||
// computed values are read back.
|
||||
void document.body.offsetHeight;
|
||||
measured[entry.key] = readStyle(el, entry.pseudo, job.properties, !!entry.custom);
|
||||
if (restore) restore();
|
||||
}
|
||||
|
||||
for (const selector of (job.bench || [])) {
|
||||
let built;
|
||||
try {
|
||||
built = buildBenchNode(selector);
|
||||
} catch (err) {
|
||||
missing.push(selector);
|
||||
continue;
|
||||
}
|
||||
document.body.appendChild(built.root);
|
||||
void document.body.offsetHeight;
|
||||
measured[selector] = readStyle(built.leaf, null, job.properties, false);
|
||||
built.root.remove();
|
||||
}
|
||||
|
||||
return { measured, missing };
|
||||
}
|
||||
|
||||
// The bench page must load whatever the app shell loads. Once style.css is
|
||||
// split, the shell will pull in several ordered stylesheets and a bench that
|
||||
// kept linking style.css alone would measure a stylesheet the app no longer
|
||||
// serves on its own - and report the extraction as clean when it was not.
|
||||
function stylesheetLinks(html) {
|
||||
const links = html.match(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi) || [];
|
||||
return links.join('\n ');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const job = JSON.parse(await readStdin());
|
||||
const { chromium } = await import('playwright');
|
||||
const browser = await chromium.launch({ headless: true, args: ['--hide-scrollbars'] });
|
||||
const snapshot = {};
|
||||
const missing = {};
|
||||
|
||||
try {
|
||||
for (const page of job.pages) {
|
||||
snapshot[page.name] = {};
|
||||
let shippedStylesheets = null;
|
||||
if (page.stylesheetsFrom) {
|
||||
const source = await fetch(job.origin + page.stylesheetsFrom);
|
||||
if (!source.ok) throw new Error(`${page.stylesheetsFrom} returned ${source.status}`);
|
||||
shippedStylesheets = stylesheetLinks(await source.text());
|
||||
if (!shippedStylesheets) throw new Error(`no stylesheet links found in ${page.stylesheetsFrom}`);
|
||||
}
|
||||
for (const variant of job.variants) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: variant.width, height: variant.height },
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: variant.colorScheme || 'dark',
|
||||
reducedMotion: 'no-preference',
|
||||
forcedColors: 'none',
|
||||
hasTouch: !!variant.touch,
|
||||
isMobile: false,
|
||||
javaScriptEnabled: true,
|
||||
});
|
||||
const tab = await context.newPage();
|
||||
|
||||
// Registered first so the document/stylesheet handlers below win:
|
||||
// Playwright matches the most recently registered route.
|
||||
await tab.route('**/*', route => {
|
||||
const type = route.request().resourceType();
|
||||
if (type === 'image' || type === 'media' || type === 'font') return route.abort();
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
if (job.swapRule) {
|
||||
await tab.route('**/static/style.css*', async route => {
|
||||
const response = await route.fetch();
|
||||
const body = swapRuleOccurrences(await response.text(), job.swapRule);
|
||||
await route.fulfill({ response, body, headers: { ...response.headers(), 'content-type': 'text/css; charset=utf-8' } });
|
||||
});
|
||||
}
|
||||
|
||||
const documentPath = page.url.split('?')[0];
|
||||
await tab.route(`**${documentPath}`, async route => {
|
||||
const response = await route.fetch();
|
||||
let html = await response.text();
|
||||
html = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||
if (shippedStylesheets !== null) {
|
||||
html = html.replace(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi, '');
|
||||
html = html.replace(/<\/head>/i, ` ${shippedStylesheets}\n</head>`);
|
||||
}
|
||||
const classes = [variant.theme === 'light' ? 'light' : '', variant.density && variant.density !== 'comfortable' ? `density-${variant.density}` : '']
|
||||
.filter(Boolean).join(' ');
|
||||
html = html.replace(/<html\b([^>]*)>/i, (match, attrs) => `<html${attrs.replace(/\sclass="[^"]*"/i, '')} class="${classes}">`);
|
||||
await route.fulfill({ response, body: html, headers: { ...response.headers(), 'content-type': 'text/html; charset=utf-8' } });
|
||||
});
|
||||
|
||||
const response = await tab.goto(job.origin + page.url, { waitUntil: 'load' });
|
||||
if (!response || !response.ok()) {
|
||||
throw new Error(`${page.url} returned ${response ? response.status() : 'no response'}`);
|
||||
}
|
||||
const result = await tab.evaluate(pageMeasure, {
|
||||
elements: page.elements || [],
|
||||
bench: page.bench || [],
|
||||
properties: job.properties,
|
||||
});
|
||||
snapshot[page.name][variant.name] = result.measured;
|
||||
if (result.missing.length) missing[`${page.name}/${variant.name}`] = result.missing;
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({ snapshot, missing }));
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
process.stderr.write(String(err && err.stack ? err.stack : err) + '\n');
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
"""Read the app's CSS the way the browser does.
|
||||
|
||||
``static/style.css`` no longer holds every rule: panel styles live in separate
|
||||
files that ``static/index.html`` loads eagerly, in a fixed order, right after
|
||||
it. The cascade is the concatenation of those files in that order.
|
||||
|
||||
A test that asserts on a rule must therefore look at all of them. Reading
|
||||
``static/style.css`` alone ties the test to whichever file a rule happens to
|
||||
sit in today, so it goes red the next time a rule moves without anything about
|
||||
the rendered page having changed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_STATIC = Path(__file__).resolve().parents[2] / "static"
|
||||
_INDEX = _STATIC / "index.html"
|
||||
|
||||
# Only same-origin app stylesheets. Vendored <link>s under static/lib are not
|
||||
# part of the cascade these tests reason about.
|
||||
_LINK = re.compile(
|
||||
r"""<link\b[^>]*\brel\s*=\s*["']stylesheet["'][^>]*\bhref\s*=\s*["']/static/([^"'?]+)([^"']*)["']""",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _entries() -> list[tuple[Path, str]]:
|
||||
html = _INDEX.read_text(encoding="utf-8")
|
||||
out = []
|
||||
for m in _LINK.finditer(html):
|
||||
rel, query = m.group(1), m.group(2)
|
||||
if rel.startswith("lib/"):
|
||||
continue
|
||||
out.append((_STATIC / rel, "/static/" + rel + query))
|
||||
if not out:
|
||||
raise AssertionError(f"no app stylesheet <link> tags found in {_INDEX}")
|
||||
missing = [p for p, _u in out if not p.is_file()]
|
||||
if missing:
|
||||
raise AssertionError(f"index.html links stylesheets that do not exist: {missing}")
|
||||
return out
|
||||
|
||||
|
||||
def stylesheet_paths() -> list[Path]:
|
||||
"""Every app stylesheet on disk, in the order index.html loads it."""
|
||||
return [p for p, _u in _entries()]
|
||||
|
||||
|
||||
def stylesheet_urls() -> list[str]:
|
||||
"""The same stylesheets as request URLs, query string included."""
|
||||
return [u for _p, u in _entries()]
|
||||
|
||||
|
||||
def stylesheet_link_tags() -> str:
|
||||
"""The <link> tags to drop into a synthetic page so it gets the whole
|
||||
cascade, not just style.css."""
|
||||
return "".join(f'<link rel="stylesheet" href="{u}">' for u in stylesheet_urls())
|
||||
|
||||
|
||||
def app_css() -> str:
|
||||
"""The whole cascade as one string, in load order."""
|
||||
return "\n".join(p.read_text(encoding="utf-8") for p in stylesheet_paths())
|
||||
@@ -1,6 +1,8 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -80,7 +82,7 @@ def test_email_filter_menu_has_context_title() -> None:
|
||||
|
||||
def test_email_setting_toggles_render_neutral_disabled_state() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
assert 'email-settings-auto-reply-section' in source
|
||||
assert 'email-settings-display-enabled-state' in source
|
||||
assert 'stateLabel = section?.querySelector' in source
|
||||
@@ -100,7 +102,7 @@ def test_email_date_headers_mark_unexpected_timeline_gaps() -> None:
|
||||
assert "email-date-gap-break" in source
|
||||
assert "gapDays > 90 && gapDays > timelineGapThreshold" in source
|
||||
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
assert ".date-section-header.email-date-gap-break" in style
|
||||
|
||||
|
||||
@@ -122,7 +124,7 @@ def test_email_filters_and_card_favorite_toggle_are_wired() -> None:
|
||||
assert "const typedFilter = _exactTypedFilterSuggestion(v);" in source
|
||||
assert "_acceptSuggestion(typedFilter);" in source
|
||||
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
favorite_start = style.index(".email-card-favorite {")
|
||||
favorite_end = style.index("}", favorite_start) + 1
|
||||
assert "top: -3px;" in style[favorite_start:favorite_end]
|
||||
@@ -154,7 +156,7 @@ def test_email_auto_reply_syncs_one_calendar_event_per_account() -> None:
|
||||
|
||||
def test_email_settings_show_away_account_and_compact_display_controls() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
assert 'email-account-away-label">(AWAY)</span>' in source
|
||||
assert 'id="email-lib-auto-reply-badge"' in source
|
||||
assert ">Show Email Tags</span>" in source
|
||||
@@ -227,7 +229,7 @@ def test_email_clean_always_forces_a_fresh_unsubscribe_scan() -> None:
|
||||
|
||||
def test_unsubscribe_duplicate_badge_is_lowered() -> None:
|
||||
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
stylesheet = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
stylesheet = app_css()
|
||||
assert "email-unsub-duplicate-badge" in frontend
|
||||
start = stylesheet.index(".email-unsub-duplicate-badge {")
|
||||
assert "top: 2px;" in stylesheet[start:stylesheet.index("}", start) + 1]
|
||||
@@ -235,7 +237,7 @@ def test_unsubscribe_duplicate_badge_is_lowered() -> None:
|
||||
|
||||
def test_unsubscribe_scan_status_sits_before_clean_action() -> None:
|
||||
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
stylesheet = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
stylesheet = app_css()
|
||||
start = frontend.index("function _emailCleanupSettingsHtml")
|
||||
end = frontend.index("function _emailDisplaySettingsHtml", start)
|
||||
cleanup = frontend[start:end]
|
||||
@@ -291,7 +293,7 @@ def test_browser_agent_unsubscribe_cleans_sender_after_positive_confirmation() -
|
||||
|
||||
|
||||
def test_auto_unsubscribe_all_is_visibly_taller_than_toolbar_buttons() -> None:
|
||||
source = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
source = app_css()
|
||||
start = source.index(".email-unsub-auto-safe-btn {")
|
||||
assert "height: 29px;" in source[start:source.index("}", start) + 1]
|
||||
|
||||
@@ -330,7 +332,7 @@ def test_unsubscribe_review_marks_handled_cards_and_offers_scan_further() -> Non
|
||||
|
||||
def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None:
|
||||
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
||||
styles = (ROOT / "static" / "style.css").read_text()
|
||||
styles = app_css()
|
||||
assert "email-unsub-ignore-btn" in source
|
||||
assert "_rememberUnsubscribeIgnored(c)" in source
|
||||
assert "Ignore this unsubscribe candidate" in source
|
||||
@@ -339,7 +341,7 @@ def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None
|
||||
|
||||
def test_email_settings_sections_use_static_headers() -> None:
|
||||
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
||||
styles = (ROOT / "static" / "style.css").read_text()
|
||||
styles = app_css()
|
||||
assert 'class="email-unsub-accent-icon"' in source
|
||||
assert 'M12 0L14.59 8.41' in source
|
||||
assert 'Scanning ${_esc(scanFolderLabel)} headers…' in source
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -25,7 +27,7 @@ def test_successful_calendar_tool_output_is_suppressed_in_live_and_saved_rendere
|
||||
|
||||
def test_calendar_chat_event_links_fetch_uid_and_show_title_time():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
routes_src = (ROOT / "routes/calendar_routes.py").read_text()
|
||||
app_src = (ROOT / "static/app.js").read_text()
|
||||
renderer_src = (ROOT / "static/js/chatRenderer.js").read_text()
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _occurrences(text, needle):
|
||||
"""Every index of needle, so an assertion does not depend on which copy of
|
||||
a selector the cascade happens to put first."""
|
||||
out, i = [], text.find(needle)
|
||||
while i != -1:
|
||||
out.append(i)
|
||||
i = text.find(needle, i + 1)
|
||||
return out
|
||||
|
||||
|
||||
def test_calendar_week_view_has_overlap_lanes_and_live_ruler_hooks():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "function _wkLayoutTimedEvents" in calendar_src
|
||||
assert "--lane:${lane};--lane-count:${laneCount}" in calendar_src
|
||||
@@ -20,7 +32,7 @@ def test_calendar_week_view_has_overlap_lanes_and_live_ruler_hooks():
|
||||
|
||||
def test_crowded_week_events_expand_left_to_reveal_full_title_on_hover():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "const crowdedClass = laneCount > 1 ? ' cal-wk-block-crowded' : '';" in calendar_src
|
||||
assert "--lane-right:${laneRight}%" in calendar_src
|
||||
@@ -61,7 +73,7 @@ def test_crowded_week_events_expand_left_to_reveal_full_title_on_hover():
|
||||
|
||||
def test_mobile_truncated_week_event_expands_before_opening_editor():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
click_idx = calendar_src.index("body.querySelectorAll('.cal-wk-block, .cal-wk-allday-event')")
|
||||
edit_idx = calendar_src.index("if (ev) _showEventForm(ev);", click_idx)
|
||||
@@ -78,7 +90,7 @@ def test_mobile_truncated_week_event_expands_before_opening_editor():
|
||||
|
||||
def test_mobile_empty_week_slot_selects_before_opening_new_event():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "let _selectedWeekSlot = null;" in calendar_src
|
||||
assert "function _paintSelectedWeekSlot(body)" in calendar_src
|
||||
@@ -100,7 +112,7 @@ def test_mobile_empty_week_slot_selects_before_opening_new_event():
|
||||
|
||||
def test_week_view_hints_when_current_time_is_below_the_visible_field():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "function _updateWeekNowBelowHint(wrap)" in calendar_src
|
||||
assert "nowRect.top > wrapRect.bottom - 2" in calendar_src
|
||||
@@ -115,7 +127,7 @@ def test_week_view_hints_when_current_time_is_below_the_visible_field():
|
||||
|
||||
def test_mobile_week_scroll_has_resisted_edge_pull_feedback():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "pullStartedAtTop = _wrap.scrollTop <= 1;" in calendar_src
|
||||
assert "pullStartedAtBottom = _wrap.scrollTop >= maxScroll - 1;" in calendar_src
|
||||
@@ -143,7 +155,7 @@ def test_calendar_view_change_recovers_a_fully_open_day_drawer():
|
||||
|
||||
def test_calendar_month_and_agenda_have_visual_depth_hooks():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "cal-weekend" in calendar_src
|
||||
assert "cal-empty-day" in calendar_src
|
||||
@@ -156,7 +168,7 @@ def test_calendar_month_and_agenda_have_visual_depth_hooks():
|
||||
|
||||
def test_calendar_event_cards_show_compact_source_badges():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "function _eventSourceHtml" in calendar_src
|
||||
assert "${_eventSourceHtml(ev)}" in calendar_src
|
||||
@@ -166,7 +178,7 @@ def test_calendar_event_cards_show_compact_source_badges():
|
||||
|
||||
def test_calendar_toolbar_previous_next_arrows_are_mobile_only():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert 'class="cal-nav cal-toolbar-arrow" id="cal-prev"' in calendar_src
|
||||
assert 'class="cal-nav cal-toolbar-arrow" id="cal-next"' in calendar_src
|
||||
@@ -181,7 +193,7 @@ def test_calendar_toolbar_previous_next_arrows_are_mobile_only():
|
||||
|
||||
def test_calendar_side_arrows_center_against_calendar_pane():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "function _alignSideNavToCalendar()" in calendar_src
|
||||
assert "paneRect.top - contentRect.top + (paneRect.height / 2)" in calendar_src
|
||||
@@ -191,7 +203,7 @@ def test_calendar_side_arrows_center_against_calendar_pane():
|
||||
|
||||
def test_calendar_splitter_double_click_snaps_to_nearest_pane_then_toggles():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "detailH >= calendarH ? 'events' : 'calendar'" in calendar_src
|
||||
assert "splitter.addEventListener('dblclick', () => {" in calendar_src
|
||||
@@ -302,7 +314,7 @@ def test_calendar_settings_are_close_only_and_color_the_name_field():
|
||||
assert name_idx < color_idx
|
||||
assert "nameInput.style.borderColor = colorInput.value" in calendar_src
|
||||
assert "nameInput.style.backgroundColor = colorInput.value" not in calendar_src
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
toggle_idx = style_src.index(".cal-week-start-toggle")
|
||||
button_idx = style_src.index(".cal-week-start-btn", toggle_idx)
|
||||
assert "padding: 0;" in style_src[toggle_idx:button_idx]
|
||||
@@ -312,7 +324,7 @@ def test_calendar_settings_are_close_only_and_color_the_name_field():
|
||||
|
||||
def test_calendar_settings_actions_sync_error_and_escape_behavior():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert calendar_src.count('class="cal-settings-actions"') >= 4
|
||||
assert "justify-content: flex-end;" in style_src[style_src.index(".cal-settings-actions"):]
|
||||
@@ -331,7 +343,7 @@ def test_calendar_settings_actions_sync_error_and_escape_behavior():
|
||||
|
||||
def test_calendar_hides_navigation_in_agenda_and_event_forms():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "_modal?.classList.toggle('cal-navigation-hidden', _view === 'agenda')" in calendar_src
|
||||
assert "_modal?.classList.add('cal-navigation-hidden')" in calendar_src
|
||||
@@ -353,7 +365,7 @@ def test_calendar_event_form_escape_cancels_before_modal_close():
|
||||
|
||||
|
||||
def test_calendar_from_and_to_month_day_use_theme_highlight():
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
month_idx = style_src.index('input[type="date"]::-webkit-datetime-edit-month-field')
|
||||
day_idx = style_src.index('input[type="date"]::-webkit-datetime-edit-day-field', month_idx)
|
||||
@@ -364,7 +376,7 @@ def test_calendar_from_and_to_month_day_use_theme_highlight():
|
||||
|
||||
def test_calendar_location_hint_uses_accent_map_pin():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert 'class="cal-loc-hint"' in calendar_src
|
||||
assert '<span>Location</span>' in calendar_src
|
||||
@@ -382,7 +394,7 @@ def test_calendar_tag_order_prioritizes_personal_work_travel_admin():
|
||||
|
||||
def test_long_week_events_keep_their_label_visible_while_scrolling():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert 'class="cal-wk-block-label"' in calendar_src
|
||||
label_idx = style_src.index(".cal-wk-block-label")
|
||||
@@ -393,8 +405,12 @@ def test_long_week_events_keep_their_label_visible_while_scrolling():
|
||||
block_idx = style_src.index(".cal-wk-block {")
|
||||
assert "overflow: clip;" in style_src[block_idx:label_idx]
|
||||
head_idx = style_src.index(".cal-wk-col-head {")
|
||||
allday_idx = style_src.rindex(
|
||||
".cal-wk-allday {", 0, style_src.index(".cal-wk-allday::-webkit-scrollbar")
|
||||
# .cal-wk-allday and its scrollbar rule may now sit in different
|
||||
# stylesheets, so anchoring on the scrollbar rule's offset no longer finds
|
||||
# the block. Take whichever .cal-wk-allday block carries the z-index.
|
||||
allday_idx = next(
|
||||
i for i in _occurrences(style_src, ".cal-wk-allday {")
|
||||
if "z-index: 39;" in style_src[i:i + 500]
|
||||
)
|
||||
now_idx = style_src.index(".cal-wk-now {")
|
||||
assert "z-index: 40;" in style_src[head_idx:head_idx + 500]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -302,7 +304,7 @@ def test_dependency_status_cache_is_not_fragmented_by_unused_model_hint():
|
||||
def test_model_lists_fade_after_complete_rows_are_painted():
|
||||
hwfit = _read("static/js/cookbook-hwfit.js")
|
||||
cookbook = _read("static/js/cookbook.js")
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
|
||||
assert "cookbook-model-list-fade" in hwfit
|
||||
assert "cookbook-model-list-fade" in cookbook
|
||||
@@ -314,7 +316,7 @@ def test_model_lists_fade_after_complete_rows_are_painted():
|
||||
|
||||
def test_dependency_panel_has_stable_categories_and_mobile_build_action():
|
||||
cookbook = _read("static/js/cookbook.js")
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
|
||||
assert "const _depCategoryOrder = ['System', 'Tools', 'LLM', 'Image'" in cookbook
|
||||
assert "const _orderedDepCategories = (byCat)" in cookbook
|
||||
@@ -337,7 +339,7 @@ def test_direct_download_auto_fold_requires_deliberate_boundary_gesture():
|
||||
|
||||
|
||||
def test_launch_command_box_uses_accent_focus_glow():
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
assert ".hwfit-serve-cmd-details[open] .hwfit-serve-cmd" in style
|
||||
assert "box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent" in style
|
||||
|
||||
@@ -345,7 +347,7 @@ def test_launch_command_box_uses_accent_focus_glow():
|
||||
def test_server_settings_color_picker_uses_dot_trigger():
|
||||
cookbook = _read("static/js/cookbook.js")
|
||||
hwfit = _read("static/js/cookbook-hwfit.js")
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
assert 'aria-label="Change server color (currently ${esc(selectedColorLabel)})"' in cookbook
|
||||
assert "btn.setAttribute('aria-label', `Change server color (currently ${label})`)" in hwfit
|
||||
assert ".cookbook-server-row .cookbook-srv-color-dot" in style
|
||||
@@ -357,7 +359,7 @@ def test_server_settings_color_picker_uses_dot_trigger():
|
||||
def test_default_server_matches_exclusive_model_directory_selector():
|
||||
cookbook = _read("static/js/cookbook.js")
|
||||
hwfit = _read("static/js/cookbook-hwfit.js")
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
assert "const icon = active ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF;" in cookbook
|
||||
assert "_envState.defaultServer = key;" in hwfit
|
||||
assert "Toggle off if it's already the default" not in hwfit
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
COOKBOOK = (ROOT / "static/js/cookbook.js").read_text(encoding="utf-8")
|
||||
@@ -18,7 +20,7 @@ def test_trending_models_expose_persistent_official_only_switch():
|
||||
|
||||
|
||||
def test_trending_models_list_stays_within_the_cookbook_window():
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
|
||||
rule = style[style.index("#cookbook-hf-latest-list {"):style.index("#cookbook-hf-latest-list {") + 220]
|
||||
assert "max-height: min(52vh, 480px);" in rule
|
||||
@@ -34,7 +36,7 @@ def test_trending_endpoint_applies_first_party_namespace_filter():
|
||||
|
||||
|
||||
def test_official_only_toggle_fits_narrow_download_toolbar():
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
start = style.rindex(".cookbook-official-filter {")
|
||||
rule = style[start:style.index("}", start)]
|
||||
assert "flex: 0 1 auto" in rule
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Computed-style snapshot regression for ``static/style.css``.
|
||||
|
||||
The stylesheet is one 51k-line file whose result depends on source order, so an
|
||||
extraction that "looks fine" can still change which declaration wins. These
|
||||
tests capture ``getComputedStyle`` over a fixed element inventory across pages,
|
||||
viewports, themes and density modes, and compare the hash against
|
||||
``tests/css_snapshot/baseline.json``.
|
||||
|
||||
Run ``python scripts/css_snapshot.py --write-baseline`` to re-record the
|
||||
baseline, and only after confirming the change is intended - see
|
||||
``tests/css_snapshot/README.md``.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.cli_loader import load_script
|
||||
|
||||
snapshot = load_script("css_snapshot.py")
|
||||
|
||||
_HAS_BROWSER = snapshot.playwright_available()
|
||||
_requires_browser = pytest.mark.skipif(
|
||||
not _HAS_BROWSER,
|
||||
reason="node with the playwright package is required (npm ci)",
|
||||
)
|
||||
|
||||
def static_origin() -> str:
|
||||
"""Origin of the session static server, read at call time.
|
||||
|
||||
The session fixture binds an ephemeral port and publishes it through the
|
||||
environment, which happens after this module is imported at collection.
|
||||
Reading it into a module constant therefore captured the fallback and the
|
||||
capture then connected to a port nothing was listening on. The literal
|
||||
fallback is the fixed port the fixture used before it moved to an ephemeral
|
||||
one, so this still works on a revision that predates that change.
|
||||
"""
|
||||
return os.environ.get("ODYSSEUS_TEST_STATIC_ORIGIN", "http://127.0.0.1:7011")
|
||||
|
||||
# One conflicting selector used to prove the harness is actually sensitive to
|
||||
# source order. `.attach-strip` is declared three times at the top level of
|
||||
# style.css with different margin, min-height and padding, so swapping the
|
||||
# first two changes which declaration wins without changing a single byte of
|
||||
# any individual rule.
|
||||
CONFLICTING_SELECTOR = ".attach-strip"
|
||||
|
||||
|
||||
def test_baseline_covers_every_inventory_entry():
|
||||
"""The committed baseline and the inventory describe the same surface.
|
||||
|
||||
Cheap and browserless: it catches an inventory entry added without
|
||||
re-recording the baseline, which would otherwise look like a pass because
|
||||
nothing compares an absent key.
|
||||
"""
|
||||
inventory = snapshot.load_inventory()
|
||||
baseline = snapshot.load_baseline()
|
||||
|
||||
variant_names = {variant["name"] for variant in inventory["variants"]}
|
||||
for page in inventory["pages"]:
|
||||
name = page["name"]
|
||||
assert name in baseline["elements"], f"{name} missing from the baseline"
|
||||
expected_keys = {entry["key"] for entry in page.get("elements", [])}
|
||||
expected_keys |= set(page.get("bench", []))
|
||||
assert set(baseline["elements"][name]) == expected_keys, (
|
||||
f"{name}: baseline elements differ from the inventory; "
|
||||
"re-record with scripts/css_snapshot.py --write-baseline"
|
||||
)
|
||||
assert set(baseline["variants"][name]) == variant_names
|
||||
|
||||
|
||||
@_requires_browser
|
||||
def test_computed_styles_match_the_committed_baseline():
|
||||
captured = snapshot.capture(static_origin())
|
||||
|
||||
assert captured["missing"] == {}, (
|
||||
"inventory entries matched no element - the markup moved under the "
|
||||
f"harness: {captured['missing']}"
|
||||
)
|
||||
|
||||
summary = snapshot.summarize(captured["snapshot"])
|
||||
drift = snapshot.compare(snapshot.load_baseline(), summary)
|
||||
assert not drift["elements"] and not drift["variants"] and not drift["digest_changed"], (
|
||||
"computed styles moved against tests/css_snapshot/baseline.json.\n"
|
||||
f"elements: {drift['elements']}\n"
|
||||
f"variants: {drift['variants']}\n"
|
||||
"If the change is intended, re-record with "
|
||||
"`python scripts/css_snapshot.py --write-baseline`; if it is not, the "
|
||||
"restructuring changed which declaration wins."
|
||||
)
|
||||
|
||||
|
||||
@_requires_browser
|
||||
def test_reordering_two_conflicting_declarations_moves_the_digest():
|
||||
"""The harness has to fail when the cascade changes, or it proves nothing.
|
||||
|
||||
Captures one variant twice - once normally, once with the first two
|
||||
top-level `.attach-strip` blocks swapped - and asserts the digest moves and
|
||||
points at the affected element.
|
||||
"""
|
||||
variants = ["desktop-dark-comfortable"]
|
||||
unchanged = snapshot.summarize(
|
||||
snapshot.capture(static_origin(), variants=variants)["snapshot"]
|
||||
)
|
||||
reordered = snapshot.summarize(
|
||||
snapshot.capture(static_origin(), variants=variants,
|
||||
swap_rule=CONFLICTING_SELECTOR)["snapshot"]
|
||||
)
|
||||
|
||||
assert unchanged["digest"] != reordered["digest"]
|
||||
drift = snapshot.compare(unchanged, reordered)
|
||||
assert "app-shell/attach-strip" in drift["elements"]
|
||||
assert "bench/.attach-strip" in drift["elements"]
|
||||
@@ -95,7 +95,7 @@ def test_mobile_explicit_load_restores_full_editor_from_bottom_dock():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
const state = await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&mobile-library-open-test=1');
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
CSS = Path("static/style.css").read_text()
|
||||
|
||||
CSS = app_css()
|
||||
|
||||
|
||||
def rule_for(selector: str) -> str:
|
||||
|
||||
@@ -23,7 +23,7 @@ def test_mobile_rich_text_history_state_and_document_switch():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&history-controls-test=1');
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_mobile_open_in_new_chat_copies_to_materialized_session():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
const state = await page.evaluate(async () => {
|
||||
let currentSession = 'current-chat';
|
||||
|
||||
@@ -70,7 +70,7 @@ def test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&outline-test=1');
|
||||
|
||||
@@ -25,7 +25,7 @@ def test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&checklist-enter-test=1');
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent(`<style>
|
||||
:root { --fg:#d8dee9; --bg:#17191f; --panel:#20232b; --border:#444; --red:#e45b6c; --accent-primary:#e45b6c; }
|
||||
</style><link rel="stylesheet" href="/static/style.css?rich-color-test=1">
|
||||
|
||||
@@ -58,7 +58,7 @@ def test_browser_word_export_contains_native_rich_docx_ooxml():
|
||||
viewport: {{ width: 900, height: 700 }},
|
||||
acceptDownloads: true,
|
||||
}});
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${{process.env.ODYSSEUS_TEST_STATIC_ORIGIN}}/static/js/documentStats.js`);
|
||||
await page.route('**/api/upload/docx-image-test', route => route.fulfill({{
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
@@ -157,7 +157,7 @@ def test_browser_markdown_word_export_keeps_heading_and_inline_formatting():
|
||||
viewport: {{ width: 900, height: 700 }},
|
||||
acceptDownloads: true,
|
||||
}});
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${{process.env.ODYSSEUS_TEST_STATIC_ORIGIN}}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {{
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&markdown-docx-export-test=1');
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replac
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&find-boundaries-test=1');
|
||||
|
||||
@@ -5,10 +5,12 @@ import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def test_font_and_color_controls_use_shared_components():
|
||||
@@ -91,7 +93,7 @@ def test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile():
|
||||
|
||||
async function exercise(viewport, suffix) {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async suffix => {
|
||||
const mod = await import(`/static/js/document.js?v=20260831richtexttools91&font-color=${suffix}`);
|
||||
|
||||
@@ -28,7 +28,7 @@ def test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&heading-enter=1');
|
||||
@@ -96,7 +96,7 @@ def test_heading_enter_preserves_shift_middle_and_empty_heading_semantics():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&heading-enter-boundaries=1');
|
||||
|
||||
@@ -4,10 +4,12 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def test_image_caption_uses_semantic_figure_and_structured_export_paths():
|
||||
@@ -33,7 +35,7 @@ def test_mobile_image_caption_survives_resize_history_and_empty_removal():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&image-caption=1');
|
||||
|
||||
@@ -4,10 +4,12 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def test_input_rules_are_scoped_to_plain_blocks():
|
||||
@@ -25,7 +27,7 @@ def test_typing_markers_converts_blocks_and_preserves_following_text():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&input-rules-test=1');
|
||||
|
||||
@@ -15,7 +15,7 @@ def test_rich_document_shortcuts_work_at_desktop_and_mobile_widths():
|
||||
|
||||
async function exercise(viewport, suffix) {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async suffix => {
|
||||
const mod = await import(`/static/js/document.js?v=20260831richtexttools91&keyboard-shortcuts=${suffix}`);
|
||||
|
||||
@@ -25,7 +25,7 @@ def test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&selection-toolbar-test=1');
|
||||
|
||||
@@ -40,7 +40,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&slash-menu-test=1');
|
||||
|
||||
@@ -13,7 +13,7 @@ def test_rich_url_paste_links_selections_and_plain_urls_without_unsafe_autolinks
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&smart-link-paste=1');
|
||||
|
||||
@@ -43,7 +43,7 @@ def test_mobile_headings_page_break_history_and_persistence():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&structure-tools-test=1');
|
||||
|
||||
@@ -30,7 +30,7 @@ def test_mobile_table_cell_alignment_tracks_state_and_native_history():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-cell-alignment=1');
|
||||
|
||||
@@ -35,7 +35,7 @@ def test_mobile_structural_edits_preserve_header_modes_and_history():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-header-preservation-test=1');
|
||||
|
||||
@@ -39,7 +39,7 @@ def test_mobile_header_row_and_column_toggle_independently_with_undo():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-header-test=1');
|
||||
|
||||
@@ -33,7 +33,7 @@ def test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history(
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-merge-split=1');
|
||||
|
||||
@@ -30,7 +30,7 @@ def test_mobile_table_tab_navigation_row_creation_and_history():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-tab-history-test=1');
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def test_rich_text_toolbar_exposes_document_formatting_controls():
|
||||
|
||||
@@ -57,7 +57,7 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&toggle-test=1');
|
||||
@@ -126,7 +126,7 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&toolbar-menu-test=1');
|
||||
@@ -241,7 +241,7 @@ def test_rich_toolbar_menus_track_live_formatting_values():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&toolbar-state-test=1');
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_ctrl_s_saves_rich_text_immediately_once_and_updates_status():
|
||||
body: JSON.stringify({ version_count: 2 }),
|
||||
});
|
||||
});
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&save-shortcut-test=1');
|
||||
|
||||
@@ -31,7 +31,7 @@ def test_save_status_is_dirty_race_safe_and_reports_failures():
|
||||
});
|
||||
active -= 1;
|
||||
});
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&save-status-test=1');
|
||||
|
||||
@@ -4,10 +4,12 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def _run_stats(expression: str):
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
|
||||
|
||||
async function inspect(viewport, suffix) {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async suffix => {
|
||||
const mod = await import(`/static/js/document.js?v=20260831richtexttools91&toolbar-order=${suffix}`);
|
||||
|
||||
@@ -13,7 +13,7 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None:
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=escape-regression-1');
|
||||
@@ -75,7 +75,7 @@ def test_email_escape_closes_inner_states_without_closing_library() -> None:
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
window.fetch = async () => new Response(JSON.stringify({
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_gallery_detail_uses_fixed_media_stage_and_arrow_lanes():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
image_rule = css[css.index(".gallery-detail-image {"):]
|
||||
image_rule = image_rule[:image_rule.index("}")]
|
||||
|
||||
@@ -17,7 +19,7 @@ def test_gallery_detail_uses_fixed_media_stage_and_arrow_lanes():
|
||||
|
||||
|
||||
def test_gallery_media_maximizes_inside_stage_without_resizing_it():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert css.count("max-height: 100%") >= 2
|
||||
assert "object-fit: contain" in css
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_gallery_selection_dot_has_rendered_dimensions():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
rule = css[css.index(".gallery-select-dot {"):]
|
||||
rule = rule[:rule.index("}")]
|
||||
|
||||
@@ -15,7 +17,7 @@ def test_gallery_selection_dot_has_rendered_dimensions():
|
||||
|
||||
|
||||
def test_gallery_selection_highlight_is_painted_above_thumbnail():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
selector = ".gallery-card:has(.gallery-select-dot.selected)::after"
|
||||
rule = css[css.index(selector):]
|
||||
rule = rule[:rule.index("}")]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
@@ -20,7 +22,7 @@ def test_gallery_exposes_active_filter_organization():
|
||||
|
||||
def test_gallery_cards_keep_badges_without_hover_inspect_label():
|
||||
js = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert "gallery-card-overlay" in js
|
||||
assert "gallery-card-inspect" not in js
|
||||
@@ -31,7 +33,7 @@ def test_gallery_cards_keep_badges_without_hover_inspect_label():
|
||||
|
||||
def test_gallery_detail_has_richer_inspector_actions():
|
||||
js = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert "gallery-detail-inspector-head" in js
|
||||
assert "gallery-detail-meta-grid" in js
|
||||
@@ -57,7 +59,7 @@ def test_gallery_static_cache_key_bumped():
|
||||
|
||||
def test_gallery_bulk_select_button_keeps_toolbar_alignment():
|
||||
js = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert 'id="gallery-select-btn"' in js
|
||||
assert 'id="gallery-select-btn" title="Select for bulk actions" style="position:relative;top:0;"' in js
|
||||
@@ -74,7 +76,7 @@ def test_gallery_bulk_select_button_keeps_toolbar_alignment():
|
||||
def test_gallery_selection_bars_share_style_and_escape_priority():
|
||||
gallery = _read("static/js/gallery.js")
|
||||
ui = _read("static/js/ui.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert gallery.count("memory-bulk-bar gallery-selection-bar hidden") == 3
|
||||
assert "window.__galleryCancelSelection = () =>" in gallery
|
||||
@@ -93,7 +95,7 @@ def test_gallery_selection_bars_share_style_and_escape_priority():
|
||||
|
||||
def test_editor_saved_projects_support_grid_and_list_views():
|
||||
gallery = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert 'id="gallery-editor-drafts-view" role="group"' in gallery
|
||||
assert 'data-view="grid"' in gallery
|
||||
@@ -136,7 +138,7 @@ def test_editor_saved_projects_support_grid_and_list_views():
|
||||
|
||||
def test_empty_albums_show_action_tiles_instead_of_no_albums_message():
|
||||
gallery = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert "function _albumActionTiles()" in gallery
|
||||
assert 'id="gallery-albums-new"' in gallery
|
||||
@@ -149,7 +151,7 @@ def test_empty_albums_show_action_tiles_instead_of_no_albums_message():
|
||||
|
||||
|
||||
def test_gallery_body_uses_available_window_height_without_grid_crop():
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
gallery_section = css.index("/* ── Gallery (image library) ── */")
|
||||
modal_start = css.index(".gallery-modal-content {", gallery_section)
|
||||
@@ -161,7 +163,7 @@ def test_gallery_body_uses_available_window_height_without_grid_crop():
|
||||
|
||||
|
||||
def test_gallery_photo_search_is_not_vertically_offset():
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
search_idx = css.index(".gallery-search-wrap {")
|
||||
assert "top: 0;" in css[search_idx:search_idx + 180]
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_mobile_notes_checklist_rows_remain_tappable():
|
||||
css = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "body.notes-mobile-mode .note-card .note-checkbox {" in css
|
||||
@@ -39,7 +41,7 @@ def test_mobile_long_press_enters_select_mode_for_that_note():
|
||||
|
||||
|
||||
def test_mobile_select_mode_has_subtle_jiggle_feedback():
|
||||
css = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert "@keyframes notes-select-jiggle" in css
|
||||
assert "body.notes-mobile-mode .note-card-selectmode" in css
|
||||
@@ -81,7 +83,7 @@ def test_calendar_email_attachments_have_a_calendar_import_action():
|
||||
|
||||
def test_mobile_bulk_select_long_press_is_shared_across_card_types():
|
||||
helper = (ROOT / "static" / "js" / "mobileBulkSelect.js").read_text(encoding="utf-8")
|
||||
css = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert ".memory-item[data-memory-id]" in helper
|
||||
assert ".skill-card[data-skill-name]" in helper
|
||||
|
||||
@@ -1801,3 +1801,47 @@ def test_generic_go_to_phrase_is_browser_interaction() -> None:
|
||||
assert not _looks_like_explicit_browser_interaction(
|
||||
"Find rules for getting rid of garbage in Setagaya-ku."
|
||||
)
|
||||
|
||||
|
||||
def test_terminate_owned_chrome_skips_the_sweep_without_procfs(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
"""macOS and Windows have no /proc; shutdown must degrade, not raise."""
|
||||
|
||||
missing = tmp_path / "no-procfs"
|
||||
monkeypatch.setattr(web_tools, "_PROC_ROOT", missing)
|
||||
|
||||
def _unexpected_iterdir(*args, **kwargs):
|
||||
raise AssertionError("the pid sweep must not run without procfs")
|
||||
|
||||
monkeypatch.setattr(Path, "iterdir", _unexpected_iterdir)
|
||||
|
||||
PrivateBrowserTool._terminate_owned_chrome({"TMPDIR": str(tmp_path)})
|
||||
|
||||
|
||||
def test_terminate_owned_chrome_kills_only_this_runtimes_profile(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
"""With procfs present, match on the runtime-owned profile prefix alone."""
|
||||
|
||||
proc = tmp_path / "proc"
|
||||
tmpdir = tmp_path / "runtime-tmp"
|
||||
tmpdir.mkdir()
|
||||
profile_prefix = str(tmpdir.resolve() / "agent-browser-chrome-")
|
||||
|
||||
def _write_pid(pid: str, cmdline: str) -> None:
|
||||
entry = proc / pid
|
||||
entry.mkdir(parents=True)
|
||||
(entry / "cmdline").write_bytes(cmdline.replace(" ", "\0").encode())
|
||||
|
||||
_write_pid("101", f"chrome --user-data-dir={profile_prefix}abc")
|
||||
_write_pid("202", "chrome --user-data-dir=/Users/someone/Library/Chrome")
|
||||
(proc / "self").mkdir()
|
||||
|
||||
monkeypatch.setattr(web_tools, "_PROC_ROOT", proc)
|
||||
killed: list[int] = []
|
||||
monkeypatch.setattr(web_tools.os, "kill", lambda pid, sig: killed.append(pid))
|
||||
|
||||
PrivateBrowserTool._terminate_owned_chrome({"TMPDIR": str(tmpdir)})
|
||||
|
||||
assert killed == [101]
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_history_list_participates_in_shared_library_expansion_layout():
|
||||
panel = (ROOT / "static/js/research/panel.js").read_text(encoding="utf-8")
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert 'id="research-past-list" class="doclib-grid memory-list research-jobs-list"' in panel
|
||||
card = css[css.index("#research-pane .research-history-card {"):]
|
||||
@@ -38,7 +40,7 @@ def test_history_list_participates_in_shared_library_expansion_layout():
|
||||
|
||||
|
||||
def test_expanded_history_report_owns_and_scrolls_the_available_height():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
expanded = (
|
||||
"#research-pane #research-past-list "
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -19,7 +21,7 @@ def test_active_research_visualizer_has_live_hierarchy_and_balanced_layout():
|
||||
|
||||
|
||||
def test_active_research_visualizer_respects_reduced_motion():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert "@media (prefers-reduced-motion: reduce)" in css
|
||||
assert ".research-synapse .rs-live-dot" in css
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _occurrences(text, needle):
|
||||
"""Every index of needle, so an assertion does not depend on which copy of
|
||||
a selector the cascade happens to put first."""
|
||||
out, i = [], text.find(needle)
|
||||
while i != -1:
|
||||
out.append(i)
|
||||
i = text.find(needle, i + 1)
|
||||
return out
|
||||
|
||||
|
||||
def test_bulk_select_triggers_share_dot_and_x_icons():
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
for selector in (
|
||||
"#memory-select-btn",
|
||||
@@ -36,14 +48,14 @@ def test_library_titles_have_highlighted_respective_icons():
|
||||
source = (ROOT / "static" / "js" / "documentLibrary.js").read_text()
|
||||
|
||||
assert source.count('class="doclib-section-title-icon"') == 4
|
||||
assert "color: var(--accent, var(--red));" in (ROOT / "static" / "style.css").read_text()
|
||||
assert "color: var(--accent, var(--red));" in app_css()
|
||||
for title in ("Chats", "Archive", "Research", "Documents"):
|
||||
assert f">{title} <span" in source
|
||||
|
||||
|
||||
def test_library_select_follows_tidy():
|
||||
source = (ROOT / "static" / "js" / "documentLibrary.js").read_text()
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
assert source.index('id="doclib-chats-tidy-btn"') < source.index('id="doclib-chats-select-btn"')
|
||||
assert source.index('id="doclib-research-tidy-btn"') < source.index('id="doclib-research-select-btn"')
|
||||
@@ -67,7 +79,7 @@ def test_library_select_follows_tidy():
|
||||
|
||||
def test_library_filter_chips_match_skills_count_markup():
|
||||
source = (ROOT / "static" / "js" / "documentLibrary.js").read_text()
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
assert "function _setLibraryCountChipContent" in source
|
||||
assert "chip.replaceChildren(text, value)" in source
|
||||
@@ -89,7 +101,7 @@ def test_launch_cookbook_filter_chips_match_skills():
|
||||
|
||||
|
||||
def test_launch_search_select_and_tags_move_up_together():
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
rule = css[css.index("#serve-search,"):css.index("#serve-search,") + 140]
|
||||
assert "#hwfit-cache-select," in rule
|
||||
@@ -109,11 +121,16 @@ def test_launch_search_select_and_tags_move_up_together():
|
||||
|
||||
|
||||
def test_launch_server_selector_and_model_search_move_up_together():
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
rule = css[css.index("#hwfit-server-select,"):css.index("#hwfit-server-select,") + 130]
|
||||
assert "#hwfit-search" in rule
|
||||
assert "top: -2px !important;" in rule
|
||||
# The stylesheets are split across several files, so this selector group
|
||||
# is not guaranteed to be the first occurrence of its opening selector.
|
||||
# Check every occurrence instead of a fixed offset into the cascade.
|
||||
rules = [css[i:i + 130] for i in _occurrences(css, "#hwfit-server-select,")]
|
||||
assert any(
|
||||
"#hwfit-search" in rule and "top: -2px !important;" in rule
|
||||
for rule in rules
|
||||
), "no #hwfit-server-select rule groups #hwfit-search with top: -2px !important"
|
||||
|
||||
|
||||
def test_launch_heading_uses_accented_flame_icon():
|
||||
@@ -133,7 +150,7 @@ def test_launch_heading_reports_cached_model_count():
|
||||
|
||||
|
||||
def test_launch_search_matches_document_library_search_treatment():
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
rule = css[css.index("#hwfit-search {", css.index("#hwfit-server-select,")):][:700]
|
||||
assert "height: 30px;" in rule
|
||||
@@ -151,7 +168,7 @@ def test_launch_search_matches_document_library_search_treatment():
|
||||
|
||||
def test_memory_filter_chips_match_skills_and_are_not_cropped():
|
||||
source = (ROOT / "static" / "js" / "memory.js").read_text()
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
assert "skills-summary-chip memory-filter-chip" in source
|
||||
assert "btn.replaceChildren(label, count)" in source
|
||||
|
||||
@@ -2,11 +2,13 @@ import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SKILLS_JS = (ROOT / "static/js/skills.js").read_text(encoding="utf-8")
|
||||
METRICS_JS = (ROOT / "static/js/skillsMetrics.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Stylesheet asset manifest: shipped HTML and the service worker must agree.
|
||||
|
||||
``specs/frontend.md`` records that nothing validates the service-worker
|
||||
precache against what ``index.html`` actually loads, and that a static asset
|
||||
manifest regression should confirm referenced files exist. Both matter more
|
||||
once ``static/style.css`` starts being split: every new stylesheet has to be
|
||||
registered in ``static/index.html`` *and* in the ``static/sw.js`` precache,
|
||||
with the same ``?v=`` cache-bust string in both. Today those two strings are
|
||||
hardcoded independently, so a split that updates one and not the other ships an
|
||||
offline cache that serves a stale or missing stylesheet - invisible until
|
||||
someone loads the app without a network.
|
||||
|
||||
This test reads the shipped files rather than driving a browser: the artifact
|
||||
under test *is* the URL string in two files, and a browser would only see one
|
||||
of them at a time.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INDEX_HTML = ROOT / "static" / "index.html"
|
||||
SERVICE_WORKER = ROOT / "static" / "sw.js"
|
||||
|
||||
_STYLESHEET_LINK = re.compile(
|
||||
r"""<link\b[^>]*\brel=["']stylesheet["'][^>]*>""", re.IGNORECASE)
|
||||
_HREF = re.compile(r"""\bhref=["']([^"']+)["']""", re.IGNORECASE)
|
||||
|
||||
|
||||
def _stylesheet_hrefs(html_path):
|
||||
"""Every href from a <link rel="stylesheet"> in one HTML file."""
|
||||
html = html_path.read_text(encoding="utf-8")
|
||||
hrefs = []
|
||||
for tag in _STYLESHEET_LINK.findall(html):
|
||||
match = _HREF.search(tag)
|
||||
if match:
|
||||
hrefs.append(match.group(1))
|
||||
return hrefs
|
||||
|
||||
|
||||
def _precache_entries(name):
|
||||
"""The quoted URLs of one precache array in sw.js.
|
||||
|
||||
Entries built by spreading another list (``...KATEX_FONTS``) are skipped:
|
||||
they carry no literal URL at this level, and the fonts they add are not
|
||||
stylesheets.
|
||||
"""
|
||||
source = SERVICE_WORKER.read_text(encoding="utf-8")
|
||||
match = re.search(rf"const {name} = \[(.*?)\n\];", source, re.DOTALL)
|
||||
assert match, f"{name} array not found in static/sw.js"
|
||||
return re.findall(r"""['"](/[^'"]+)['"]""", match.group(1))
|
||||
|
||||
|
||||
def _local_path(url):
|
||||
"""Map an app-absolute URL to the file it is served from."""
|
||||
return ROOT / url.split("?", 1)[0].lstrip("/")
|
||||
|
||||
|
||||
def test_every_stylesheet_referenced_by_shipped_html_exists():
|
||||
checked = 0
|
||||
for html_path in sorted((ROOT / "static").glob("*.html")):
|
||||
for href in _stylesheet_hrefs(html_path):
|
||||
if not href.startswith("/"):
|
||||
continue # external or relative-to-page; not ours to resolve
|
||||
assert _local_path(href).is_file(), (
|
||||
f"{html_path.name} links {href}, which does not exist on disk"
|
||||
)
|
||||
checked += 1
|
||||
assert checked, "no app stylesheet links found - the parser or the markup moved"
|
||||
|
||||
|
||||
def test_every_precached_stylesheet_exists():
|
||||
for name in ("PRECACHE", "PANEL_PRECACHE"):
|
||||
for url in _precache_entries(name):
|
||||
if not url.endswith(".css") and ".css?" not in url:
|
||||
continue
|
||||
assert _local_path(url).is_file(), (
|
||||
f"sw.js {name} precaches {url}, which does not exist on disk"
|
||||
)
|
||||
|
||||
|
||||
def test_index_stylesheets_are_precached_with_the_same_cache_bust_string():
|
||||
"""The precache must match the request the browser actually makes.
|
||||
|
||||
A service-worker entry only matches a fetch when the URL is identical,
|
||||
query string included, so a stylesheet whose ``?v=`` differs between the
|
||||
two files is precached under a URL nothing ever requests.
|
||||
"""
|
||||
precached = set(_precache_entries("PRECACHE"))
|
||||
precached_paths = {url.split("?", 1)[0]: url for url in precached}
|
||||
|
||||
for href in _stylesheet_hrefs(INDEX_HTML):
|
||||
if not href.startswith("/static/"):
|
||||
continue
|
||||
path = href.split("?", 1)[0]
|
||||
assert path in precached_paths, (
|
||||
f"index.html loads {href} but sw.js PRECACHE has no entry for {path}; "
|
||||
"the app shell would not be available offline"
|
||||
)
|
||||
assert precached_paths[path] == href, (
|
||||
f"cache-bust mismatch for {path}: index.html requests {href!r}, "
|
||||
f"sw.js precaches {precached_paths[path]!r}"
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Contract for the loopback static server the browser tests run against.
|
||||
|
||||
The server used to bind a fixed port and raise if it was taken, which errored
|
||||
every collected test rather than the browser ones — so a second worktree
|
||||
running its own suite took the whole session down with it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_static_origin_is_published_for_node_subprocesses() -> None:
|
||||
origin = os.environ.get("ODYSSEUS_TEST_STATIC_ORIGIN")
|
||||
|
||||
assert origin, "the session fixture must publish the origin it bound"
|
||||
assert re.fullmatch(r"http://127\.0\.0\.1:\d+", origin)
|
||||
|
||||
|
||||
def test_static_origin_does_not_reuse_the_application_port() -> None:
|
||||
"""An ephemeral port keeps the suite and a running instance independent."""
|
||||
|
||||
origin = os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"]
|
||||
|
||||
assert not origin.endswith(":7011")
|
||||
|
||||
|
||||
def test_static_server_serves_this_worktree() -> None:
|
||||
origin = os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"]
|
||||
|
||||
with urllib.request.urlopen(f"{origin}/static/js/documentStats.js", timeout=5) as r:
|
||||
assert r.status == 200
|
||||
assert r.headers.get_content_type() == "application/javascript"
|
||||
|
||||
|
||||
def test_no_test_hardcodes_the_static_server_origin() -> None:
|
||||
"""Regression guard: a hardcoded port reintroduces the collision."""
|
||||
|
||||
needle = "page.goto(" + "'http://127.0.0.1:"
|
||||
offenders = []
|
||||
for path in sorted((ROOT / "tests").glob("*.py")):
|
||||
if path.name == Path(__file__).name:
|
||||
continue
|
||||
if needle in path.read_text(encoding="utf-8"):
|
||||
offenders.append(path.name)
|
||||
|
||||
assert offenders == [], (
|
||||
"browser tests must read ODYSSEUS_TEST_STATIC_ORIGIN rather than a fixed port"
|
||||
)
|
||||
Reference in New Issue
Block a user