mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-09 00:12:21 +02:00
Merge current lab into agent runtime decomposition
This commit is contained in:
@@ -145,6 +145,25 @@ The runner propagates pytest's exit code, so it composes with normal local
|
||||
workflows; "report-only" means it is not a CI gate, not that failures are
|
||||
swallowed.
|
||||
|
||||
## CSS computed-style snapshot
|
||||
|
||||
`tests/test_css_computed_style_snapshot.py` pins the rendered result of
|
||||
`static/style.css` - one 51k-line file whose behavior depends on source order -
|
||||
by hashing `getComputedStyle` over a fixed element inventory across pages,
|
||||
viewports, themes and density modes. Any PR that moves CSS has to produce an
|
||||
identical digest or explain why it did not.
|
||||
|
||||
```bash
|
||||
./venv/bin/python -m pytest tests/test_css_computed_style_snapshot.py
|
||||
./venv/bin/python scripts/css_snapshot.py --check # standalone, no pytest
|
||||
./venv/bin/python scripts/css_snapshot.py --write-baseline # re-record, deliberately
|
||||
```
|
||||
|
||||
The inventory, the baseline and the capture live in `tests/css_snapshot/`;
|
||||
`tests/css_snapshot/README.md` documents what is covered, what is deliberately
|
||||
not, and how to find the property that moved when it fails. The run takes about
|
||||
21 seconds and skips when `npm ci` has not been run.
|
||||
|
||||
## Core principles
|
||||
|
||||
- Keep PRs small and homogeneous: one kind of change per PR.
|
||||
@@ -161,6 +180,23 @@ The helpers below live under `tests/helpers/`. They exist to remove repeated
|
||||
boilerplate that already appeared across multiple tests. Reach for one only when
|
||||
your test matches its intended use; do not stretch a helper to cover a new case.
|
||||
|
||||
### `tests.helpers.stylesheets.app_css`
|
||||
|
||||
Use when a test asserts on a CSS rule.
|
||||
|
||||
- Returns every app stylesheet concatenated in the order `static/index.html`
|
||||
loads them, which is the order the cascade actually has.
|
||||
- Panel styles no longer all live in `static/style.css`; reading that file
|
||||
alone ties the test to whichever file a rule sits in today, so it goes red
|
||||
when a rule moves without the rendered page changing.
|
||||
- `stylesheet_paths()` and `stylesheet_urls()` are there when a test needs the
|
||||
files or the request URLs rather than their contents.
|
||||
`stylesheet_link_tags()` returns the `<link>` markup for a synthetic page
|
||||
driven through Playwright, so it gets the whole cascade instead of only
|
||||
`style.css`.
|
||||
- All of them fail loudly if `index.html` links a stylesheet that is missing.
|
||||
- Not for vendored CSS under `static/lib/`, which they deliberately skip.
|
||||
|
||||
### `tests.helpers.cli_loader.load_script`
|
||||
|
||||
Use when a test needs to import a script under `scripts/` without repeating
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""The isolation contract of `odysseus dev`.
|
||||
|
||||
The launcher exists so that two checkouts on one machine cannot share
|
||||
runtime state by accident. Every test here pins one of the guarantees
|
||||
that makes that true: derived ports never land on a port the project
|
||||
already means something by, a ChromaDB we did not start is refused
|
||||
rather than adopted, a checkout wired into a service manager is not
|
||||
bootable, and nothing is signalled on the strength of a pid alone.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.cli_loader import load_script
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli():
|
||||
return load_script("odysseus-dev")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worktree(tmp_path):
|
||||
"""A directory shaped enough like a checkout for the launcher to accept it."""
|
||||
for marker in ("app.py", "setup.py", "requirements.txt"):
|
||||
(tmp_path / marker).write_text("")
|
||||
(tmp_path / "venv" / "bin").mkdir(parents=True)
|
||||
(tmp_path / "venv" / "bin" / "python").write_text("")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def up_args(**overrides):
|
||||
defaults = dict(
|
||||
port=None, chroma_port=None, no_chroma=False, venv=None, from_pr=None,
|
||||
remote="origin", foreground=False, timeout=5, pretty=False,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
def test_derived_ports_are_stable_distinct_and_never_reserved(cli):
|
||||
first = cli.derive_ports("/checkouts/alpha")
|
||||
assert first == cli.derive_ports("/checkouts/alpha")
|
||||
assert first != cli.derive_ports("/checkouts/beta")
|
||||
assert first["chroma"] == first["app"] + 1
|
||||
assert first["test_static"] == first["app"] + 2
|
||||
|
||||
# No path can derive onto a port the project already owns — 7860 is a
|
||||
# normal start-macos.sh launch, 8100 somebody else's vector store.
|
||||
for index in range(500):
|
||||
for port in cli.derive_ports(f"/checkouts/w{index}").values():
|
||||
assert cli.reserved_reason(port) is None, port
|
||||
|
||||
|
||||
def test_reserved_ports_are_refused_even_when_asked_for(cli, worktree, monkeypatch):
|
||||
monkeypatch.chdir(worktree)
|
||||
with pytest.raises(SystemExit):
|
||||
cli.resolve_ports(worktree, up_args(port=7860))
|
||||
with pytest.raises(SystemExit):
|
||||
cli.resolve_ports(worktree, up_args(chroma_port=8100))
|
||||
|
||||
|
||||
def test_root_is_resolved_from_the_working_directory(cli, worktree):
|
||||
nested = worktree / "static" / "js"
|
||||
nested.mkdir(parents=True)
|
||||
assert cli.find_repo_root(nested) == worktree.resolve()
|
||||
assert cli.find_repo_root(worktree.parent) is None
|
||||
|
||||
|
||||
def test_a_checkout_run_by_a_service_manager_is_not_bootable(cli, worktree, monkeypatch):
|
||||
units = worktree.parent / "units"
|
||||
units.mkdir()
|
||||
(units / "com.odysseus.server.plist").write_text(
|
||||
f"<plist><string>{worktree.resolve()}/start-macos.sh</string></plist>"
|
||||
)
|
||||
assert cli.managed_by_service(worktree, unit_dirs=[units]).endswith(".plist")
|
||||
unrelated = worktree.parent / "somewhere-else"
|
||||
unrelated.mkdir()
|
||||
assert cli.managed_by_service(unrelated, unit_dirs=[units]) is None
|
||||
|
||||
monkeypatch.chdir(worktree)
|
||||
monkeypatch.setattr(cli, "service_unit_dirs", lambda: [units])
|
||||
with pytest.raises(SystemExit):
|
||||
cli.cmd_up(up_args())
|
||||
|
||||
|
||||
def test_a_chromadb_we_did_not_start_is_refused_not_adopted(cli, worktree, monkeypatch):
|
||||
monkeypatch.chdir(worktree)
|
||||
monkeypatch.setattr(cli, "service_unit_dirs", list)
|
||||
ports = cli.derive_ports(worktree)
|
||||
|
||||
foreign = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
foreign.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
foreign.bind(("127.0.0.1", ports["chroma"]))
|
||||
except OSError:
|
||||
pytest.skip(f"derived chroma port {ports['chroma']} is unavailable on this host")
|
||||
foreign.listen(1)
|
||||
try:
|
||||
with pytest.raises(SystemExit):
|
||||
cli.cmd_up(up_args())
|
||||
finally:
|
||||
foreign.close()
|
||||
|
||||
# Refused means refused: nothing was started and no state was recorded.
|
||||
assert not cli.state_path(worktree).exists()
|
||||
|
||||
|
||||
def test_no_chroma_points_the_app_at_a_port_nothing_answers(cli):
|
||||
port = cli.unused_port()
|
||||
assert not cli.port_bound(port)
|
||||
assert cli.reserved_reason(port) is None
|
||||
|
||||
|
||||
def test_every_chroma_outcome_leaves_the_app_off_a_port_we_do_not_own(cli, worktree):
|
||||
"""The decision has three endings and none of them is "use theirs"."""
|
||||
ports = cli.derive_ports(worktree)
|
||||
data, logs = worktree / "data", worktree / "logs"
|
||||
data.mkdir()
|
||||
logs.mkdir()
|
||||
venv_python = worktree / "venv" / "bin" / "python"
|
||||
|
||||
# 1. Asked to go without: a port nothing answers on, not the derived
|
||||
# one, which is where a foreign server may appear later.
|
||||
entry, port, note = cli.resolve_chroma(
|
||||
up_args(no_chroma=True), {}, ports, venv_python, data, logs
|
||||
)
|
||||
assert (entry, port != ports["chroma"], cli.port_bound(port)) == (None, True, False)
|
||||
assert "no-chroma" in note
|
||||
|
||||
# 2. No server to start (this venv has no `chroma` binary, which is
|
||||
# the stock requirements.txt): keyword mode, and again not the
|
||||
# derived port.
|
||||
entry, port, note = cli.resolve_chroma(
|
||||
up_args(), {}, ports, venv_python, data, logs
|
||||
)
|
||||
assert entry is None and port != ports["chroma"]
|
||||
assert "keyword-only" in note
|
||||
|
||||
|
||||
def test_a_pid_is_never_trusted_without_its_command_line(cli, worktree, monkeypatch):
|
||||
own_pid = os.getpid()
|
||||
assert cli.pid_is_ours(own_pid, ["definitely-not-in-this-command-line"]) is False
|
||||
assert cli.pid_is_ours(None, []) is False
|
||||
assert cli.pid_is_ours(own_pid, [cli.pid_command(own_pid).split()[0]]) is True
|
||||
|
||||
# `down` must not signal a live process whose fingerprints disagree —
|
||||
# here, this very test run — and must keep the record so the pid can
|
||||
# be investigated rather than lost.
|
||||
cli.write_state(worktree, {"app": {"pid": own_pid, "fingerprints": ["uvicorn --port 1"]}})
|
||||
monkeypatch.chdir(worktree)
|
||||
monkeypatch.setattr(os, "kill", _forbidden_kill)
|
||||
cli.cmd_down(up_args())
|
||||
assert cli.state_path(worktree).exists()
|
||||
|
||||
|
||||
def test_down_forgets_an_instance_that_is_gone(cli, worktree, monkeypatch):
|
||||
cli.write_state(worktree, {"app": {"pid": 2 ** 31 - 1, "fingerprints": ["uvicorn"]}})
|
||||
monkeypatch.chdir(worktree)
|
||||
cli.cmd_down(up_args())
|
||||
assert not cli.state_path(worktree).exists()
|
||||
|
||||
|
||||
def _forbidden_kill(pid, sig):
|
||||
"""Liveness probes (signal 0) are fine; anything that would actually
|
||||
reach the process is the failure this test is about."""
|
||||
if sig == 0:
|
||||
return None
|
||||
raise AssertionError(f"cmd_down sent signal {sig} to a process it does not own")
|
||||
+33
-17
@@ -98,26 +98,23 @@ def pytest_collection_modifyitems(config, items):
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _serve_test_static():
|
||||
"""Serve this worktree's assets; non-browser runs can request an ephemeral port."""
|
||||
import socket
|
||||
"""Serve static assets on loopback for the browser integration tests.
|
||||
|
||||
Binds an ephemeral port so several worktrees can run their own suite at the
|
||||
same time, and publishes the resulting origin through
|
||||
``ODYSSEUS_TEST_STATIC_ORIGIN``. The browser tests shell out to node, which
|
||||
inherits the environment, so the snippets read the origin from
|
||||
``process.env`` instead of hardcoding a port.
|
||||
|
||||
Set ``ODYSSEUS_TEST_STATIC_PORT`` to pin a specific port when something
|
||||
outside pytest has to reach this server.
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
import http.server
|
||||
import socketserver
|
||||
from pathlib import Path
|
||||
|
||||
port = int(os.environ.get("ODYSSEUS_TEST_STATIC_PORT", "7011"))
|
||||
if not 0 <= port <= 65535:
|
||||
raise ValueError("ODYSSEUS_TEST_STATIC_PORT must be between 0 and 65535")
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
is_bound = (sock.connect_ex(("127.0.0.1", port)) == 0) if port else False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
if is_bound:
|
||||
raise RuntimeError(f"port {port} is already in use; browser tests require this worktree's static server")
|
||||
|
||||
root_dir = Path(__file__).resolve().parent.parent
|
||||
|
||||
class _Handler(http.server.SimpleHTTPRequestHandler):
|
||||
@@ -137,11 +134,30 @@ def _serve_test_static():
|
||||
class _Server(socketserver.TCPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
server = _Server(("127.0.0.1", port), _Handler)
|
||||
requested = int(os.environ.get("ODYSSEUS_TEST_STATIC_PORT") or 0)
|
||||
if not 0 <= requested <= 65535:
|
||||
raise ValueError("ODYSSEUS_TEST_STATIC_PORT must be between 0 and 65535")
|
||||
try:
|
||||
server = _Server(("127.0.0.1", requested), _Handler)
|
||||
except OSError as exc:
|
||||
# Port 0 cannot collide, so this only fires for an explicit pin.
|
||||
raise RuntimeError(
|
||||
f"ODYSSEUS_TEST_STATIC_PORT={requested} is not bindable; unset it to "
|
||||
"let the browser tests pick an ephemeral port"
|
||||
) from exc
|
||||
|
||||
origin = f"http://127.0.0.1:{server.server_address[1]}"
|
||||
previous_origin = os.environ.get("ODYSSEUS_TEST_STATIC_ORIGIN")
|
||||
os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"] = origin
|
||||
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield
|
||||
yield origin
|
||||
finally:
|
||||
if previous_origin is None:
|
||||
os.environ.pop("ODYSSEUS_TEST_STATIC_ORIGIN", None)
|
||||
else:
|
||||
os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"] = previous_origin
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Computed-style snapshot harness
|
||||
|
||||
`static/style.css` is 51,425 lines in one file. Hundreds of selectors are
|
||||
declared more than once and `!important` appears throughout, so the rendered
|
||||
result is a function of **source order**. Extracting a block into its own file,
|
||||
reordering `<link>` tags, or moving an `@media` rule can silently change which
|
||||
declaration wins, and nothing else in the suite would notice.
|
||||
|
||||
This harness makes that falsifiable. It captures `getComputedStyle` over a
|
||||
fixed element inventory, hashes the result, and compares it to a committed
|
||||
baseline. It moves no CSS itself.
|
||||
|
||||
## What it covers
|
||||
|
||||
| Dimension | Values |
|
||||
|---|---|
|
||||
| Pages | `static/index.html` (app shell, 76 elements), `static/login.html` (14), the bench (586 selectors) |
|
||||
| Viewports | 1440x900, 820x1000, 768x1024 (touch), 390x844 (touch) |
|
||||
| Themes | dark (default) and `:root.light` |
|
||||
| Density | default, `:root.density-compact`, `:root.density-spacious` |
|
||||
| Properties | 122 pinned properties per element, plus every custom property on `:root` and `body` |
|
||||
|
||||
That is 676 elements x 24 variants = 16,224 element snapshots per run, in
|
||||
about 21 seconds.
|
||||
|
||||
The **app shell** page measures real elements in the markup the server sends,
|
||||
including modals - each one revealed on its own and re-hidden straight after,
|
||||
so the measurements stay independent.
|
||||
|
||||
The **bench** page measures one synthesised element per selector, built from
|
||||
the selector itself. Its selector list is evidence-driven: every selector
|
||||
declared **more than once** in `style.css` that can be expressed as a static
|
||||
compound chain (551 of them), plus a curated set covering chat, documents,
|
||||
email, notes, calendar, settings, cookbook and gallery. Redeclared selectors
|
||||
are the ones a reorder can actually flip, so they are the ones worth benching.
|
||||
A bench element pins the cascade for that class combination; it does not pin
|
||||
the markup that the JS produces.
|
||||
|
||||
Selectors the bench grammar cannot express are the gap: selector lists
|
||||
(`a, b`), pseudo-elements, pseudo-classes, `:not()` and `:has()`. They are
|
||||
skipped rather than approximated.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `inventory.json` | The fixed inventory: properties, variants, pages, elements, bench selectors |
|
||||
| `baseline.json` | The committed digest plus per-element and per-variant hashes |
|
||||
| `capture.mjs` | Playwright capture; raw values on stdout |
|
||||
| `bench.html` | Empty page that loads the stylesheet; the capture mounts bench nodes into it |
|
||||
| `../test_css_computed_style_snapshot.py` | The regression test |
|
||||
| `../../scripts/css_snapshot.py` | Hashing, comparison, and the CLI |
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
./venv/bin/python -m pytest tests/test_css_computed_style_snapshot.py
|
||||
./venv/bin/python scripts/css_snapshot.py --check # same comparison, standalone
|
||||
./venv/bin/python scripts/css_snapshot.py --write-baseline # re-record
|
||||
```
|
||||
|
||||
The CLI serves the repository on an ephemeral port itself, so it does not need
|
||||
pytest. Under pytest the session static server is reused through
|
||||
`ODYSSEUS_TEST_STATIC_ORIGIN`.
|
||||
|
||||
`npm ci` is required: the capture drives Playwright's Chromium. Without it the
|
||||
browser tests skip.
|
||||
|
||||
## When the test fails
|
||||
|
||||
The failure names the elements and the variants whose hashes moved. To see
|
||||
which *property* moved, capture both sides and diff:
|
||||
|
||||
```bash
|
||||
./venv/bin/python scripts/css_snapshot.py --dump after.json
|
||||
git stash && ./venv/bin/python scripts/css_snapshot.py --dump before.json && git stash pop
|
||||
diff <(python -m json.tool before.json) <(python -m json.tool after.json)
|
||||
```
|
||||
|
||||
Re-record the baseline only when the change in rendered style is **intended**
|
||||
and reviewed. On a mechanical CSS extraction it never should be: an extraction
|
||||
that preserves order produces an identical digest, and one that does not has
|
||||
changed the UI.
|
||||
|
||||
## Determinism
|
||||
|
||||
The digest is only worth having if an unchanged stylesheet always produces the
|
||||
same bytes, so the capture:
|
||||
|
||||
- strips every `<script>` from the document, leaving exactly the markup the
|
||||
server sends - no app module can mutate classes underneath the measurement;
|
||||
- injects the theme and density classes into `<html>` *before* first paint
|
||||
rather than toggling them afterwards, so no CSS transition is ever
|
||||
mid-interpolation while `getComputedStyle` runs;
|
||||
- aborts images, fonts and media, which cost time and change nothing in the
|
||||
pinned property set;
|
||||
- hides scrollbars, so a platform's scrollbar width cannot change the width
|
||||
that percentages and `auto` resolve against;
|
||||
- pins `prefers-reduced-motion`, `forced-colors`, `prefers-color-scheme` and
|
||||
the device pixel ratio.
|
||||
|
||||
## What it does not cover
|
||||
|
||||
- **Layout geometry.** `width`, `height`, `top`/`left`/`right`/`bottom`,
|
||||
`transform` and `grid-template-*` resolve to used values that depend on text
|
||||
layout, so they are excluded rather than risk a baseline that only holds on
|
||||
one machine. A reorder that changes a size through a property in the pinned
|
||||
set is still caught; one that changes it only through an excluded property is
|
||||
not.
|
||||
- **Pseudo-class states.** `:hover`, `:focus` and `:active` are not driven.
|
||||
- **JS-applied classes.** State the app adds at runtime (collapsed sidebar,
|
||||
open panels, active tabs) is not represented beyond what the served markup
|
||||
and the bench selectors already carry.
|
||||
- **Cross-platform equality has not been measured.** The baseline was recorded
|
||||
on macOS. The self-hosted Fira Code face means text metrics should not differ
|
||||
from CI's Linux Chromium, and the layout-derived properties are excluded, but
|
||||
until a Linux run confirms it, treat a CI-only drift as a possible harness
|
||||
artifact and diff the dumps before assuming the CSS moved.
|
||||
- **The stylesheet is only one of the inputs.** `static/login.html` styles
|
||||
itself from an inline `<style>` block; it is in the inventory so the
|
||||
hand-mirrored token values there are pinned too.
|
||||
|
||||
## Adding coverage
|
||||
|
||||
Add an entry to `inventory.json` - an `{key, selector}` object under a page's
|
||||
`elements` (with `"unhide": true` if it ships hidden, `"custom": true` to
|
||||
include custom properties), or a selector string under `bench` - then
|
||||
re-record the baseline. `test_baseline_covers_every_inventory_entry` fails if
|
||||
the two go out of sync.
|
||||
@@ -0,0 +1,767 @@
|
||||
{
|
||||
"digest": "e8ef2a81ebd7a4ab",
|
||||
"elements": {
|
||||
"app-shell": {
|
||||
"app-loader": "f04ad5bf6312d53f",
|
||||
"attach-strip": "a6474951e2481214",
|
||||
"body": "58df17f0afa190b9",
|
||||
"chat-container": "b00a7c6be4e107b1",
|
||||
"chat-context-pill": "dd905b87245a9a71",
|
||||
"chat-history": "e65bdc51f4d1675f",
|
||||
"confirm-btn-primary": "c906e0be9fe73bef",
|
||||
"cookbook-modal": "918b17b2e2a311dc",
|
||||
"cookbook-modal-content": "73700bbe8be47774",
|
||||
"custom-preset-modal": "9345624780009d14",
|
||||
"custom-system-prompt": "ef839a436cd42975",
|
||||
"export-dl-btn": "d3fe28e931b66974",
|
||||
"export-dropdown-item": "d90f6f513d06a6f7",
|
||||
"export-dropdown-menu": "0793e06a6aae6a7a",
|
||||
"hamburger-btn": "34714f6eef3734d8",
|
||||
"icon-rail": "e3f72205260a4a5f",
|
||||
"icon-rail-btn": "54ffb10e056ebf44",
|
||||
"incognito-btn": "ee42bd3f6a32576b",
|
||||
"input-icon-btn": "3d1c773acd84a825",
|
||||
"memory-close-btn": "c3f4ba99098a6e4b",
|
||||
"memory-list": "da9a37a3de3c1a31",
|
||||
"memory-modal": "5efc81537eab4526",
|
||||
"memory-modal-content": "3cadc8581361436e",
|
||||
"memory-modal-header": "a8c822fe8c6d20e7",
|
||||
"memory-search-input": "8b0fbb33401750d4",
|
||||
"memory-toolbar-btn": "9f985efa7c896f90",
|
||||
"message-ghost": "ce9144a7705ee903",
|
||||
"message-textarea": "ae703a5c12022f08",
|
||||
"mobile-backdrop": "b2ed3a84835d4916",
|
||||
"mode-toggle-active": "9dfc0fe8866d86c2",
|
||||
"mode-toggle-idle": "4a221dded6bec4d6",
|
||||
"model-picker-btn": "d44aa4f424c3447e",
|
||||
"model-picker-list": "23a89bcc30d0ccee",
|
||||
"model-picker-menu": "f3320494f37c1156",
|
||||
"overflow-menu": "e40a9742168a2a8b",
|
||||
"overflow-menu-item": "1a7cad32047dc976",
|
||||
"pinned-tools-bar": "bb0f88ce29ae7637",
|
||||
"rail-new-chat": "f8f1c2f6be426ab9",
|
||||
"reasoning-effort-btn": "800d3d3edff6bf85",
|
||||
"rename-session-modal": "9345624780009d14",
|
||||
"root": "e3897afe51e821cc",
|
||||
"save-custom-preset": "443da70ef5fe9b88",
|
||||
"scroll-bottom-btn": "24602d72e2949991",
|
||||
"search-input": "53b94f3e5fdb384d",
|
||||
"search-overlay": "87d5f21c20bdde98",
|
||||
"session-list-bootstrap": "aaa28ed4edac84a9",
|
||||
"session-name-input": "c435986f102bee53",
|
||||
"settings-admin-card": "fcf3c80f20175a3c",
|
||||
"settings-btn-add": "82aeb52b9fc7de0f",
|
||||
"settings-btn-sm": "c31bcb9bdfbae10d",
|
||||
"settings-fallbacks": "e339280590935fe3",
|
||||
"settings-logs-tab": "e470140373597143",
|
||||
"settings-modal": "5efc81537eab4526",
|
||||
"settings-nav-search": "f4484b2980f2ccc5",
|
||||
"settings-row": "de568724268cec6e",
|
||||
"settings-select": "a96e8430b0ec6668",
|
||||
"settings-sidebar-toggle": "6be9025ab24bc434",
|
||||
"sidebar": "77ec42a646a8db66",
|
||||
"sidebar-brand": "d3d7ef4b21514e80",
|
||||
"sidebar-new-chat": "567fae30048b7ff3",
|
||||
"sidebar-resize-handle": "6607b819ee45409d",
|
||||
"sidebar-section": "a46f059ca4b0b4b5",
|
||||
"sidebar-section-header-btn": "eff26568c49f8b40",
|
||||
"sidebar-user-bar": "b77372dbdcb069ef",
|
||||
"theme-grid": "4d4b2aeb8952cc6f",
|
||||
"theme-io-btn": "859f222c79a8c721",
|
||||
"theme-modal": "0a65ca916656cdbb",
|
||||
"theme-popup": "3aa65e6f313bbc65",
|
||||
"theme-tabs": "0cc4987f39876dce",
|
||||
"toast": "c60c6a60d4982005",
|
||||
"tool-indicator": "0dee45eaca8b703a",
|
||||
"user-bar-avatar": "b989bbab5aab3988",
|
||||
"user-bar-settings": "2d91a65e0ebdd049",
|
||||
"welcome-screen": "a1dc93c81a7d1899",
|
||||
"welcome-sub": "89cdafbb95b7c845",
|
||||
"welcome-tip": "875045a9d2ec1055"
|
||||
},
|
||||
"bench": {
|
||||
"#agent-indicator": "09a1b8d94b1487ee",
|
||||
"#agent-indicator.active": "67e2ad3fa48acd3b",
|
||||
"#compare-model-overlay .modal-body": "0b1ee357313551b9",
|
||||
"#compare-model-overlay .modal-content": "56883147d181bd9c",
|
||||
"#cookbook-modal .cookbook-body": "20bd8a7268cee788",
|
||||
"#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card": "48f6787a3df3f19e",
|
||||
"#cookbook-modal .modal-content": "0c4e1e686df0990e",
|
||||
"#cookbook-modal .modal-content.cookbook-modal-entering": "fba9bbbb406a70c4",
|
||||
"#cookbook-modal .modal-header": "1445de059f94beb9",
|
||||
"#cookbook-server-add.cal-add-btn-text": "36d5d25f82aad559",
|
||||
"#doc-actions-footer": "a517afe6dd10dbf3",
|
||||
"#doc-actions-footer #doc-copy-export-split": "70b267bc3460ba24",
|
||||
"#doc-actions-footer #doc-footer-copy-btn": "b339e77a5e9730c1",
|
||||
"#email-lib-compose-btn": "3b21baa4a3d6bec8",
|
||||
"#email-lib-compose-btn svg": "6decb15adb02a12b",
|
||||
"#email-lib-modal .admin-card": "fb6ad83c8330d140",
|
||||
"#email-lib-modal .doclib-grid": "99515ed7d3c12dc8",
|
||||
"#email-lib-modal .email-lib-fab.fab-revealed": "9668afab75e5e45d",
|
||||
"#email-lib-modal .email-lib-sync-status": "6d1a0374a16cf7f4",
|
||||
"#email-lib-modal .email-reader-atts": "c5d68ecda3c14bb8",
|
||||
"#email-lib-modal .email-reader-header": "85afca11c8cbd38e",
|
||||
"#email-lib-modal .modal-content": "d8b8fff38c613c95",
|
||||
"#email-lib-settings-btn": "9ae15bf04b6a22ce",
|
||||
"#email-unread-dot.sidebar-notif-dot": "c80f1e03592983b6",
|
||||
"#export-compact-btn": "7d0325a38e51134b",
|
||||
"#gallery-editor-drafts-bulk": "9d8c00a765d6689e",
|
||||
"#gallery-editor-drafts-select": "694fb627310c9295",
|
||||
"#ge-history-panel": "26ec3f6b3e6d2751",
|
||||
"#hwfit-cache-select": "62ab492dd8fad73d",
|
||||
"#memory-modal .doclib-card.skill-card.doclib-card-expanded > .doclib-card-preview": "10834da1800d529b",
|
||||
"#message": "018b0ab2128d9d34",
|
||||
"#modal-dock": "bc81bafabc09440c",
|
||||
"#mode-agent-btn": "5cccc6edda975b2a",
|
||||
"#mode-chat-btn": "77af83dd6bfcd26c",
|
||||
"#recording-indicator": "e856ac42970d1a8c",
|
||||
"#recording-indicator.error": "8a1bcc599c3c237b",
|
||||
"#recording-indicator.hidden": "c8b3476b1107b2e2",
|
||||
"#research-pane": "e465bba8b1555f51",
|
||||
"#research-pane #research-past-list .research-job-card": "721dde2eb52cb7ed",
|
||||
"#research-pane #research-past-list .research-job-header": "c4d23f4b8bc102a8",
|
||||
"#research-pane #research-past-list .research-job-more": "414c2b05ad7e45e0",
|
||||
"#research-pane .research-history-card": "e9d829f0fda26f94",
|
||||
"#research-pane .research-history-toolbar-row": "33ce111a10432f3b",
|
||||
"#research-pane .research-history-toolbar-row .memory-sort-select": "6054d616490a49eb",
|
||||
"#research-pane .research-pane-body": "7e10ca158f86d408",
|
||||
"#session-select-all-dot": "d72b24787bef1c6a",
|
||||
"#session-sort-btn": "bb0f88ce29ae7637",
|
||||
"#sessions-section": "02a1d6939b9ef639",
|
||||
"#sessions-section #session-list": "5f7dfc0825eec2ee",
|
||||
"#sessions-section .chats-manage-btn": "63fe8c42ed236f21",
|
||||
"#sidebar-backdrop": "f46fd74d98873484",
|
||||
"#stop-recording": "d5489a601cfabf69",
|
||||
"#styled-confirm-overlay": "64466ea74bcf53ed",
|
||||
"#theme-popup": "f22c8010b180b42d",
|
||||
"#theme-tab-customize .color-row": "e3721ab86ea18eef",
|
||||
"#theme-tab-customize .theme-custom": "9b8fe83f16e039ee",
|
||||
"#welcome-screen": "7024d434f65886c9",
|
||||
"#welcome-screen .welcome-name": "01cffb1d086a1ab3",
|
||||
"#welcome-screen .welcome-sub": "89cdafbb95b7c845",
|
||||
"#welcome-screen .welcome-tip": "875045a9d2ec1055",
|
||||
"#welcome-screen .welcome-version": "3a850e9283976c34",
|
||||
".admin-btn-add": "8a933cbf8ac270ce",
|
||||
".admin-btn-sm": "1419bf7301e47b66",
|
||||
".admin-card": "0ae3d86c8cfeb751",
|
||||
".admin-model-form-row input": "1d820446629f79c4",
|
||||
".admin-slider": "836a69285985c958",
|
||||
".admin-switch": "876abcc0f10c2d55",
|
||||
".agent-thread": "f6198eddb1a9969f",
|
||||
".agent-thread-cmd": "54d5ece7fa9de68c",
|
||||
".agent-thread-content": "c60aadf77a2a5883",
|
||||
".agent-thread-dot": "d59e25a8c69eeac4",
|
||||
".agent-thread-header": "a336602ef4d31c1c",
|
||||
".agent-tool-diff summary": "4128de76cb424c63",
|
||||
".agent-tool-output pre": "530700f4a0699704",
|
||||
".archive-list": "a180dd2654ef8229",
|
||||
".archive-menu-btn": "9f508f063b881978",
|
||||
".ask-user-card": "f2dee20264814c84",
|
||||
".ask-user-card-attached": "e300c7fb5bc70b1f",
|
||||
".ask-user-option": "531ea450e6af3121",
|
||||
".ask-user-option-desc": "22de16b97f42ea38",
|
||||
".ask-user-option-label": "f555c9bd471d5421",
|
||||
".ask-user-other": "0b2855d42c5d7f32",
|
||||
".ask-user-other-send": "2144fc24a8bf91c2",
|
||||
".ask-user-question": "05a91b0c244960fc",
|
||||
".attach-image-preview": "b5dd397be5ad69aa",
|
||||
".attach-ocr-btn": "b0d7e3be6781d365",
|
||||
".attach-ocr-btn svg": "8b09b0b7140aae98",
|
||||
".attach-strip": "a6474951e2481214",
|
||||
".btn-spinner": "14215771d1d171fb",
|
||||
".cal-add-label": "72fd3ef86c3bbfcb",
|
||||
".cal-add-plus": "a1b0414e63b6ac40",
|
||||
".cal-day": "4ca6a688a40d4db2",
|
||||
".cal-day-num": "a4745da9f0b703bc",
|
||||
".cal-detail-header": "c81d6378318b5cb9",
|
||||
".cal-event-item": "e631fe656b59f308",
|
||||
".cal-event-row": "8dca80590c370f70",
|
||||
".cal-filter-item": "131d083db2c9c116",
|
||||
".cal-filter-toggle": "757cfe6e18bc90eb",
|
||||
".cal-filters": "0e423e393699903b",
|
||||
".cal-form-bespoke": "91129760df1ca566",
|
||||
".cal-form-mobile-cancel": "bde3b168ed685bb0",
|
||||
".cal-grid": "92c99ce3581e92c9",
|
||||
".cal-hero-time": "7474b3c587ca5d98",
|
||||
".cal-modal-container": "3ed41f999d50bd8f",
|
||||
".cal-multiday": "bc5ae2259e9db38a",
|
||||
".cal-quickadd-row": "b90bdc13d6453de7",
|
||||
".cal-splitter": "871cd8b0eae95d23",
|
||||
".cal-splitter-grip": "73e88032078aac16",
|
||||
".cal-title": "162d427104c49b46",
|
||||
".cal-toolbar": "77d67b21b75d89fc",
|
||||
".cal-toolbar-nav": "855c82a6eb672a85",
|
||||
".cal-toolbar-right": "b6059b5f1a77c077",
|
||||
".cal-wk-block": "5763c2f0c31ff3d8",
|
||||
".cal-wk-block-name": "edc7443f13567025",
|
||||
".cal-wk-block-time": "2fe325de785343a0",
|
||||
".cal-wk-wrap": "de61e4d3f59cf949",
|
||||
".cal-wk-zoom": "e5edf7c699287e58",
|
||||
".cal-year": "76d643a419e7656e",
|
||||
".cal-year-cell": "25a1642b5b698c0b",
|
||||
".chat-container": "b00a7c6be4e107b1",
|
||||
".chat-container.welcome-active .chat-input-bar": "c6510a7083e92ce8",
|
||||
".chat-history": "4dd22e235be04a73",
|
||||
".chat-input-bar": "fa08ce1f4b0cdb46",
|
||||
".chat-meta-overlay": "fc44623332db5eac",
|
||||
".chat-meta-overlay #current-meta": "1ae4281cd2a008d4",
|
||||
".chat-top-bar": "28bc79270a878362",
|
||||
".close-btn": "9c3b6334c7b25fee",
|
||||
".compare-add-flap": "bcc20ba15ee5b0ed",
|
||||
".compare-add-flap span": "b40d213f00c806d3",
|
||||
".compare-grid": "f6f69aa41d74c616",
|
||||
".compare-grid.sequential-layout .compare-pane .pane-header": "235ba192ba9c7f3e",
|
||||
".compare-mobile-tabs": "62c5cbb34f339509",
|
||||
".compare-mode-tabs": "e2177d8b26c19145",
|
||||
".compare-pane .pane-header": "07ab4c430c694f8a",
|
||||
".compare-probe-card": "06a67cf386d5bdec",
|
||||
".compare-probe-detail": "db787590d19ef4cc",
|
||||
".compare-probe-detail-message": "57eeaebb2a9b0a4f",
|
||||
".compare-probe-list": "78deccee6c00025e",
|
||||
".compare-toggle-label": "e200b7dff04bf81b",
|
||||
".confirm-btn": "a8518c6834fb7ac0",
|
||||
".cookbook-body": "1a10500fe1e306f0",
|
||||
".cookbook-btn": "40ad2e8df76f2d9c",
|
||||
".cookbook-card-desc": "f6ef9079e5e15a2d",
|
||||
".cookbook-card-title": "3df0f721555953a0",
|
||||
".cookbook-checkbox-label": "aac8b53d01d979cf",
|
||||
".cookbook-cmd-preview": "c565845df485518e",
|
||||
".cookbook-dep-build-deps": "15280be2979ad180",
|
||||
".cookbook-dep-row": "57e3a98d97f7b9c8",
|
||||
".cookbook-dep-tag": "97d68d0162f39744",
|
||||
".cookbook-dl-btn": "cc7fe1bb0c79630a",
|
||||
".cookbook-dl-repo": "50b7fc43bce8f35c",
|
||||
".cookbook-field-input": "a12fc90dcf7e9de3",
|
||||
".cookbook-field-label": "afc196d6dccbbc6f",
|
||||
".cookbook-gpu-btn": "52b45862dd394f30",
|
||||
".cookbook-gpu-btn.active": "5bff77f9adcb7448",
|
||||
".cookbook-official-filter": "6bdea60315f7d0da",
|
||||
".cookbook-output-pre": "40fc0611f762b039",
|
||||
".cookbook-run-btn": "c816359f8655bef2",
|
||||
".cookbook-saved-host": "cf6b4983fa9bebd0",
|
||||
".cookbook-saved-name": "d434036dd310ca6a",
|
||||
".cookbook-section-header .cookbook-clear-btn": "e19b1a13059aa249",
|
||||
".cookbook-section-title": "c7c5399d03a5b9c2",
|
||||
".cookbook-serve-preset-meta": "1e2786a24b30d019",
|
||||
".cookbook-serve-preset-name": "abf2efbb491f8eb9",
|
||||
".cookbook-serve-title": "dbc48ae1de4db056",
|
||||
".cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-dot": "ff38b51b211440c0",
|
||||
".cookbook-settings-hint": "cc67f7db1e73911c",
|
||||
".cookbook-settings-input": "a6e3b4a498232fcb",
|
||||
".cookbook-settings-label": "9f811c48f43831c3",
|
||||
".cookbook-settings-stack": "eb21f8352c23e9d0",
|
||||
".cookbook-slot-btn": "185943b4d681c18e",
|
||||
".cookbook-tab": "cbac98e770f1b5c7",
|
||||
".cookbook-tab-error-dot": "083ff5e013f0f752",
|
||||
".cookbook-task-check": "203f3f00e300b69a",
|
||||
".cookbook-task-header": "17633a41a71fb15c",
|
||||
".cookbook-task-menu-btn": "fbec9d85e73f99c2",
|
||||
".cookbook-task-name": "b5e36c1e6dfc2196",
|
||||
".cookbook-task-status": "832d485fa467a2c9",
|
||||
".cookbook-task-sub": "e67555ec0e2edcfb",
|
||||
".cookbook-task-wave": "8fdc66c19c2f2169",
|
||||
".cp-popover": "a2aa1ac1ca1ba392",
|
||||
".cp-sl": "3332f2879d861c24",
|
||||
".diff-chunk-btn": "31f68158a063bfc1",
|
||||
".diff-toolbar": "28e32a3239b7b603",
|
||||
".diff-toolbar-btn": "14e8f6a294cf4088",
|
||||
".doc-action-icon-btn": "392100f636cc0f05",
|
||||
".doc-docx-paper": "c6d5777d00f18064",
|
||||
".doc-docx-preview": "a25a0a2fc4de255d",
|
||||
".doc-editor-header": "253267e87c976100",
|
||||
".doc-email-collapse-btn": "417a42c5c91f9a22",
|
||||
".doc-email-header": "0cf44218df62de2a",
|
||||
".doc-email-richbody.doc-font-m": "613a4e9d172bb44d",
|
||||
".doc-language-select": "90a29013c434753c",
|
||||
".doc-line-numbers": "a7aa45b481331f5b",
|
||||
".doc-md-toolbar": "cf5013815486fa9b",
|
||||
".doc-md-toolbar button": "7790f81a78a6e4cb",
|
||||
".doc-mobile-footer": "b2ed3a84835d4916",
|
||||
".doc-outline-item": "5e404c8d680cfc69",
|
||||
".doc-outline-menu": "f29f6f5a8e53fd57",
|
||||
".doc-overflow-item": "d10da3bab3e43ce4",
|
||||
".doc-preview-hover-edit": "b78f58de2e582325",
|
||||
".doc-rich-empty-import": "7ee18d60aed92aab",
|
||||
".doc-rich-selection-toolbar": "4f6431c0ab8b4b08",
|
||||
".doc-rich-selection-toolbar .doc-rich-selection-close": "1df07aa1a7ea60b7",
|
||||
".doc-rich-selection-toolbar button": "73704ff1c15f0013",
|
||||
".doc-rich-slash-item": "d8c5cbb3e3e3e5d6",
|
||||
".doc-rich-slash-menu": "2e31f63219c116aa",
|
||||
".doc-save-button[data-save-state=\"saving\"] .doc-save-state-saving": "84ecc420b557e35d",
|
||||
".doc-selection-clear": "b0d9428152a09e39",
|
||||
".doc-suggestion-card": "b1db7167c4231257",
|
||||
".doc-suggestion-close": "8fbd4d5f6ba460b6",
|
||||
".doc-suggestion-nav-btn": "befb117a2fd3fa7d",
|
||||
".doc-tab": "8f563b5e946c20be",
|
||||
".doc-tab-bar": "0b65732476210fd4",
|
||||
".doc-tab-close": "6c1e0e8793444210",
|
||||
".doc-tab-lang": "8b0efacad728c610",
|
||||
".doc-tab-new": "def25060720b95fc",
|
||||
".doc-version-panel": "1073efb40f164f6b",
|
||||
".doclib-btn-hint": "1e7b736920f0afa7",
|
||||
".doclib-card": "6771693fee18c8b3",
|
||||
".doclib-card-action-btn": "4e2f069505c5dbb5",
|
||||
".doclib-card-expanded-actions": "33a86d25e6b02b9c",
|
||||
".doclib-card-header": "af0f97b1f3004360",
|
||||
".doclib-card-preview": "2c6e654086abaa34",
|
||||
".doclib-card.doclib-card-expanded": "1ecfff38fa7abc2c",
|
||||
".doclib-card.email-card-removing": "e379729bdba2a0eb",
|
||||
".doclib-chat-row.doclib-card-expanded": "dfd424102537f179",
|
||||
".doclib-chat-row.doclib-card-expanded .doclib-chat-preview": "dabb220508f9eae3",
|
||||
".doclib-chat-row.doclib-card-expanded .doclib-chat-preview-messages": "d9e01bfd21a07b37",
|
||||
".doclib-chip-scroll-arrow": "40757af6c9e795fb",
|
||||
".doclib-expanded-delete-btn": "bb0f88ce29ae7637",
|
||||
".doclib-grid": "34c18c37757a199f",
|
||||
".doclib-lang-chips": "6d351e1534d7b081",
|
||||
".doclib-toolbar": "dc9ec6073f2c3605",
|
||||
".dropdown": "bed9ac24c6696903",
|
||||
".dropdown-cancel-mobile": "e3b3786fda598403",
|
||||
".dropdown-divider": "5738d6943e2b555b",
|
||||
".dropdown-item": "5c8fd729a604d69a",
|
||||
".dropdown-item-compact": "984fa882578bfc56",
|
||||
".dropdown-item-compact .dropdown-icon": "b75dab733d89dbc1",
|
||||
".dropdown-item-compact .dropdown-icon svg": "b39764818e19ca4c",
|
||||
".email-attach-toggle-inline": "18470eba1fd88166",
|
||||
".email-attachment-chip.is-expanded": "833a4ff805e57c88",
|
||||
".email-attachment-open": "b2e7d5021587cf3b",
|
||||
".email-bubble": "84c884b461b7bfd6",
|
||||
".email-bubble-avatar": "642766e3dbe32509",
|
||||
".email-card-unread-dot": "33dea788cbd97824",
|
||||
".email-compose-chip .compose-chip-name": "58ca58105c731e0b",
|
||||
".email-draft-btn": "f256eb68fc8b17e3",
|
||||
".email-field": "981e2a2bef7d2401",
|
||||
".email-field #doc-email-subject": "0864bcaf3b3dffb0",
|
||||
".email-field input": "b4b0542ee0ce5b2a",
|
||||
".email-field label": "c6076082752aed3e",
|
||||
".email-filter-btn": "cc290eac8042b6f7",
|
||||
".email-filter-menu": "dc5d1dfacdd26733",
|
||||
".email-filter-picker": "9ae15bf04b6a22ce",
|
||||
".email-inline-image-actions": "891b1549d5e6a2ab",
|
||||
".email-inline-image-skeleton": "0d20c32f79dbe7f7",
|
||||
".email-odysseus-attach-list": "fc3cf3fa0fe725dd",
|
||||
".email-odysseus-attach-menu": "ca6e3ea838c14777",
|
||||
".email-quote-fold .email-fold-summary": "38395d57d7800777",
|
||||
".email-reader": "bb0f88ce29ae7637",
|
||||
".email-reader-body": "8ce048cdcb667db7",
|
||||
".email-reader-body .email-inline-image-placeholder": "deebf724fa3a2a5d",
|
||||
".email-reader-header": "066a5ffcb140bbc9",
|
||||
".email-reader-header > .email-reader-actions": "0331e25e809e0d0b",
|
||||
".email-reader-meta": "6d84db440dcf1dcf",
|
||||
".email-reader-meta .recipient-chips": "c9ceda20ba24a94b",
|
||||
".email-reader-meta > .email-reader-actions-inline": "06ecc1c60b199e44",
|
||||
".email-reader-meta-row strong": "0af50deb999967c3",
|
||||
".email-reminder-toggle-inline": "18470eba1fd88166",
|
||||
".email-search-options-menu button": "f112d02b7f4b2a3f",
|
||||
".email-search-row .memory-search-input": "f5edf94bbb3a2730",
|
||||
".email-send-btn": "8cd36039dde7b23a",
|
||||
".email-settings-auto-reply-section .email-settings-section-body": "a09c1e68706d8dc3",
|
||||
".email-settings-auto-reply-section .email-settings-section-desc": "8d8ce3cc398fc912",
|
||||
".email-settings-auto-reply-title-row": "bfc6bb8acd83b4c3",
|
||||
".email-settings-clean-btn": "398f211c2e62b7be",
|
||||
".email-settings-display-section .email-settings-section-desc": "f540b150a9ba3b16",
|
||||
".email-settings-grid": "eee5ec449a8f214e",
|
||||
".email-settings-section[open] > .email-settings-section-body": "88b6ae868ea5a387",
|
||||
".email-tags-toggle-inline": "8b5bcb3c17df370e",
|
||||
".email-undone-toggle-inline": "18470eba1fd88166",
|
||||
".export-dl-btn": "fc2a9d706e560a26",
|
||||
".export-dropdown-item": "8ec724a091aa55c8",
|
||||
".export-dropdown-item .dropdown-icon": "4e597b835e44680b",
|
||||
".export-dropdown-menu": "11a65ef24743b950",
|
||||
".gallery-album-chips": "9c85df6af61b4447",
|
||||
".gallery-albums-grid": "19900300e1bee43f",
|
||||
".gallery-chip": "3df2d7f6ea8bf569",
|
||||
".gallery-detail-body": "2ccc994e7a9fc0bc",
|
||||
".gallery-detail-image": "bb4a5fa517c1d077",
|
||||
".gallery-detail-meta-grid": "0804ee3dd5a92578",
|
||||
".gallery-detail-nav": "265fadf513a58d78",
|
||||
".gallery-detail-sidebar": "b47067b4c540d312",
|
||||
".gallery-detail-title": "3a61a73ed392c545",
|
||||
".gallery-dl-btn": "afa5e7125e483700",
|
||||
".gallery-editor-draft-delete": "501e6db6e97bd5fc",
|
||||
".gallery-editor-drafts": "e28e1aa9423d297b",
|
||||
".gallery-editor-drafts-header": "14467c19ae89fd16",
|
||||
".gallery-editor-drafts-search": "456f4e599d692064",
|
||||
".gallery-editor-drafts-title": "07556469d7e5bfcd",
|
||||
".gallery-editor-landing": "a5ae5067720f044d",
|
||||
".gallery-editor-landing-actions": "dec9a0fd207ba1bc",
|
||||
".gallery-editor-landing-actions .gallery-select-btn": "c92cc6790f517732",
|
||||
".gallery-editor-template-options": "9b8c6bae4cc423a7",
|
||||
".gallery-editor-template-picker": "0a04414510047d6f",
|
||||
".gallery-grid": "9193704237593d45",
|
||||
".gallery-search": "499b1d5db75d8637",
|
||||
".gallery-search-enter-hint": "59988183b5e2231e",
|
||||
".gallery-search-wrap": "a3a65cb89f837b1a",
|
||||
".gallery-select-btn": "6062fab15408c8d2",
|
||||
".gallery-tab": "856ba5e8322b2ac3",
|
||||
".gallery-tabs": "6e77ea3e6cf981c9",
|
||||
".gallery-toolbar": "cbee92b65715f862",
|
||||
".gallery-toolbar-break": "b2ed3a84835d4916",
|
||||
".ge-adj-icon": "405c77944c2f21c7",
|
||||
".ge-adj-popup": "e1a93233ab11f043",
|
||||
".ge-adj-row": "fed828d535ba710b",
|
||||
".ge-adj-row > label": "21bd09c7cf6167e5",
|
||||
".ge-adj-row input[type=\"range\"]": "3f1690aad291a7da",
|
||||
".ge-adj-title": "7609b8e4f862ad23",
|
||||
".ge-ai-command-collapsed": "35a892b0246538ba",
|
||||
".ge-alpha-badge": "59eb1bb7eddf6df0",
|
||||
".ge-canvas-area": "b6a7426fcd6ad230",
|
||||
".ge-canvas-prompt": "97ddee153cdaf1f2",
|
||||
".ge-canvas-prompt-footer": "153de6a15c89d60d",
|
||||
".ge-clone-hint-mobile": "fde6e603285602e2",
|
||||
".ge-controls": "004ef864b98d552f",
|
||||
".ge-edge-menu": "6734a5a4c658fb59",
|
||||
".ge-editor-body": "96e859c8c9bfe4f9",
|
||||
".ge-export-body": "9746be0da055b9cf",
|
||||
".ge-export-dialog": "07273df75fec40a5",
|
||||
".ge-export-overlay": "71118be46a543136",
|
||||
".ge-export-preview": "d15f3deb0fea3bca",
|
||||
".ge-export-preview-wrap": "022e7646bf83adb1",
|
||||
".ge-fx-menu": "34ba0908e3d27a7d",
|
||||
".ge-gradient-color-row": "ae1866697d4fb492",
|
||||
".ge-inpaint-popup": "375180aa2e257450",
|
||||
".ge-inpaint-popup-input": "82d3fa4cb683d69e",
|
||||
".ge-layer-action-flash": "3adf53342ca69b3f",
|
||||
".ge-layer-drag": "bec7d2f34c0e079b",
|
||||
".ge-layer-geometry-grid": "d478f4137955e85a",
|
||||
".ge-layer-group-row .ge-group-inline-thumb": "bb0f88ce29ae7637",
|
||||
".ge-layer-group-row .ge-layer-controls": "da1a0cafc90aff7f",
|
||||
".ge-layer-group-row .ge-layer-group-drag": "33cf63103dbea379",
|
||||
".ge-layer-group-row .ge-layer-group-name": "48642695eec50f01",
|
||||
".ge-layer-group-row .ge-layer-group-toggle": "cb04950c0a50aacb",
|
||||
".ge-layer-group-row .ge-layer-lock-btn": "bb0f88ce29ae7637",
|
||||
".ge-layer-group-row .ge-layer-opacity-row": "e697b236b3b4ba6e",
|
||||
".ge-layer-group-row .ge-layer-vis": "2e6dbbab8d823b4f",
|
||||
".ge-layer-lock-menu": "528deb6e31022e89",
|
||||
".ge-layer-lock-option": "38a3da4a8785330a",
|
||||
".ge-layers-grab": "094e764489b1b069",
|
||||
".ge-layers-header": "03ee9ca6c3fc912b",
|
||||
".ge-layers-list": "18de5e01e079682f",
|
||||
".ge-main-canvas": "274650d62945ac12",
|
||||
".ge-mask-sub-item": "546f03f9c46eb507",
|
||||
".ge-right-panel": "3d0aed961f2df331",
|
||||
".ge-section-help": "8bd5ecd734f48ca2",
|
||||
".ge-shortcuts-grid": "84d3ab6453dab9b3",
|
||||
".ge-slider-bubble": "2bc54e40357f68cf",
|
||||
".ge-tool-btn": "fd54cca726e51429",
|
||||
".ge-tool-sep": "4ae61e4e4eddd504",
|
||||
".ge-toolbar": "ca827cf012ad8863",
|
||||
".ge-topbar": "6c39a6ade412d5fa",
|
||||
".ge-transform-field": "76a785f630632652",
|
||||
".ge-transform-field > input.ge-transform-popup-input": "b870e4e9d5fc2d91",
|
||||
".ge-transform-popup-body": "d9d70292e5be7acc",
|
||||
".ge-transform-popup-head .ge-adj-icon": "88ac29d44343e6fa",
|
||||
".ge-transform-popup-head .ge-adj-title": "1d6ca66f2c39c9ef",
|
||||
".ge-transform-popup-head .ge-head-btns": "ecd0a09cb27407d7",
|
||||
".ge-transform-popup-hint": "402aac74aea3c779",
|
||||
".ge-transform-popup-suffix": "d55491303f919f61",
|
||||
".ge-transform-quick-btn": "e74ea16e29b9b001",
|
||||
".ge-transform-spin": "22dc5f2bfab2ec9a",
|
||||
".ge-transform-spin button": "527810c9f1ac1be5",
|
||||
".ge-transform-spin button[data-spin=\"down\"]": "c0aff9fcdb755ed2",
|
||||
".ge-transform-spin button[data-spin=\"up\"]": "4fccf8fbe6c04fa9",
|
||||
".hamburger": "2b0e4a6cb3333ded",
|
||||
".hamburger span": "6e2fee4770672a20",
|
||||
".hamburger-btn": "dc0f91fb82ccd472",
|
||||
".hljs": "6ecc4f2f88eac570",
|
||||
".hwfit-cached-item .hwfit-serve-panel": "14fffc9c4952ddd5",
|
||||
".hwfit-cached-menu-btn svg": "f7ad8ff47d5e5a9e",
|
||||
".hwfit-context-calc-btn": "30ab84fd32699c1a",
|
||||
".hwfit-context-control": "31f335a5f5f7093b",
|
||||
".hwfit-context-control .hwfit-sf[data-field=\"ctx\"]": "c4367757132248ae",
|
||||
".hwfit-ctx-control": "d175d7225fe97089",
|
||||
".hwfit-ctx-control input[type=\"range\"]": "947b1970077d4f4a",
|
||||
".hwfit-ctx-control output": "9a1f476037c00dc2",
|
||||
".hwfit-ctx-control span": "66e420440d60beee",
|
||||
".hwfit-help-chip": "d51bfab71de7b80e",
|
||||
".hwfit-hw-chip-toggle": "e0e58ca582f813cd",
|
||||
".hwfit-numstep": "0ec0a0c4db53c2cd",
|
||||
".hwfit-panel-fields": "4e6d730d4a665d2e",
|
||||
".hwfit-panel-fields input[type=\"text\"]": "caeeb82204c15e3e",
|
||||
".hwfit-preset-model": "f86026efe0d3498c",
|
||||
".hwfit-sched-day-chip": "0a2f0ddb74896f9f",
|
||||
".hwfit-schedule-row": "ee03e04722554137",
|
||||
".hwfit-serve-cmd-details[open] > .hwfit-serve-cmd-summary": "4952b4e93e125c16",
|
||||
".hwfit-serve-preset-row": "c6c4d6a2f3c3e6c3",
|
||||
".hwfit-serve-row .cookbook-serve-slots": "a7be413a7490cc01",
|
||||
".hwfit-serve-row-core .hwfit-context-label": "a0b969fbae15de72",
|
||||
".hwfit-serve-topline": "d9feede09cc16f87",
|
||||
".hwfit-serve-topline .hwfit-serve-runtime-note": "15b3fef4e5ceaffc",
|
||||
".hwfit-serve-topline .hwfit-serve-runtime-text": "57c6822b0d13147d",
|
||||
".hwfit-spec-group .hwfit-spec-method": "5d1c329116272cf1",
|
||||
".icon-rail": "e3f72205260a4a5f",
|
||||
".icon-rail-btn": "a5f207c3be15401e",
|
||||
".incognito-btn": "9833a2e86fab6a94",
|
||||
".incognito-indicator": "b3945c46844c7278",
|
||||
".input-icon-btn": "2e073a8e266f201e",
|
||||
".item-drag-handle": "5f889f0059232495",
|
||||
".list-item": "0653eb1e34c6b7d1",
|
||||
".list-item .hamburger": "2092eaea70b49de6",
|
||||
".loading-dot": "686e1bb53f792ac5",
|
||||
".loading-dots": "997b347b61484fc0",
|
||||
".loading-indicator": "eb9f3c301fbb4976",
|
||||
".md-scroll-arrow svg": "6f1ae7652f2dd51a",
|
||||
".md-toolbar-items #doc-pdf-add-text-btn": "950a49c2ef935d9e",
|
||||
".md-toolbar-sep": "146c50b38436d02a",
|
||||
".md-view-toggle": "b092a4e770904171",
|
||||
".md-view-toggle .md-view-opt": "3192fcae7d1f0ab6",
|
||||
".md-view-toggle .md-view-opt.active": "3ee0bf92dfab3072",
|
||||
".memory-item": "85cccfa273eae409",
|
||||
".memory-item-actions": "8642d181c8fe3c2a",
|
||||
".memory-item-actions .memory-item-btn": "7a9214669a7cc7b6",
|
||||
".memory-item-title": "d50a89fe377bbe0d",
|
||||
".memory-menu-btn": "d7378a97a127f815",
|
||||
".memory-tab-panel[data-memory-panel=\"skills\"]": "f18376de38243808",
|
||||
".memory-toolbar": "9285742f00c21d49",
|
||||
".memory-toolbar-btn": "a67a54d4043e34ab",
|
||||
".minimize-btn": "d22769aec9c30099",
|
||||
".minimized-dock-chip": "af70d8f0eb594924",
|
||||
".minimized-dock-chip svg": "5f94d9cfc3935c34",
|
||||
".mobile-new-chat-btn": "8505c445195e7b15",
|
||||
".modal": "9345624780009d14",
|
||||
".modal-content": "e5b005cecef5d140",
|
||||
".modal-content.modal-closing": "8bdccec5cd43b27b",
|
||||
".modal-header": "2c8f003ed4b092ad",
|
||||
".mode-toggle": "43526722c32158c5",
|
||||
".mode-toggle-btn": "08df82025829cf7b",
|
||||
".mode-toggle.mode-toggle-three [data-llama-mode=\"unified\"] > span": "adf60064ba19f5de",
|
||||
".model-picker-list .model-switch-item": "9077ef9414c73080",
|
||||
".model-picker-list .mp-fav-dot": "cde6ff8162ad99db",
|
||||
".models-row": "d882bd9517efbca7",
|
||||
".msg": "e874630644527764",
|
||||
".msg .body": "454c7e06516e1201",
|
||||
".msg .role": "2aa3fc47cc98be04",
|
||||
".msg-action-btn[data-action=\"shorten\"]": "39875219ab81b49e",
|
||||
".msg-actions": "015e01ce5213f013",
|
||||
".msg-ai": "ca278b97f3b29560",
|
||||
".msg-user": "3964d2a2b7460aa6",
|
||||
".note-card": "1f346ea173c883c6",
|
||||
".note-card-colors": "329743d4521bb459",
|
||||
".note-card-copy-corner": "7759db4e507df9aa",
|
||||
".note-card-corner-copy": "0874439a31308004",
|
||||
".note-card-corner-copy svg": "c514aac06948c53e",
|
||||
".note-card-done": "c1c08dfe20a2319f",
|
||||
".note-card-edit-corner": "28358acd0d495d56",
|
||||
".note-card-image": "2d43d9998c0054e2",
|
||||
".note-check-text": "bf79d1927ab1354c",
|
||||
".note-color-blue": "a639c83faed486a5",
|
||||
".note-color-green": "2ec9b93d7e537274",
|
||||
".note-color-orange": "d6b28667f64299ed",
|
||||
".note-color-purple": "09413154ae0539b7",
|
||||
".note-color-red": "3578124008036b4c",
|
||||
".note-color-yellow": "0375be5e8a17c052",
|
||||
".note-form-actions-group": "01325dedac4f2135",
|
||||
".note-form-header": "981e2a2bef7d2401",
|
||||
".note-form-meta .note-form-label": "4523cb29977907ee",
|
||||
".note-form-remind-btn": "3d8e742995aab275",
|
||||
".note-form-type-pill": "c2e1cdc092ce35d2",
|
||||
".note-fullscreen-overlay .note-cl-add-row .note-cl-add": "6c5a9e646b44c5ad",
|
||||
".notes-header-btn-label": "6756fa87daa244e9",
|
||||
".notes-header-text-btn": "0bc6208299bf6f01",
|
||||
".notes-pane-title": "66bcc3f992df720f",
|
||||
".notes-pane.notes-view-grid .note-card": "a327d50c6b0bf852",
|
||||
".notes-pane.notes-view-grid .note-form": "f393ab7b10692ea2",
|
||||
".notes-pane.notes-view-grid .notes-labels-bar": "71aff777c51126c6",
|
||||
".notes-pane.notes-view-grid .notes-pane-body": "65a4c9d6644c1c37",
|
||||
".notes-pane.notes-view-grid .notes-quick-add": "87b5d77fec70f8bd",
|
||||
".notes-quick-add": "b56d9585019420b5",
|
||||
".notes-quick-icon": "83f7f73487cc23da",
|
||||
".notes-quick-input": "8760b2b8588274fd",
|
||||
".notes-quick-type-pill": "55c9f6527ebc8b3d",
|
||||
".notes-quick-type-pill svg": "a73e69725b9d97b6",
|
||||
".notes-quick-type-seg": "de7a4f92c908db4e",
|
||||
".pdf-export-overlay .modal-content": "3b5f35912509daf2",
|
||||
".preset-btn": "11527aa662becc84",
|
||||
".preset-btn.active": "2fc1631701e917c2",
|
||||
".preset-range": "6c05bf7f8a880758",
|
||||
".private-browser-preview-frame img": "b1866aa1b67e4626",
|
||||
".reasoning-effort-prefix": "51c4c138641d61e2",
|
||||
".recipient-chip": "f88380e8f4c09cc5",
|
||||
".recipient-chips": "83fad85374d3c475",
|
||||
".recording-content": "d5e0c053ceaf4f29",
|
||||
".recording-error": "5a2f9e8095e1b45e",
|
||||
".recording-icon": "cb60d94934ee919a",
|
||||
".recording-text": "58e99b27946bafd6",
|
||||
".red-text": "a93f1ed45d8646c0",
|
||||
".research-badge": "fcf661967461fc70",
|
||||
".research-cat": "a523b00cdffd4546",
|
||||
".research-category-row": "09701efa1598810c",
|
||||
".research-job-action[data-action=\"delete\"]": "d1bf97c182a9a746",
|
||||
".research-job-card": "c71a3e1a36a8c265",
|
||||
".research-new-job h2": "5e67de78656d65dc",
|
||||
".research-sb-dot": "3fd3b71cb6c521ce",
|
||||
".research-start-btn": "89df2d121b24f52b",
|
||||
".research-synapse .rs-meta": "59320709dd4454db",
|
||||
".research-synapse .rs-meta .rs-status": "2e1a6f4a3fda05dc",
|
||||
".scroll-nav-btn": "436360e8d744c897",
|
||||
".search-result-snippet": "fac8292c8f391904",
|
||||
".section": "37fc733d59519a08",
|
||||
".section-header-btn": "5d949e304c239db4",
|
||||
".section-header-flex": "bfcb656a86ca5a6a",
|
||||
".send-btn": "b29fc333cc66ed9d",
|
||||
".send-btn.anim-launch svg": "25d7e30ad49d3374",
|
||||
".session-bulk-bar": "a2833fd7ce35e639",
|
||||
".session-bulk-btn": "827a6bb9524080c9",
|
||||
".session-loading-state": "95adb7c1b1e67832",
|
||||
".session-menu-btn": "43014a6ecb68472a",
|
||||
".session-skeleton-bubble": "23878c71363d5db7",
|
||||
".session-skeleton-bubble.is-user": "b0e090855a4865be",
|
||||
".session-skeleton-line": "05960e43aaa5fa1c",
|
||||
".settings-layout": "9e37e5723ec05246",
|
||||
".settings-nav-item": "1db63b881ad2eba4",
|
||||
".settings-panels": "728f16c852c993b6",
|
||||
".settings-row": "1a84bec9dc21c226",
|
||||
".settings-select": "13a40c3922ce8015",
|
||||
".settings-sidebar": "2b39df3565e83790",
|
||||
".settings-sidebar-content": "88544f44fdb2ca73",
|
||||
".settings-sidebar-divider": "cdadb5f574b44236",
|
||||
".settings-sidebar.settings-sidebar-collapsed": "d9d8f25df95d9ad8",
|
||||
".settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content": "608e51170864586a",
|
||||
".settings-system-logs-tab": "d0d90a13ff327ace",
|
||||
".sidebar": "77ec42a646a8db66",
|
||||
".sidebar .section": "37fc733d59519a08",
|
||||
".sidebar-brand-title": "2b20f75768b50346",
|
||||
".sidebar-header": "0d58816ca65b7216",
|
||||
".sidebar-inner": "ad5e28fedbfeb5db",
|
||||
".sidebar-notif-dot": "2673e215d0cd84a6",
|
||||
".sidebar-user-bar": "b77372dbdcb069ef",
|
||||
".sidebar.hidden": "0df301b638e2f93f",
|
||||
".sidebar.right-side": "2b6bcc7d59f7b6bd",
|
||||
".skill-card > .skill-card-header": "e2408e762818d2fb",
|
||||
".skill-card-tags": "a327c85e053e6d3f",
|
||||
".skill-card-textcol": "f1059ed37c120b60",
|
||||
".skill-card.doclib-card-expanded": "b7f5d0edd75b1422",
|
||||
".skill-kebab-btn": "5cd557d02656d079",
|
||||
".spinner": "e403b24f35c58e53",
|
||||
".stop-recording-btn": "d5489a601cfabf69",
|
||||
".styled-confirm-box": "90a27b200f7179c1",
|
||||
".styled-confirm-box .modal-body p": "39733c5420c1521b",
|
||||
".styled-confirm-box .modal-footer": "0d1b29f6f7487848",
|
||||
".styled-prompt-box": "1f3925c0236d652e",
|
||||
".styled-prompt-input": "a4e24959b76553cf",
|
||||
".task-card .memory-item-actions": "33e67907b02e543c",
|
||||
".task-log-name": "f29203861c94fb42",
|
||||
".theme-grid": "0f53a4921d5a478c",
|
||||
".thumb button": "323943969cfbb194",
|
||||
".thumb-collapsed": "c967b682b6c604e4",
|
||||
".thumb.thumb-image .thumb-img": "4a4da275eee6cc70",
|
||||
".toast": "c60c6a60d4982005",
|
||||
".toast-action-hint": "13c8621842a92195",
|
||||
"a": "bb0f88ce29ae7637",
|
||||
"body": "a402cd1690151695",
|
||||
"body.notes-drag-mode .note-card": "38ee1aee83dccef6",
|
||||
"body.notes-view .chat-container": "8ba89017b94cf407",
|
||||
"button.cal-add-btn.cal-add-btn-text": "7e6817fca9a759dc",
|
||||
"button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm": "d5d12fbae3d58592",
|
||||
"button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm .cal-add-label": "9b674e66a8ae6380",
|
||||
"button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm .cal-add-plus": "69dca313be48c31e",
|
||||
"button.cal-nav.cal-toolbar-arrow": "7c252b6e2024fdb4",
|
||||
"code": "0b8a26334c2cdc21",
|
||||
"details": "1979b4f98bd52f98",
|
||||
"details a": "5a744551046b2383",
|
||||
"h2": "8a4d59fbdab8c1ad",
|
||||
"pre": "a5f2d86c696dc650",
|
||||
"pre .copy-code": "a9489a5797fcf296",
|
||||
"pre .edit-code": "a9489a5797fcf296",
|
||||
"pre .run-code": "a9489a5797fcf296",
|
||||
"table": "fc1e26a096450665",
|
||||
"td": "b782f3027093fc82",
|
||||
"th": "fd81ff4190b2f023"
|
||||
},
|
||||
"login": {
|
||||
"body": "64975bdadce9f6e5",
|
||||
"card": "5139f16a929fa893",
|
||||
"error": "3eb05963b0d792b6",
|
||||
"logo": "15751ee911f586da",
|
||||
"password": "42f0c35485f59dea",
|
||||
"pw-toggle": "9e5f0475eb7b6c57",
|
||||
"remember-dot": "62b8fa66ceaf590e",
|
||||
"remember-toggle": "87bed84dfe9061e8",
|
||||
"root": "07533d1e7958a57a",
|
||||
"setup-note": "028127c742bf5f87",
|
||||
"submit": "5e371dbe68196bfd",
|
||||
"toggle-link": "289ae7be0ef6e564",
|
||||
"username": "a8d417a9a08b204c",
|
||||
"version-label": "cc443cc7790ff3b4"
|
||||
}
|
||||
},
|
||||
"variants": {
|
||||
"app-shell": {
|
||||
"desktop-dark-comfortable": "21afac735b96f31e",
|
||||
"desktop-dark-compact": "c1690c3291f79cef",
|
||||
"desktop-dark-spacious": "7dbd8d97ad28a540",
|
||||
"desktop-light-comfortable": "9e88a1c7a8dfc1d2",
|
||||
"desktop-light-compact": "4c882ebf427c8ff7",
|
||||
"desktop-light-spacious": "f74cff517a61dad9",
|
||||
"laptop-dark-comfortable": "95c85f14bfde85e3",
|
||||
"laptop-dark-compact": "d7c8522e5f70cc07",
|
||||
"laptop-dark-spacious": "b7245ea652815c27",
|
||||
"laptop-light-comfortable": "b34f4282a5ef51b7",
|
||||
"laptop-light-compact": "38887a9d17175a65",
|
||||
"laptop-light-spacious": "8f3ad89ae046dfe9",
|
||||
"phone-dark-comfortable": "1a8c2a51ed18c0cc",
|
||||
"phone-dark-compact": "2a11e3b08826edef",
|
||||
"phone-dark-spacious": "1a8c2a51ed18c0cc",
|
||||
"phone-light-comfortable": "5b0af830cb78676b",
|
||||
"phone-light-compact": "3ffe9d863f213a49",
|
||||
"phone-light-spacious": "5b0af830cb78676b",
|
||||
"tablet-dark-comfortable": "f2ddb9a92ac7c0f6",
|
||||
"tablet-dark-compact": "8ef6c7958cabb3ad",
|
||||
"tablet-dark-spacious": "f2ddb9a92ac7c0f6",
|
||||
"tablet-light-comfortable": "2c846ae8e56f881b",
|
||||
"tablet-light-compact": "f4bfea7dbcd7df5f",
|
||||
"tablet-light-spacious": "2c846ae8e56f881b"
|
||||
},
|
||||
"bench": {
|
||||
"desktop-dark-comfortable": "2471a82979dfd8d8",
|
||||
"desktop-dark-compact": "b2807c0c54345c3e",
|
||||
"desktop-dark-spacious": "41a6d588192a02ed",
|
||||
"desktop-light-comfortable": "631778e05aca46ef",
|
||||
"desktop-light-compact": "340c1bbce11d0b32",
|
||||
"desktop-light-spacious": "e21051317e9d346d",
|
||||
"laptop-dark-comfortable": "4c931615f7151fc3",
|
||||
"laptop-dark-compact": "181a9bb9f0add9a0",
|
||||
"laptop-dark-spacious": "ad24cee14e456d04",
|
||||
"laptop-light-comfortable": "d635b3b801ff803d",
|
||||
"laptop-light-compact": "dbe6446fad9cc2f0",
|
||||
"laptop-light-spacious": "c81a4f935d0270a3",
|
||||
"phone-dark-comfortable": "ae0aa5695982a188",
|
||||
"phone-dark-compact": "8ca1949b9817e3c4",
|
||||
"phone-dark-spacious": "912fe8e2d490162f",
|
||||
"phone-light-comfortable": "ccd790243858a150",
|
||||
"phone-light-compact": "2b008b092bec6fb1",
|
||||
"phone-light-spacious": "c2465ac50f71a035",
|
||||
"tablet-dark-comfortable": "1d96addb759bc3e3",
|
||||
"tablet-dark-compact": "53294ba127a61959",
|
||||
"tablet-dark-spacious": "c26663bc163aa446",
|
||||
"tablet-light-comfortable": "b66904a1f69417b7",
|
||||
"tablet-light-compact": "9f0836a37f087c2e",
|
||||
"tablet-light-spacious": "645c09fa0e1ddb59"
|
||||
},
|
||||
"login": {
|
||||
"desktop-dark-comfortable": "d3d0512f5223397e",
|
||||
"desktop-dark-compact": "d3d0512f5223397e",
|
||||
"desktop-dark-spacious": "d3d0512f5223397e",
|
||||
"desktop-light-comfortable": "d3d0512f5223397e",
|
||||
"desktop-light-compact": "d3d0512f5223397e",
|
||||
"desktop-light-spacious": "d3d0512f5223397e",
|
||||
"laptop-dark-comfortable": "d614fc150b017e6e",
|
||||
"laptop-dark-compact": "d614fc150b017e6e",
|
||||
"laptop-dark-spacious": "d614fc150b017e6e",
|
||||
"laptop-light-comfortable": "d614fc150b017e6e",
|
||||
"laptop-light-compact": "d614fc150b017e6e",
|
||||
"laptop-light-spacious": "d614fc150b017e6e",
|
||||
"phone-dark-comfortable": "c7f59e5d8c979f02",
|
||||
"phone-dark-compact": "c7f59e5d8c979f02",
|
||||
"phone-dark-spacious": "c7f59e5d8c979f02",
|
||||
"phone-light-comfortable": "c7f59e5d8c979f02",
|
||||
"phone-light-compact": "c7f59e5d8c979f02",
|
||||
"phone-light-spacious": "c7f59e5d8c979f02",
|
||||
"tablet-dark-comfortable": "ee0918e00caa6875",
|
||||
"tablet-dark-compact": "ee0918e00caa6875",
|
||||
"tablet-dark-spacious": "ee0918e00caa6875",
|
||||
"tablet-light-comfortable": "ee0918e00caa6875",
|
||||
"tablet-light-compact": "ee0918e00caa6875",
|
||||
"tablet-light-spacious": "ee0918e00caa6875"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>CSS snapshot bench</title>
|
||||
<!-- Replaced at capture time by whatever <link rel="stylesheet"> tags
|
||||
static/index.html ships, in shell order, so the bench keeps measuring
|
||||
the real stylesheet set after style.css is split. This fallback is only
|
||||
for opening the page by hand; it is deliberately unversioned so the
|
||||
cache-bust string has one home (tests/test_static_stylesheet_manifest.py
|
||||
pins the shipped ones). -->
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Intentionally empty. tests/css_snapshot/capture.mjs mounts one subtree
|
||||
per bench selector from tests/css_snapshot/inventory.json, measures the
|
||||
leaf, and removes it again - so adding coverage is a one-line inventory
|
||||
change and this file never drifts from the selector list. -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,266 @@
|
||||
// Computed-style capture for the CSS snapshot harness.
|
||||
//
|
||||
// Reads a capture job as JSON on stdin, drives headless Chromium over the
|
||||
// loopback static server, and writes the raw computed values as JSON on
|
||||
// stdout. Hashing, comparison and baseline storage live on the Python side
|
||||
// (scripts/css_snapshot.py) so there is exactly one canonicalisation.
|
||||
//
|
||||
// Determinism rules that matter here, because the digest is only useful if an
|
||||
// unchanged stylesheet always produces the same bytes:
|
||||
// - every <script> is stripped from the document, so the DOM stays exactly
|
||||
// what the server sends and no app module can mutate classes underneath us;
|
||||
// - the theme/density classes are injected into the <html> tag *before* the
|
||||
// first paint instead of toggled afterwards, so no CSS transition is ever
|
||||
// mid-interpolation while getComputedStyle runs;
|
||||
// - images, fonts and media are aborted: they cost time and change nothing
|
||||
// in the pinned property set;
|
||||
// - scrollbars are hidden, so a platform's scrollbar width cannot change the
|
||||
// width that percentage and auto values resolve against.
|
||||
|
||||
import process from 'node:process';
|
||||
|
||||
function readStdin() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let raw = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => { raw += chunk; });
|
||||
process.stdin.on('end', () => resolve(raw));
|
||||
process.stdin.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Swap the first two blocks that declare `selector` at the same nesting level.
|
||||
// Used by the harness self-test: if reordering two conflicting declarations of
|
||||
// the same selector does not move the digest, the harness is not watching
|
||||
// anything worth watching.
|
||||
function swapRuleOccurrences(css, selector) {
|
||||
const blocks = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < css.length; i += 1) {
|
||||
const ch = css[i];
|
||||
if (ch === '{') {
|
||||
if (depth === 0) {
|
||||
const sel = css.slice(start, i).trim().replace(/\s+/g, ' ');
|
||||
blocks.push({ selector: sel, start, bodyStart: i });
|
||||
}
|
||||
depth += 1;
|
||||
} else if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
blocks[blocks.length - 1].end = i + 1;
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const matches = blocks.filter(b => b.selector === selector && b.end !== undefined);
|
||||
if (matches.length < 2) {
|
||||
throw new Error(`swap-rule: need two top-level blocks for "${selector}", found ${matches.length}`);
|
||||
}
|
||||
const [a, b] = matches;
|
||||
const textA = css.slice(a.start, a.end);
|
||||
const textB = css.slice(b.start, b.end);
|
||||
return css.slice(0, a.start) + textB + css.slice(a.end, b.start) + textA + css.slice(b.end);
|
||||
}
|
||||
|
||||
// The whole measurement runs inside one page function: Playwright serialises
|
||||
// the function source, so anything it calls has to be declared inside it.
|
||||
//
|
||||
// buildBenchNode is a minimal selector-to-DOM builder for the bench page. It
|
||||
// supports descendant and child combinators over compound selectors made of a
|
||||
// tag, an id, classes and [attr=value] pairs - which is what the
|
||||
// high-redeclaration selectors in style.css are made of. Anything else is
|
||||
// reported as missing rather than silently benched as the wrong element.
|
||||
function pageMeasure(job) {
|
||||
function buildBenchNode(selector) {
|
||||
const parts = selector.split(/\s*>\s*|\s+/).filter(Boolean);
|
||||
let root = null;
|
||||
let parent = null;
|
||||
let leaf = null;
|
||||
for (const part of parts) {
|
||||
const m = part.match(/^([a-zA-Z][\w-]*)?((?:[#.][\w-]+|\[[^\]]+\])*)$/);
|
||||
if (!m || (!m[1] && !m[2])) throw new Error('unsupported bench selector: ' + selector);
|
||||
const el = document.createElement(m[1] || 'div');
|
||||
const tokens = (m[2] || '').match(/[#.][\w-]+|\[[^\]]+\]/g) || [];
|
||||
for (const token of tokens) {
|
||||
if (token[0] === '#') el.id = token.slice(1);
|
||||
else if (token[0] === '.') el.classList.add(token.slice(1));
|
||||
else {
|
||||
const attr = token.slice(1, -1);
|
||||
const eq = attr.indexOf('=');
|
||||
if (eq === -1) el.setAttribute(attr, '');
|
||||
else el.setAttribute(attr.slice(0, eq), attr.slice(eq + 1).replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
}
|
||||
if (parent) parent.appendChild(el); else root = el;
|
||||
parent = el;
|
||||
leaf = el;
|
||||
}
|
||||
if (!leaf) throw new Error('empty bench selector');
|
||||
return { root, leaf };
|
||||
}
|
||||
|
||||
function readStyle(el, pseudo, properties, wantCustom) {
|
||||
const cs = getComputedStyle(el, pseudo || undefined);
|
||||
const values = {};
|
||||
for (const prop of properties) values[prop] = cs.getPropertyValue(prop);
|
||||
if (wantCustom) {
|
||||
const names = [];
|
||||
for (let i = 0; i < cs.length; i += 1) {
|
||||
const name = cs.item(i);
|
||||
if (name.startsWith('--')) names.push(name);
|
||||
}
|
||||
names.sort();
|
||||
for (const name of names) values[name] = cs.getPropertyValue(name).trim();
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
// Modals and menus ship hidden in the served markup. Revealing one element
|
||||
// at a time - and putting the class back straight after - keeps each
|
||||
// measurement independent of the others.
|
||||
function reveal(el) {
|
||||
const undo = [];
|
||||
let node = el;
|
||||
while (node && node !== document.documentElement) {
|
||||
if (node.classList && node.classList.contains('hidden')) {
|
||||
const target = node;
|
||||
target.classList.remove('hidden');
|
||||
undo.push(() => target.classList.add('hidden'));
|
||||
}
|
||||
if (node.hasAttribute && node.hasAttribute('hidden')) {
|
||||
const target = node;
|
||||
target.removeAttribute('hidden');
|
||||
undo.push(() => target.setAttribute('hidden', ''));
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return () => { for (const fn of undo.reverse()) fn(); };
|
||||
}
|
||||
|
||||
const measured = {};
|
||||
const missing = [];
|
||||
|
||||
for (const entry of (job.elements || [])) {
|
||||
const el = document.querySelector(entry.selector);
|
||||
if (!el) { missing.push(entry.key); continue; }
|
||||
const restore = entry.unhide ? reveal(el) : null;
|
||||
// Reading a layout property forces the style and layout pass before the
|
||||
// computed values are read back.
|
||||
void document.body.offsetHeight;
|
||||
measured[entry.key] = readStyle(el, entry.pseudo, job.properties, !!entry.custom);
|
||||
if (restore) restore();
|
||||
}
|
||||
|
||||
for (const selector of (job.bench || [])) {
|
||||
let built;
|
||||
try {
|
||||
built = buildBenchNode(selector);
|
||||
} catch (err) {
|
||||
missing.push(selector);
|
||||
continue;
|
||||
}
|
||||
document.body.appendChild(built.root);
|
||||
void document.body.offsetHeight;
|
||||
measured[selector] = readStyle(built.leaf, null, job.properties, false);
|
||||
built.root.remove();
|
||||
}
|
||||
|
||||
return { measured, missing };
|
||||
}
|
||||
|
||||
// The bench page must load whatever the app shell loads. Once style.css is
|
||||
// split, the shell will pull in several ordered stylesheets and a bench that
|
||||
// kept linking style.css alone would measure a stylesheet the app no longer
|
||||
// serves on its own - and report the extraction as clean when it was not.
|
||||
function stylesheetLinks(html) {
|
||||
const links = html.match(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi) || [];
|
||||
return links.join('\n ');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const job = JSON.parse(await readStdin());
|
||||
const { chromium } = await import('playwright');
|
||||
const browser = await chromium.launch({ headless: true, args: ['--hide-scrollbars'] });
|
||||
const snapshot = {};
|
||||
const missing = {};
|
||||
|
||||
try {
|
||||
for (const page of job.pages) {
|
||||
snapshot[page.name] = {};
|
||||
let shippedStylesheets = null;
|
||||
if (page.stylesheetsFrom) {
|
||||
const source = await fetch(job.origin + page.stylesheetsFrom);
|
||||
if (!source.ok) throw new Error(`${page.stylesheetsFrom} returned ${source.status}`);
|
||||
shippedStylesheets = stylesheetLinks(await source.text());
|
||||
if (!shippedStylesheets) throw new Error(`no stylesheet links found in ${page.stylesheetsFrom}`);
|
||||
}
|
||||
for (const variant of job.variants) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: variant.width, height: variant.height },
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: variant.colorScheme || 'dark',
|
||||
reducedMotion: 'no-preference',
|
||||
forcedColors: 'none',
|
||||
hasTouch: !!variant.touch,
|
||||
isMobile: false,
|
||||
javaScriptEnabled: true,
|
||||
});
|
||||
const tab = await context.newPage();
|
||||
|
||||
// Registered first so the document/stylesheet handlers below win:
|
||||
// Playwright matches the most recently registered route.
|
||||
await tab.route('**/*', route => {
|
||||
const type = route.request().resourceType();
|
||||
if (type === 'image' || type === 'media' || type === 'font') return route.abort();
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
if (job.swapRule) {
|
||||
await tab.route('**/static/style.css*', async route => {
|
||||
const response = await route.fetch();
|
||||
const body = swapRuleOccurrences(await response.text(), job.swapRule);
|
||||
await route.fulfill({ response, body, headers: { ...response.headers(), 'content-type': 'text/css; charset=utf-8' } });
|
||||
});
|
||||
}
|
||||
|
||||
const documentPath = page.url.split('?')[0];
|
||||
await tab.route(`**${documentPath}`, async route => {
|
||||
const response = await route.fetch();
|
||||
let html = await response.text();
|
||||
html = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||
if (shippedStylesheets !== null) {
|
||||
html = html.replace(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi, '');
|
||||
html = html.replace(/<\/head>/i, ` ${shippedStylesheets}\n</head>`);
|
||||
}
|
||||
const classes = [variant.theme === 'light' ? 'light' : '', variant.density && variant.density !== 'comfortable' ? `density-${variant.density}` : '']
|
||||
.filter(Boolean).join(' ');
|
||||
html = html.replace(/<html\b([^>]*)>/i, (match, attrs) => `<html${attrs.replace(/\sclass="[^"]*"/i, '')} class="${classes}">`);
|
||||
await route.fulfill({ response, body: html, headers: { ...response.headers(), 'content-type': 'text/html; charset=utf-8' } });
|
||||
});
|
||||
|
||||
const response = await tab.goto(job.origin + page.url, { waitUntil: 'load' });
|
||||
if (!response || !response.ok()) {
|
||||
throw new Error(`${page.url} returned ${response ? response.status() : 'no response'}`);
|
||||
}
|
||||
const result = await tab.evaluate(pageMeasure, {
|
||||
elements: page.elements || [],
|
||||
bench: page.bench || [],
|
||||
properties: job.properties,
|
||||
});
|
||||
snapshot[page.name][variant.name] = result.measured;
|
||||
if (result.missing.length) missing[`${page.name}/${variant.name}`] = result.missing;
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({ snapshot, missing }));
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
process.stderr.write(String(err && err.stack ? err.stack : err) + '\n');
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
"""Read the app's CSS the way the browser does.
|
||||
|
||||
``static/style.css`` no longer holds every rule: panel styles live in separate
|
||||
files that ``static/index.html`` loads eagerly, in a fixed order, right after
|
||||
it. The cascade is the concatenation of those files in that order.
|
||||
|
||||
A test that asserts on a rule must therefore look at all of them. Reading
|
||||
``static/style.css`` alone ties the test to whichever file a rule happens to
|
||||
sit in today, so it goes red the next time a rule moves without anything about
|
||||
the rendered page having changed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_STATIC = Path(__file__).resolve().parents[2] / "static"
|
||||
_INDEX = _STATIC / "index.html"
|
||||
|
||||
# Only same-origin app stylesheets. Vendored <link>s under static/lib are not
|
||||
# part of the cascade these tests reason about.
|
||||
_LINK = re.compile(
|
||||
r"""<link\b[^>]*\brel\s*=\s*["']stylesheet["'][^>]*\bhref\s*=\s*["']/static/([^"'?]+)([^"']*)["']""",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _entries() -> list[tuple[Path, str]]:
|
||||
html = _INDEX.read_text(encoding="utf-8")
|
||||
out = []
|
||||
for m in _LINK.finditer(html):
|
||||
rel, query = m.group(1), m.group(2)
|
||||
if rel.startswith("lib/"):
|
||||
continue
|
||||
out.append((_STATIC / rel, "/static/" + rel + query))
|
||||
if not out:
|
||||
raise AssertionError(f"no app stylesheet <link> tags found in {_INDEX}")
|
||||
missing = [p for p, _u in out if not p.is_file()]
|
||||
if missing:
|
||||
raise AssertionError(f"index.html links stylesheets that do not exist: {missing}")
|
||||
return out
|
||||
|
||||
|
||||
def stylesheet_paths() -> list[Path]:
|
||||
"""Every app stylesheet on disk, in the order index.html loads it."""
|
||||
return [p for p, _u in _entries()]
|
||||
|
||||
|
||||
def stylesheet_urls() -> list[str]:
|
||||
"""The same stylesheets as request URLs, query string included."""
|
||||
return [u for _p, u in _entries()]
|
||||
|
||||
|
||||
def stylesheet_link_tags() -> str:
|
||||
"""The <link> tags to drop into a synthetic page so it gets the whole
|
||||
cascade, not just style.css."""
|
||||
return "".join(f'<link rel="stylesheet" href="{u}">' for u in stylesheet_urls())
|
||||
|
||||
|
||||
def app_css() -> str:
|
||||
"""The whole cascade as one string, in load order."""
|
||||
return "\n".join(p.read_text(encoding="utf-8") for p in stylesheet_paths())
|
||||
@@ -1,6 +1,8 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -80,7 +82,7 @@ def test_email_filter_menu_has_context_title() -> None:
|
||||
|
||||
def test_email_setting_toggles_render_neutral_disabled_state() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
assert 'email-settings-auto-reply-section' in source
|
||||
assert 'email-settings-display-enabled-state' in source
|
||||
assert 'stateLabel = section?.querySelector' in source
|
||||
@@ -100,7 +102,7 @@ def test_email_date_headers_mark_unexpected_timeline_gaps() -> None:
|
||||
assert "email-date-gap-break" in source
|
||||
assert "gapDays > 90 && gapDays > timelineGapThreshold" in source
|
||||
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
assert ".date-section-header.email-date-gap-break" in style
|
||||
|
||||
|
||||
@@ -122,7 +124,7 @@ def test_email_filters_and_card_favorite_toggle_are_wired() -> None:
|
||||
assert "const typedFilter = _exactTypedFilterSuggestion(v);" in source
|
||||
assert "_acceptSuggestion(typedFilter);" in source
|
||||
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
favorite_start = style.index(".email-card-favorite {")
|
||||
favorite_end = style.index("}", favorite_start) + 1
|
||||
assert "top: -3px;" in style[favorite_start:favorite_end]
|
||||
@@ -154,7 +156,7 @@ def test_email_auto_reply_syncs_one_calendar_event_per_account() -> None:
|
||||
|
||||
def test_email_settings_show_away_account_and_compact_display_controls() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
assert 'email-account-away-label">(AWAY)</span>' in source
|
||||
assert 'id="email-lib-auto-reply-badge"' in source
|
||||
assert ">Show Email Tags</span>" in source
|
||||
@@ -227,7 +229,7 @@ def test_email_clean_always_forces_a_fresh_unsubscribe_scan() -> None:
|
||||
|
||||
def test_unsubscribe_duplicate_badge_is_lowered() -> None:
|
||||
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
stylesheet = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
stylesheet = app_css()
|
||||
assert "email-unsub-duplicate-badge" in frontend
|
||||
start = stylesheet.index(".email-unsub-duplicate-badge {")
|
||||
assert "top: 2px;" in stylesheet[start:stylesheet.index("}", start) + 1]
|
||||
@@ -235,7 +237,7 @@ def test_unsubscribe_duplicate_badge_is_lowered() -> None:
|
||||
|
||||
def test_unsubscribe_scan_status_sits_before_clean_action() -> None:
|
||||
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
stylesheet = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
stylesheet = app_css()
|
||||
start = frontend.index("function _emailCleanupSettingsHtml")
|
||||
end = frontend.index("function _emailDisplaySettingsHtml", start)
|
||||
cleanup = frontend[start:end]
|
||||
@@ -291,7 +293,7 @@ def test_browser_agent_unsubscribe_cleans_sender_after_positive_confirmation() -
|
||||
|
||||
|
||||
def test_auto_unsubscribe_all_is_visibly_taller_than_toolbar_buttons() -> None:
|
||||
source = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
source = app_css()
|
||||
start = source.index(".email-unsub-auto-safe-btn {")
|
||||
assert "height: 29px;" in source[start:source.index("}", start) + 1]
|
||||
|
||||
@@ -330,7 +332,7 @@ def test_unsubscribe_review_marks_handled_cards_and_offers_scan_further() -> Non
|
||||
|
||||
def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None:
|
||||
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
||||
styles = (ROOT / "static" / "style.css").read_text()
|
||||
styles = app_css()
|
||||
assert "email-unsub-ignore-btn" in source
|
||||
assert "_rememberUnsubscribeIgnored(c)" in source
|
||||
assert "Ignore this unsubscribe candidate" in source
|
||||
@@ -339,7 +341,7 @@ def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None
|
||||
|
||||
def test_email_settings_sections_use_static_headers() -> None:
|
||||
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
||||
styles = (ROOT / "static" / "style.css").read_text()
|
||||
styles = app_css()
|
||||
assert 'class="email-unsub-accent-icon"' in source
|
||||
assert 'M12 0L14.59 8.41' in source
|
||||
assert 'Scanning ${_esc(scanFolderLabel)} headers…' in source
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -25,7 +27,7 @@ def test_successful_calendar_tool_output_is_suppressed_in_live_and_saved_rendere
|
||||
|
||||
def test_calendar_chat_event_links_fetch_uid_and_show_title_time():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
routes_src = (ROOT / "routes/calendar_routes.py").read_text()
|
||||
app_src = (ROOT / "static/app.js").read_text()
|
||||
renderer_src = (ROOT / "static/js/chatRenderer.js").read_text()
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _occurrences(text, needle):
|
||||
"""Every index of needle, so an assertion does not depend on which copy of
|
||||
a selector the cascade happens to put first."""
|
||||
out, i = [], text.find(needle)
|
||||
while i != -1:
|
||||
out.append(i)
|
||||
i = text.find(needle, i + 1)
|
||||
return out
|
||||
|
||||
|
||||
def test_calendar_week_view_has_overlap_lanes_and_live_ruler_hooks():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "function _wkLayoutTimedEvents" in calendar_src
|
||||
assert "--lane:${lane};--lane-count:${laneCount}" in calendar_src
|
||||
@@ -20,7 +32,7 @@ def test_calendar_week_view_has_overlap_lanes_and_live_ruler_hooks():
|
||||
|
||||
def test_crowded_week_events_expand_left_to_reveal_full_title_on_hover():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "const crowdedClass = laneCount > 1 ? ' cal-wk-block-crowded' : '';" in calendar_src
|
||||
assert "--lane-right:${laneRight}%" in calendar_src
|
||||
@@ -61,7 +73,7 @@ def test_crowded_week_events_expand_left_to_reveal_full_title_on_hover():
|
||||
|
||||
def test_mobile_truncated_week_event_expands_before_opening_editor():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
click_idx = calendar_src.index("body.querySelectorAll('.cal-wk-block, .cal-wk-allday-event')")
|
||||
edit_idx = calendar_src.index("if (ev) _showEventForm(ev);", click_idx)
|
||||
@@ -78,7 +90,7 @@ def test_mobile_truncated_week_event_expands_before_opening_editor():
|
||||
|
||||
def test_mobile_empty_week_slot_selects_before_opening_new_event():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "let _selectedWeekSlot = null;" in calendar_src
|
||||
assert "function _paintSelectedWeekSlot(body)" in calendar_src
|
||||
@@ -100,7 +112,7 @@ def test_mobile_empty_week_slot_selects_before_opening_new_event():
|
||||
|
||||
def test_week_view_hints_when_current_time_is_below_the_visible_field():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "function _updateWeekNowBelowHint(wrap)" in calendar_src
|
||||
assert "nowRect.top > wrapRect.bottom - 2" in calendar_src
|
||||
@@ -115,7 +127,7 @@ def test_week_view_hints_when_current_time_is_below_the_visible_field():
|
||||
|
||||
def test_mobile_week_scroll_has_resisted_edge_pull_feedback():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "pullStartedAtTop = _wrap.scrollTop <= 1;" in calendar_src
|
||||
assert "pullStartedAtBottom = _wrap.scrollTop >= maxScroll - 1;" in calendar_src
|
||||
@@ -143,7 +155,7 @@ def test_calendar_view_change_recovers_a_fully_open_day_drawer():
|
||||
|
||||
def test_calendar_month_and_agenda_have_visual_depth_hooks():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "cal-weekend" in calendar_src
|
||||
assert "cal-empty-day" in calendar_src
|
||||
@@ -156,7 +168,7 @@ def test_calendar_month_and_agenda_have_visual_depth_hooks():
|
||||
|
||||
def test_calendar_event_cards_show_compact_source_badges():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "function _eventSourceHtml" in calendar_src
|
||||
assert "${_eventSourceHtml(ev)}" in calendar_src
|
||||
@@ -166,7 +178,7 @@ def test_calendar_event_cards_show_compact_source_badges():
|
||||
|
||||
def test_calendar_toolbar_previous_next_arrows_are_mobile_only():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert 'class="cal-nav cal-toolbar-arrow" id="cal-prev"' in calendar_src
|
||||
assert 'class="cal-nav cal-toolbar-arrow" id="cal-next"' in calendar_src
|
||||
@@ -181,7 +193,7 @@ def test_calendar_toolbar_previous_next_arrows_are_mobile_only():
|
||||
|
||||
def test_calendar_side_arrows_center_against_calendar_pane():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "function _alignSideNavToCalendar()" in calendar_src
|
||||
assert "paneRect.top - contentRect.top + (paneRect.height / 2)" in calendar_src
|
||||
@@ -191,7 +203,7 @@ def test_calendar_side_arrows_center_against_calendar_pane():
|
||||
|
||||
def test_calendar_splitter_double_click_snaps_to_nearest_pane_then_toggles():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "detailH >= calendarH ? 'events' : 'calendar'" in calendar_src
|
||||
assert "splitter.addEventListener('dblclick', () => {" in calendar_src
|
||||
@@ -302,7 +314,7 @@ def test_calendar_settings_are_close_only_and_color_the_name_field():
|
||||
assert name_idx < color_idx
|
||||
assert "nameInput.style.borderColor = colorInput.value" in calendar_src
|
||||
assert "nameInput.style.backgroundColor = colorInput.value" not in calendar_src
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
toggle_idx = style_src.index(".cal-week-start-toggle")
|
||||
button_idx = style_src.index(".cal-week-start-btn", toggle_idx)
|
||||
assert "padding: 0;" in style_src[toggle_idx:button_idx]
|
||||
@@ -312,7 +324,7 @@ def test_calendar_settings_are_close_only_and_color_the_name_field():
|
||||
|
||||
def test_calendar_settings_actions_sync_error_and_escape_behavior():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert calendar_src.count('class="cal-settings-actions"') >= 4
|
||||
assert "justify-content: flex-end;" in style_src[style_src.index(".cal-settings-actions"):]
|
||||
@@ -331,7 +343,7 @@ def test_calendar_settings_actions_sync_error_and_escape_behavior():
|
||||
|
||||
def test_calendar_hides_navigation_in_agenda_and_event_forms():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "_modal?.classList.toggle('cal-navigation-hidden', _view === 'agenda')" in calendar_src
|
||||
assert "_modal?.classList.add('cal-navigation-hidden')" in calendar_src
|
||||
@@ -353,7 +365,7 @@ def test_calendar_event_form_escape_cancels_before_modal_close():
|
||||
|
||||
|
||||
def test_calendar_from_and_to_month_day_use_theme_highlight():
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
month_idx = style_src.index('input[type="date"]::-webkit-datetime-edit-month-field')
|
||||
day_idx = style_src.index('input[type="date"]::-webkit-datetime-edit-day-field', month_idx)
|
||||
@@ -364,7 +376,7 @@ def test_calendar_from_and_to_month_day_use_theme_highlight():
|
||||
|
||||
def test_calendar_location_hint_uses_accent_map_pin():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert 'class="cal-loc-hint"' in calendar_src
|
||||
assert '<span>Location</span>' in calendar_src
|
||||
@@ -382,7 +394,7 @@ def test_calendar_tag_order_prioritizes_personal_work_travel_admin():
|
||||
|
||||
def test_long_week_events_keep_their_label_visible_while_scrolling():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert 'class="cal-wk-block-label"' in calendar_src
|
||||
label_idx = style_src.index(".cal-wk-block-label")
|
||||
@@ -393,8 +405,12 @@ def test_long_week_events_keep_their_label_visible_while_scrolling():
|
||||
block_idx = style_src.index(".cal-wk-block {")
|
||||
assert "overflow: clip;" in style_src[block_idx:label_idx]
|
||||
head_idx = style_src.index(".cal-wk-col-head {")
|
||||
allday_idx = style_src.rindex(
|
||||
".cal-wk-allday {", 0, style_src.index(".cal-wk-allday::-webkit-scrollbar")
|
||||
# .cal-wk-allday and its scrollbar rule may now sit in different
|
||||
# stylesheets, so anchoring on the scrollbar rule's offset no longer finds
|
||||
# the block. Take whichever .cal-wk-allday block carries the z-index.
|
||||
allday_idx = next(
|
||||
i for i in _occurrences(style_src, ".cal-wk-allday {")
|
||||
if "z-index: 39;" in style_src[i:i + 500]
|
||||
)
|
||||
now_idx = style_src.index(".cal-wk-now {")
|
||||
assert "z-index: 40;" in style_src[head_idx:head_idx + 500]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -302,7 +304,7 @@ def test_dependency_status_cache_is_not_fragmented_by_unused_model_hint():
|
||||
def test_model_lists_fade_after_complete_rows_are_painted():
|
||||
hwfit = _read("static/js/cookbook-hwfit.js")
|
||||
cookbook = _read("static/js/cookbook.js")
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
|
||||
assert "cookbook-model-list-fade" in hwfit
|
||||
assert "cookbook-model-list-fade" in cookbook
|
||||
@@ -314,7 +316,7 @@ def test_model_lists_fade_after_complete_rows_are_painted():
|
||||
|
||||
def test_dependency_panel_has_stable_categories_and_mobile_build_action():
|
||||
cookbook = _read("static/js/cookbook.js")
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
|
||||
assert "const _depCategoryOrder = ['System', 'Tools', 'LLM', 'Image'" in cookbook
|
||||
assert "const _orderedDepCategories = (byCat)" in cookbook
|
||||
@@ -337,7 +339,7 @@ def test_direct_download_auto_fold_requires_deliberate_boundary_gesture():
|
||||
|
||||
|
||||
def test_launch_command_box_uses_accent_focus_glow():
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
assert ".hwfit-serve-cmd-details[open] .hwfit-serve-cmd" in style
|
||||
assert "box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent" in style
|
||||
|
||||
@@ -345,7 +347,7 @@ def test_launch_command_box_uses_accent_focus_glow():
|
||||
def test_server_settings_color_picker_uses_dot_trigger():
|
||||
cookbook = _read("static/js/cookbook.js")
|
||||
hwfit = _read("static/js/cookbook-hwfit.js")
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
assert 'aria-label="Change server color (currently ${esc(selectedColorLabel)})"' in cookbook
|
||||
assert "btn.setAttribute('aria-label', `Change server color (currently ${label})`)" in hwfit
|
||||
assert ".cookbook-server-row .cookbook-srv-color-dot" in style
|
||||
@@ -357,7 +359,7 @@ def test_server_settings_color_picker_uses_dot_trigger():
|
||||
def test_default_server_matches_exclusive_model_directory_selector():
|
||||
cookbook = _read("static/js/cookbook.js")
|
||||
hwfit = _read("static/js/cookbook-hwfit.js")
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
assert "const icon = active ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF;" in cookbook
|
||||
assert "_envState.defaultServer = key;" in hwfit
|
||||
assert "Toggle off if it's already the default" not in hwfit
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
COOKBOOK = (ROOT / "static/js/cookbook.js").read_text(encoding="utf-8")
|
||||
@@ -18,7 +20,7 @@ def test_trending_models_expose_persistent_official_only_switch():
|
||||
|
||||
|
||||
def test_trending_models_list_stays_within_the_cookbook_window():
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
|
||||
rule = style[style.index("#cookbook-hf-latest-list {"):style.index("#cookbook-hf-latest-list {") + 220]
|
||||
assert "max-height: min(52vh, 480px);" in rule
|
||||
@@ -34,7 +36,7 @@ def test_trending_endpoint_applies_first_party_namespace_filter():
|
||||
|
||||
|
||||
def test_official_only_toggle_fits_narrow_download_toolbar():
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
start = style.rindex(".cookbook-official-filter {")
|
||||
rule = style[start:style.index("}", start)]
|
||||
assert "flex: 0 1 auto" in rule
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Computed-style snapshot regression for ``static/style.css``.
|
||||
|
||||
The stylesheet is one 51k-line file whose result depends on source order, so an
|
||||
extraction that "looks fine" can still change which declaration wins. These
|
||||
tests capture ``getComputedStyle`` over a fixed element inventory across pages,
|
||||
viewports, themes and density modes, and compare the hash against
|
||||
``tests/css_snapshot/baseline.json``.
|
||||
|
||||
Run ``python scripts/css_snapshot.py --write-baseline`` to re-record the
|
||||
baseline, and only after confirming the change is intended - see
|
||||
``tests/css_snapshot/README.md``.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.cli_loader import load_script
|
||||
|
||||
snapshot = load_script("css_snapshot.py")
|
||||
|
||||
_HAS_BROWSER = snapshot.playwright_available()
|
||||
_requires_browser = pytest.mark.skipif(
|
||||
not _HAS_BROWSER,
|
||||
reason="node with the playwright package is required (npm ci)",
|
||||
)
|
||||
|
||||
def static_origin() -> str:
|
||||
"""Origin of the session static server, read at call time.
|
||||
|
||||
The session fixture binds an ephemeral port and publishes it through the
|
||||
environment, which happens after this module is imported at collection.
|
||||
Reading it into a module constant therefore captured the fallback and the
|
||||
capture then connected to a port nothing was listening on. The literal
|
||||
fallback is the fixed port the fixture used before it moved to an ephemeral
|
||||
one, so this still works on a revision that predates that change.
|
||||
"""
|
||||
return os.environ.get("ODYSSEUS_TEST_STATIC_ORIGIN", "http://127.0.0.1:7011")
|
||||
|
||||
# One conflicting selector used to prove the harness is actually sensitive to
|
||||
# source order. `.attach-strip` is declared three times at the top level of
|
||||
# style.css with different margin, min-height and padding, so swapping the
|
||||
# first two changes which declaration wins without changing a single byte of
|
||||
# any individual rule.
|
||||
CONFLICTING_SELECTOR = ".attach-strip"
|
||||
|
||||
|
||||
def test_baseline_covers_every_inventory_entry():
|
||||
"""The committed baseline and the inventory describe the same surface.
|
||||
|
||||
Cheap and browserless: it catches an inventory entry added without
|
||||
re-recording the baseline, which would otherwise look like a pass because
|
||||
nothing compares an absent key.
|
||||
"""
|
||||
inventory = snapshot.load_inventory()
|
||||
baseline = snapshot.load_baseline()
|
||||
|
||||
variant_names = {variant["name"] for variant in inventory["variants"]}
|
||||
for page in inventory["pages"]:
|
||||
name = page["name"]
|
||||
assert name in baseline["elements"], f"{name} missing from the baseline"
|
||||
expected_keys = {entry["key"] for entry in page.get("elements", [])}
|
||||
expected_keys |= set(page.get("bench", []))
|
||||
assert set(baseline["elements"][name]) == expected_keys, (
|
||||
f"{name}: baseline elements differ from the inventory; "
|
||||
"re-record with scripts/css_snapshot.py --write-baseline"
|
||||
)
|
||||
assert set(baseline["variants"][name]) == variant_names
|
||||
|
||||
|
||||
@_requires_browser
|
||||
def test_computed_styles_match_the_committed_baseline():
|
||||
captured = snapshot.capture(static_origin())
|
||||
|
||||
assert captured["missing"] == {}, (
|
||||
"inventory entries matched no element - the markup moved under the "
|
||||
f"harness: {captured['missing']}"
|
||||
)
|
||||
|
||||
summary = snapshot.summarize(captured["snapshot"])
|
||||
drift = snapshot.compare(snapshot.load_baseline(), summary)
|
||||
assert not drift["elements"] and not drift["variants"] and not drift["digest_changed"], (
|
||||
"computed styles moved against tests/css_snapshot/baseline.json.\n"
|
||||
f"elements: {drift['elements']}\n"
|
||||
f"variants: {drift['variants']}\n"
|
||||
"If the change is intended, re-record with "
|
||||
"`python scripts/css_snapshot.py --write-baseline`; if it is not, the "
|
||||
"restructuring changed which declaration wins."
|
||||
)
|
||||
|
||||
|
||||
@_requires_browser
|
||||
def test_reordering_two_conflicting_declarations_moves_the_digest():
|
||||
"""The harness has to fail when the cascade changes, or it proves nothing.
|
||||
|
||||
Captures one variant twice - once normally, once with the first two
|
||||
top-level `.attach-strip` blocks swapped - and asserts the digest moves and
|
||||
points at the affected element.
|
||||
"""
|
||||
variants = ["desktop-dark-comfortable"]
|
||||
unchanged = snapshot.summarize(
|
||||
snapshot.capture(static_origin(), variants=variants)["snapshot"]
|
||||
)
|
||||
reordered = snapshot.summarize(
|
||||
snapshot.capture(static_origin(), variants=variants,
|
||||
swap_rule=CONFLICTING_SELECTOR)["snapshot"]
|
||||
)
|
||||
|
||||
assert unchanged["digest"] != reordered["digest"]
|
||||
drift = snapshot.compare(unchanged, reordered)
|
||||
assert "app-shell/attach-strip" in drift["elements"]
|
||||
assert "bench/.attach-strip" in drift["elements"]
|
||||
@@ -95,7 +95,7 @@ def test_mobile_explicit_load_restores_full_editor_from_bottom_dock():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
const state = await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&mobile-library-open-test=1');
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
CSS = Path("static/style.css").read_text()
|
||||
|
||||
CSS = app_css()
|
||||
|
||||
|
||||
def rule_for(selector: str) -> str:
|
||||
|
||||
@@ -23,7 +23,7 @@ def test_mobile_rich_text_history_state_and_document_switch():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&history-controls-test=1');
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_mobile_open_in_new_chat_copies_to_materialized_session():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
const state = await page.evaluate(async () => {
|
||||
let currentSession = 'current-chat';
|
||||
|
||||
@@ -70,7 +70,7 @@ def test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&outline-test=1');
|
||||
|
||||
@@ -25,7 +25,7 @@ def test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&checklist-enter-test=1');
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent(`<style>
|
||||
:root { --fg:#d8dee9; --bg:#17191f; --panel:#20232b; --border:#444; --red:#e45b6c; --accent-primary:#e45b6c; }
|
||||
</style><link rel="stylesheet" href="/static/style.css?rich-color-test=1">
|
||||
|
||||
@@ -58,7 +58,7 @@ def test_browser_word_export_contains_native_rich_docx_ooxml():
|
||||
viewport: {{ width: 900, height: 700 }},
|
||||
acceptDownloads: true,
|
||||
}});
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${{process.env.ODYSSEUS_TEST_STATIC_ORIGIN}}/static/js/documentStats.js`);
|
||||
await page.route('**/api/upload/docx-image-test', route => route.fulfill({{
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
@@ -157,7 +157,7 @@ def test_browser_markdown_word_export_keeps_heading_and_inline_formatting():
|
||||
viewport: {{ width: 900, height: 700 }},
|
||||
acceptDownloads: true,
|
||||
}});
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${{process.env.ODYSSEUS_TEST_STATIC_ORIGIN}}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {{
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&markdown-docx-export-test=1');
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replac
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&find-boundaries-test=1');
|
||||
|
||||
@@ -5,10 +5,12 @@ import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def test_font_and_color_controls_use_shared_components():
|
||||
@@ -91,7 +93,7 @@ def test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile():
|
||||
|
||||
async function exercise(viewport, suffix) {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async suffix => {
|
||||
const mod = await import(`/static/js/document.js?v=20260831richtexttools91&font-color=${suffix}`);
|
||||
|
||||
@@ -28,7 +28,7 @@ def test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&heading-enter=1');
|
||||
@@ -96,7 +96,7 @@ def test_heading_enter_preserves_shift_middle_and_empty_heading_semantics():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&heading-enter-boundaries=1');
|
||||
|
||||
@@ -4,10 +4,12 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def test_image_caption_uses_semantic_figure_and_structured_export_paths():
|
||||
@@ -33,7 +35,7 @@ def test_mobile_image_caption_survives_resize_history_and_empty_removal():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&image-caption=1');
|
||||
|
||||
@@ -4,10 +4,12 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def test_input_rules_are_scoped_to_plain_blocks():
|
||||
@@ -25,7 +27,7 @@ def test_typing_markers_converts_blocks_and_preserves_following_text():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&input-rules-test=1');
|
||||
|
||||
@@ -15,7 +15,7 @@ def test_rich_document_shortcuts_work_at_desktop_and_mobile_widths():
|
||||
|
||||
async function exercise(viewport, suffix) {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async suffix => {
|
||||
const mod = await import(`/static/js/document.js?v=20260831richtexttools91&keyboard-shortcuts=${suffix}`);
|
||||
|
||||
@@ -25,7 +25,7 @@ def test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&selection-toolbar-test=1');
|
||||
|
||||
@@ -40,7 +40,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&slash-menu-test=1');
|
||||
|
||||
@@ -13,7 +13,7 @@ def test_rich_url_paste_links_selections_and_plain_urls_without_unsafe_autolinks
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&smart-link-paste=1');
|
||||
|
||||
@@ -43,7 +43,7 @@ def test_mobile_headings_page_break_history_and_persistence():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&structure-tools-test=1');
|
||||
|
||||
@@ -30,7 +30,7 @@ def test_mobile_table_cell_alignment_tracks_state_and_native_history():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-cell-alignment=1');
|
||||
|
||||
@@ -35,7 +35,7 @@ def test_mobile_structural_edits_preserve_header_modes_and_history():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-header-preservation-test=1');
|
||||
|
||||
@@ -39,7 +39,7 @@ def test_mobile_header_row_and_column_toggle_independently_with_undo():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-header-test=1');
|
||||
|
||||
@@ -33,7 +33,7 @@ def test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history(
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-merge-split=1');
|
||||
|
||||
@@ -30,7 +30,7 @@ def test_mobile_table_tab_navigation_row_creation_and_history():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-tab-history-test=1');
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def test_rich_text_toolbar_exposes_document_formatting_controls():
|
||||
|
||||
@@ -57,7 +57,7 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&toggle-test=1');
|
||||
@@ -126,7 +126,7 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&toolbar-menu-test=1');
|
||||
@@ -241,7 +241,7 @@ def test_rich_toolbar_menus_track_live_formatting_values():
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&toolbar-state-test=1');
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_ctrl_s_saves_rich_text_immediately_once_and_updates_status():
|
||||
body: JSON.stringify({ version_count: 2 }),
|
||||
});
|
||||
});
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&save-shortcut-test=1');
|
||||
|
||||
@@ -31,7 +31,7 @@ def test_save_status_is_dirty_race_safe_and_reports_failures():
|
||||
});
|
||||
active -= 1;
|
||||
});
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=20260831richtexttools91&save-status-test=1');
|
||||
|
||||
@@ -4,10 +4,12 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def _run_stats(expression: str):
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
|
||||
|
||||
async function inspect(viewport, suffix) {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async suffix => {
|
||||
const mod = await import(`/static/js/document.js?v=20260831richtexttools91&toolbar-order=${suffix}`);
|
||||
|
||||
@@ -13,7 +13,7 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None:
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=escape-regression-1');
|
||||
@@ -75,7 +75,7 @@ def test_email_escape_closes_inner_states_without_closing_library() -> None:
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
window.fetch = async () => new Response(JSON.stringify({
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_gallery_detail_uses_fixed_media_stage_and_arrow_lanes():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
image_rule = css[css.index(".gallery-detail-image {"):]
|
||||
image_rule = image_rule[:image_rule.index("}")]
|
||||
|
||||
@@ -17,7 +19,7 @@ def test_gallery_detail_uses_fixed_media_stage_and_arrow_lanes():
|
||||
|
||||
|
||||
def test_gallery_media_maximizes_inside_stage_without_resizing_it():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert css.count("max-height: 100%") >= 2
|
||||
assert "object-fit: contain" in css
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_gallery_selection_dot_has_rendered_dimensions():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
rule = css[css.index(".gallery-select-dot {"):]
|
||||
rule = rule[:rule.index("}")]
|
||||
|
||||
@@ -15,7 +17,7 @@ def test_gallery_selection_dot_has_rendered_dimensions():
|
||||
|
||||
|
||||
def test_gallery_selection_highlight_is_painted_above_thumbnail():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
selector = ".gallery-card:has(.gallery-select-dot.selected)::after"
|
||||
rule = css[css.index(selector):]
|
||||
rule = rule[:rule.index("}")]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
@@ -20,7 +22,7 @@ def test_gallery_exposes_active_filter_organization():
|
||||
|
||||
def test_gallery_cards_keep_badges_without_hover_inspect_label():
|
||||
js = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert "gallery-card-overlay" in js
|
||||
assert "gallery-card-inspect" not in js
|
||||
@@ -31,7 +33,7 @@ def test_gallery_cards_keep_badges_without_hover_inspect_label():
|
||||
|
||||
def test_gallery_detail_has_richer_inspector_actions():
|
||||
js = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert "gallery-detail-inspector-head" in js
|
||||
assert "gallery-detail-meta-grid" in js
|
||||
@@ -57,7 +59,7 @@ def test_gallery_static_cache_key_bumped():
|
||||
|
||||
def test_gallery_bulk_select_button_keeps_toolbar_alignment():
|
||||
js = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert 'id="gallery-select-btn"' in js
|
||||
assert 'id="gallery-select-btn" title="Select for bulk actions" style="position:relative;top:0;"' in js
|
||||
@@ -74,7 +76,7 @@ def test_gallery_bulk_select_button_keeps_toolbar_alignment():
|
||||
def test_gallery_selection_bars_share_style_and_escape_priority():
|
||||
gallery = _read("static/js/gallery.js")
|
||||
ui = _read("static/js/ui.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert gallery.count("memory-bulk-bar gallery-selection-bar hidden") == 3
|
||||
assert "window.__galleryCancelSelection = () =>" in gallery
|
||||
@@ -93,7 +95,7 @@ def test_gallery_selection_bars_share_style_and_escape_priority():
|
||||
|
||||
def test_editor_saved_projects_support_grid_and_list_views():
|
||||
gallery = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert 'id="gallery-editor-drafts-view" role="group"' in gallery
|
||||
assert 'data-view="grid"' in gallery
|
||||
@@ -136,7 +138,7 @@ def test_editor_saved_projects_support_grid_and_list_views():
|
||||
|
||||
def test_empty_albums_show_action_tiles_instead_of_no_albums_message():
|
||||
gallery = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert "function _albumActionTiles()" in gallery
|
||||
assert 'id="gallery-albums-new"' in gallery
|
||||
@@ -149,7 +151,7 @@ def test_empty_albums_show_action_tiles_instead_of_no_albums_message():
|
||||
|
||||
|
||||
def test_gallery_body_uses_available_window_height_without_grid_crop():
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
gallery_section = css.index("/* ── Gallery (image library) ── */")
|
||||
modal_start = css.index(".gallery-modal-content {", gallery_section)
|
||||
@@ -161,7 +163,7 @@ def test_gallery_body_uses_available_window_height_without_grid_crop():
|
||||
|
||||
|
||||
def test_gallery_photo_search_is_not_vertically_offset():
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
search_idx = css.index(".gallery-search-wrap {")
|
||||
assert "top: 0;" in css[search_idx:search_idx + 180]
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_mobile_notes_checklist_rows_remain_tappable():
|
||||
css = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "body.notes-mobile-mode .note-card .note-checkbox {" in css
|
||||
@@ -39,7 +41,7 @@ def test_mobile_long_press_enters_select_mode_for_that_note():
|
||||
|
||||
|
||||
def test_mobile_select_mode_has_subtle_jiggle_feedback():
|
||||
css = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert "@keyframes notes-select-jiggle" in css
|
||||
assert "body.notes-mobile-mode .note-card-selectmode" in css
|
||||
@@ -81,7 +83,7 @@ def test_calendar_email_attachments_have_a_calendar_import_action():
|
||||
|
||||
def test_mobile_bulk_select_long_press_is_shared_across_card_types():
|
||||
helper = (ROOT / "static" / "js" / "mobileBulkSelect.js").read_text(encoding="utf-8")
|
||||
css = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert ".memory-item[data-memory-id]" in helper
|
||||
assert ".skill-card[data-skill-name]" in helper
|
||||
|
||||
@@ -1801,3 +1801,47 @@ def test_generic_go_to_phrase_is_browser_interaction() -> None:
|
||||
assert not _looks_like_explicit_browser_interaction(
|
||||
"Find rules for getting rid of garbage in Setagaya-ku."
|
||||
)
|
||||
|
||||
|
||||
def test_terminate_owned_chrome_skips_the_sweep_without_procfs(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
"""macOS and Windows have no /proc; shutdown must degrade, not raise."""
|
||||
|
||||
missing = tmp_path / "no-procfs"
|
||||
monkeypatch.setattr(web_tools, "_PROC_ROOT", missing)
|
||||
|
||||
def _unexpected_iterdir(*args, **kwargs):
|
||||
raise AssertionError("the pid sweep must not run without procfs")
|
||||
|
||||
monkeypatch.setattr(Path, "iterdir", _unexpected_iterdir)
|
||||
|
||||
PrivateBrowserTool._terminate_owned_chrome({"TMPDIR": str(tmp_path)})
|
||||
|
||||
|
||||
def test_terminate_owned_chrome_kills_only_this_runtimes_profile(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
"""With procfs present, match on the runtime-owned profile prefix alone."""
|
||||
|
||||
proc = tmp_path / "proc"
|
||||
tmpdir = tmp_path / "runtime-tmp"
|
||||
tmpdir.mkdir()
|
||||
profile_prefix = str(tmpdir.resolve() / "agent-browser-chrome-")
|
||||
|
||||
def _write_pid(pid: str, cmdline: str) -> None:
|
||||
entry = proc / pid
|
||||
entry.mkdir(parents=True)
|
||||
(entry / "cmdline").write_bytes(cmdline.replace(" ", "\0").encode())
|
||||
|
||||
_write_pid("101", f"chrome --user-data-dir={profile_prefix}abc")
|
||||
_write_pid("202", "chrome --user-data-dir=/Users/someone/Library/Chrome")
|
||||
(proc / "self").mkdir()
|
||||
|
||||
monkeypatch.setattr(web_tools, "_PROC_ROOT", proc)
|
||||
killed: list[int] = []
|
||||
monkeypatch.setattr(web_tools.os, "kill", lambda pid, sig: killed.append(pid))
|
||||
|
||||
PrivateBrowserTool._terminate_owned_chrome({"TMPDIR": str(tmpdir)})
|
||||
|
||||
assert killed == [101]
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_history_list_participates_in_shared_library_expansion_layout():
|
||||
panel = (ROOT / "static/js/research/panel.js").read_text(encoding="utf-8")
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert 'id="research-past-list" class="doclib-grid memory-list research-jobs-list"' in panel
|
||||
card = css[css.index("#research-pane .research-history-card {"):]
|
||||
@@ -38,7 +40,7 @@ def test_history_list_participates_in_shared_library_expansion_layout():
|
||||
|
||||
|
||||
def test_expanded_history_report_owns_and_scrolls_the_available_height():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
expanded = (
|
||||
"#research-pane #research-past-list "
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -19,7 +21,7 @@ def test_active_research_visualizer_has_live_hierarchy_and_balanced_layout():
|
||||
|
||||
|
||||
def test_active_research_visualizer_respects_reduced_motion():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert "@media (prefers-reduced-motion: reduce)" in css
|
||||
assert ".research-synapse .rs-live-dot" in css
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _occurrences(text, needle):
|
||||
"""Every index of needle, so an assertion does not depend on which copy of
|
||||
a selector the cascade happens to put first."""
|
||||
out, i = [], text.find(needle)
|
||||
while i != -1:
|
||||
out.append(i)
|
||||
i = text.find(needle, i + 1)
|
||||
return out
|
||||
|
||||
|
||||
def test_bulk_select_triggers_share_dot_and_x_icons():
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
for selector in (
|
||||
"#memory-select-btn",
|
||||
@@ -36,14 +48,14 @@ def test_library_titles_have_highlighted_respective_icons():
|
||||
source = (ROOT / "static" / "js" / "documentLibrary.js").read_text()
|
||||
|
||||
assert source.count('class="doclib-section-title-icon"') == 4
|
||||
assert "color: var(--accent, var(--red));" in (ROOT / "static" / "style.css").read_text()
|
||||
assert "color: var(--accent, var(--red));" in app_css()
|
||||
for title in ("Chats", "Archive", "Research", "Documents"):
|
||||
assert f">{title} <span" in source
|
||||
|
||||
|
||||
def test_library_select_follows_tidy():
|
||||
source = (ROOT / "static" / "js" / "documentLibrary.js").read_text()
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
assert source.index('id="doclib-chats-tidy-btn"') < source.index('id="doclib-chats-select-btn"')
|
||||
assert source.index('id="doclib-research-tidy-btn"') < source.index('id="doclib-research-select-btn"')
|
||||
@@ -67,7 +79,7 @@ def test_library_select_follows_tidy():
|
||||
|
||||
def test_library_filter_chips_match_skills_count_markup():
|
||||
source = (ROOT / "static" / "js" / "documentLibrary.js").read_text()
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
assert "function _setLibraryCountChipContent" in source
|
||||
assert "chip.replaceChildren(text, value)" in source
|
||||
@@ -89,7 +101,7 @@ def test_launch_cookbook_filter_chips_match_skills():
|
||||
|
||||
|
||||
def test_launch_search_select_and_tags_move_up_together():
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
rule = css[css.index("#serve-search,"):css.index("#serve-search,") + 140]
|
||||
assert "#hwfit-cache-select," in rule
|
||||
@@ -109,11 +121,16 @@ def test_launch_search_select_and_tags_move_up_together():
|
||||
|
||||
|
||||
def test_launch_server_selector_and_model_search_move_up_together():
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
rule = css[css.index("#hwfit-server-select,"):css.index("#hwfit-server-select,") + 130]
|
||||
assert "#hwfit-search" in rule
|
||||
assert "top: -2px !important;" in rule
|
||||
# The stylesheets are split across several files, so this selector group
|
||||
# is not guaranteed to be the first occurrence of its opening selector.
|
||||
# Check every occurrence instead of a fixed offset into the cascade.
|
||||
rules = [css[i:i + 130] for i in _occurrences(css, "#hwfit-server-select,")]
|
||||
assert any(
|
||||
"#hwfit-search" in rule and "top: -2px !important;" in rule
|
||||
for rule in rules
|
||||
), "no #hwfit-server-select rule groups #hwfit-search with top: -2px !important"
|
||||
|
||||
|
||||
def test_launch_heading_uses_accented_flame_icon():
|
||||
@@ -133,7 +150,7 @@ def test_launch_heading_reports_cached_model_count():
|
||||
|
||||
|
||||
def test_launch_search_matches_document_library_search_treatment():
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
rule = css[css.index("#hwfit-search {", css.index("#hwfit-server-select,")):][:700]
|
||||
assert "height: 30px;" in rule
|
||||
@@ -151,7 +168,7 @@ def test_launch_search_matches_document_library_search_treatment():
|
||||
|
||||
def test_memory_filter_chips_match_skills_and_are_not_cropped():
|
||||
source = (ROOT / "static" / "js" / "memory.js").read_text()
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
assert "skills-summary-chip memory-filter-chip" in source
|
||||
assert "btn.replaceChildren(label, count)" in source
|
||||
|
||||
@@ -2,11 +2,13 @@ import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SKILLS_JS = (ROOT / "static/js/skills.js").read_text(encoding="utf-8")
|
||||
METRICS_JS = (ROOT / "static/js/skillsMetrics.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Stylesheet asset manifest: shipped HTML and the service worker must agree.
|
||||
|
||||
``specs/frontend.md`` records that nothing validates the service-worker
|
||||
precache against what ``index.html`` actually loads, and that a static asset
|
||||
manifest regression should confirm referenced files exist. Both matter more
|
||||
once ``static/style.css`` starts being split: every new stylesheet has to be
|
||||
registered in ``static/index.html`` *and* in the ``static/sw.js`` precache,
|
||||
with the same ``?v=`` cache-bust string in both. Today those two strings are
|
||||
hardcoded independently, so a split that updates one and not the other ships an
|
||||
offline cache that serves a stale or missing stylesheet - invisible until
|
||||
someone loads the app without a network.
|
||||
|
||||
This test reads the shipped files rather than driving a browser: the artifact
|
||||
under test *is* the URL string in two files, and a browser would only see one
|
||||
of them at a time.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INDEX_HTML = ROOT / "static" / "index.html"
|
||||
SERVICE_WORKER = ROOT / "static" / "sw.js"
|
||||
|
||||
_STYLESHEET_LINK = re.compile(
|
||||
r"""<link\b[^>]*\brel=["']stylesheet["'][^>]*>""", re.IGNORECASE)
|
||||
_HREF = re.compile(r"""\bhref=["']([^"']+)["']""", re.IGNORECASE)
|
||||
|
||||
|
||||
def _stylesheet_hrefs(html_path):
|
||||
"""Every href from a <link rel="stylesheet"> in one HTML file."""
|
||||
html = html_path.read_text(encoding="utf-8")
|
||||
hrefs = []
|
||||
for tag in _STYLESHEET_LINK.findall(html):
|
||||
match = _HREF.search(tag)
|
||||
if match:
|
||||
hrefs.append(match.group(1))
|
||||
return hrefs
|
||||
|
||||
|
||||
def _precache_entries(name):
|
||||
"""The quoted URLs of one precache array in sw.js.
|
||||
|
||||
Entries built by spreading another list (``...KATEX_FONTS``) are skipped:
|
||||
they carry no literal URL at this level, and the fonts they add are not
|
||||
stylesheets.
|
||||
"""
|
||||
source = SERVICE_WORKER.read_text(encoding="utf-8")
|
||||
match = re.search(rf"const {name} = \[(.*?)\n\];", source, re.DOTALL)
|
||||
assert match, f"{name} array not found in static/sw.js"
|
||||
return re.findall(r"""['"](/[^'"]+)['"]""", match.group(1))
|
||||
|
||||
|
||||
def _local_path(url):
|
||||
"""Map an app-absolute URL to the file it is served from."""
|
||||
return ROOT / url.split("?", 1)[0].lstrip("/")
|
||||
|
||||
|
||||
def test_every_stylesheet_referenced_by_shipped_html_exists():
|
||||
checked = 0
|
||||
for html_path in sorted((ROOT / "static").glob("*.html")):
|
||||
for href in _stylesheet_hrefs(html_path):
|
||||
if not href.startswith("/"):
|
||||
continue # external or relative-to-page; not ours to resolve
|
||||
assert _local_path(href).is_file(), (
|
||||
f"{html_path.name} links {href}, which does not exist on disk"
|
||||
)
|
||||
checked += 1
|
||||
assert checked, "no app stylesheet links found - the parser or the markup moved"
|
||||
|
||||
|
||||
def test_every_precached_stylesheet_exists():
|
||||
for name in ("PRECACHE", "PANEL_PRECACHE"):
|
||||
for url in _precache_entries(name):
|
||||
if not url.endswith(".css") and ".css?" not in url:
|
||||
continue
|
||||
assert _local_path(url).is_file(), (
|
||||
f"sw.js {name} precaches {url}, which does not exist on disk"
|
||||
)
|
||||
|
||||
|
||||
def test_index_stylesheets_are_precached_with_the_same_cache_bust_string():
|
||||
"""The precache must match the request the browser actually makes.
|
||||
|
||||
A service-worker entry only matches a fetch when the URL is identical,
|
||||
query string included, so a stylesheet whose ``?v=`` differs between the
|
||||
two files is precached under a URL nothing ever requests.
|
||||
"""
|
||||
precached = set(_precache_entries("PRECACHE"))
|
||||
precached_paths = {url.split("?", 1)[0]: url for url in precached}
|
||||
|
||||
for href in _stylesheet_hrefs(INDEX_HTML):
|
||||
if not href.startswith("/static/"):
|
||||
continue
|
||||
path = href.split("?", 1)[0]
|
||||
assert path in precached_paths, (
|
||||
f"index.html loads {href} but sw.js PRECACHE has no entry for {path}; "
|
||||
"the app shell would not be available offline"
|
||||
)
|
||||
assert precached_paths[path] == href, (
|
||||
f"cache-bust mismatch for {path}: index.html requests {href!r}, "
|
||||
f"sw.js precaches {precached_paths[path]!r}"
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Contract for the loopback static server the browser tests run against.
|
||||
|
||||
The server used to bind a fixed port and raise if it was taken, which errored
|
||||
every collected test rather than the browser ones — so a second worktree
|
||||
running its own suite took the whole session down with it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_static_origin_is_published_for_node_subprocesses() -> None:
|
||||
origin = os.environ.get("ODYSSEUS_TEST_STATIC_ORIGIN")
|
||||
|
||||
assert origin, "the session fixture must publish the origin it bound"
|
||||
assert re.fullmatch(r"http://127\.0\.0\.1:\d+", origin)
|
||||
|
||||
|
||||
def test_static_origin_does_not_reuse_the_application_port() -> None:
|
||||
"""An ephemeral port keeps the suite and a running instance independent."""
|
||||
|
||||
origin = os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"]
|
||||
|
||||
assert not origin.endswith(":7011")
|
||||
|
||||
|
||||
def test_static_server_serves_this_worktree() -> None:
|
||||
origin = os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"]
|
||||
|
||||
with urllib.request.urlopen(f"{origin}/static/js/documentStats.js", timeout=5) as r:
|
||||
assert r.status == 200
|
||||
assert r.headers.get_content_type() == "application/javascript"
|
||||
|
||||
|
||||
def test_no_test_hardcodes_the_static_server_origin() -> None:
|
||||
"""Regression guard: a hardcoded port reintroduces the collision."""
|
||||
|
||||
needle = "page.goto(" + "'http://127.0.0.1:"
|
||||
offenders = []
|
||||
for path in sorted((ROOT / "tests").glob("*.py")):
|
||||
if path.name == Path(__file__).name:
|
||||
continue
|
||||
if needle in path.read_text(encoding="utf-8"):
|
||||
offenders.append(path.name)
|
||||
|
||||
assert offenders == [], (
|
||||
"browser tests must read ODYSSEUS_TEST_STATIC_ORIGIN rather than a fixed port"
|
||||
)
|
||||
Reference in New Issue
Block a user