Merge current lab into agent runtime decomposition

This commit is contained in:
Alexandre Teixeira
2026-09-29 19:15:54 +01:00
67 changed files with 8455 additions and 4028 deletions
+3
View File
@@ -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/
+290
View File
@@ -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())
+853
View File
@@ -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
View File
@@ -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.
+13 -1
View File
@@ -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
+962
View File
@@ -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
+7
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -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',
+36
View File
@@ -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
+171
View File
@@ -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
View File
@@ -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()
+129
View File
@@ -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.
+767
View File
@@ -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"
}
}
}
+20
View File
@@ -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>
+266
View File
@@ -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
+63
View File
@@ -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())
+11 -9
View File
@@ -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
+3 -1
View File
@@ -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()
+35 -19
View File
@@ -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
+111
View File
@@ -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"]
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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');
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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');
+1 -1
View File
@@ -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">
+2 -2
View File
@@ -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');
+1 -1
View File
@@ -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}`);
+2 -2
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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');
+1 -1
View File
@@ -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');
+1 -1
View File
@@ -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');
+1 -1
View File
@@ -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');
+1 -1
View File
@@ -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');
+3 -1
View File
@@ -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():
+3 -3
View File
@@ -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');
+1 -1
View File
@@ -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');
+1 -1
View File
@@ -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');
+3 -1
View File
@@ -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):
+1 -1
View File
@@ -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}`);
+2 -2
View File
@@ -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({
+4 -2
View File
@@ -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("}")]
+10 -8
View File
@@ -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
+44
View File
@@ -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]
+4 -2
View File
@@ -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 "
+3 -1
View File
@@ -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
+28 -11
View File
@@ -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
+3 -1
View File
@@ -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")
+102
View File
@@ -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}"
)
+53
View File
@@ -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"
)