Merge commit 'refs/phase3/pre-ajax/publication-tip' into integration/pre-ajax-release

# Conflicts:
#	routes/chat_routes.py
#	routes/session_routes.py
#	src/agent_loop.py
#	src/agent_tools/filesystem_tools.py
#	src/teacher_escalation.py
#	src/tool_capabilities.py
#	src/tool_execution.py
#	tests/test_mcp_add_server_args_validation.py
#	tests/test_token_cache_atomic_swap.py
This commit is contained in:
Alexandre Teixeira
2026-10-05 15:59:59 +01:00
1395 changed files with 360455 additions and 105938 deletions
+133
View File
@@ -0,0 +1,133 @@
# Known full-suite failures
`python -m pytest -q` does not come back clean on every machine, and it never
has. Without a list of which failures are expected, a first local run is
uninterpretable: you cannot tell "you broke something" from "you are on a Mac",
so the usual result is either chasing a non-bug or ignoring a real one.
This is that list. It is a record of observation, not a permission slip: a test
here is still a test that does not pass, and the three that remain below are
all still worth someone's time.
Last measured: `lab @ c499c01b` plus the fixes in this change, macOS 15 on
Apple Silicon, Python 3.11, with the **default** `$TMPDIR` — see the socket
entry below for why that qualifier is load-bearing.
```
3 failed, 10658 passed, 6 skipped
```
## Get the prerequisites right first
Most "surprise" failures are a missing dependency rather than anything in this
file. A clean run needs all of:
```bash
python3.11 -m venv venv
./venv/bin/python -m pip install -r requirements.txt
npm ci # the browser tests shell out to node
npx playwright install chromium # ~30 tests drive a real browser
mkdir -p data # SQLite lives at ./data/app.db
```
plus `ffmpeg` on `PATH` for the media tests.
If you already have a ChromaDB running, point `CHROMADB_PORT` at a closed port
for the run. The client reaches Chroma over HTTP regardless of the data
directory, so a test run will otherwise attach to whatever store is listening,
including one holding real data.
Miss `npm ci` and roughly 36 browser tests fail on `Cannot find package
'playwright'`. That is not a regression, it is the missing install.
## The three that remain, and the seven that no longer do
### Test bugs: comparing an unresolved path against a resolved one
Three failures compared an unresolved `/tmp` path against a resolved
`/private/tmp` one, and are fixed rather than listed:
- `tests/test_code_nav_tools.py` (two tests) built a fixture under
`tempfile.mkdtemp(dir="/tmp")` and compared it against the path the code
reports, which it resolves.
- `tests/test_workspace_confine.py::test_glob_confined_e2e` mixed
`os.path.realpath(ws)` with an unresolved secret directory, so `relpath`
produced `../../../../tmp/<absolute path>` and the assertion that the
absolute path was absent matched it as a substring.
Both now resolve consistently. They are recorded here because the shape recurs:
on macOS, mixing a resolved and an unresolved temp path is a test bug that
looks like a platform failure.
### Test bugs: a temp path too long to bind a socket to
Four more, same family, invisible unless `$TMPDIR` is long enough:
- `tests/test_shell_routes.py::TestHostDockerAccess` (three tests)
- `tests/test_cookbook_docker_access.py::test_container_opt_in_with_unix_socket_is_allowed`
```
OSError: AF_UNIX path too long
```
Each bound an `AF_UNIX` socket at `tmp_path / "docker.sock"`. macOS gives
`sun_path` 104 bytes including the terminator, and pytest's `tmp_path` is
rooted at `$TMPDIR`, which on a stock Mac is a 49-character
`/var/folders/<2>/<30>/T/`. Add `pytest-of-<user>/pytest-<n>/` and the test's
own name and the bind path is 115 bytes before the filename.
This is why the counts above depend on where you run from: under a shortened
`$TMPDIR` the path lands at 103 and the tests pass, and it tips over the moment
pytest's run counter reaches two digits. Linux allows 108 bytes and roots
`$TMPDIR` at `/tmp`, so it never bites there and CI stays green.
They now bind through `tests/helpers/unix_sockets.bound_unix_socket`, which
puts the socket under a short directory. **Measure with the default `$TMPDIR`**
— `env -u TMPDIR` or an explicit `/var/folders/...` — or this whole file
records a run nobody else has.
### Optional dependency: ffmpeg without a WebP encoder
- `tests/test_inspect_media_tool.py::test_inspect_media_exports_final_decodable_frame_at_exact_duration`
```
ffmpeg still extraction failed: Automatic encoder selection failed ...
Error opening output files: Encoder not found
```
The test asks ffmpeg for a `.webp` still and asserts `exit_code == 0`. WebP
encoding is a build option, and Homebrew's ffmpeg does not always carry it. CI
installs a build that does, which is why this is green there.
**Needs a decision**: skip when the encoder is absent, or fall back to PNG. The
current shape asserts success from a codec that is not guaranteed present.
### Environmental: real sockets
- `tests/test_integration_api_call_ssrf.py::test_real_socket_falls_back_from_dead_first_to_live_second`
```
httpcore.ConnectTimeout / httpx.ConnectTimeout
```
Opens real sockets and depends on a connection to a dead address being refused
quickly rather than hanging. Sandboxed and restricted-network machines time out
instead. Genuinely environmental.
### Unexplained: rich-text colour contrast
- `tests/test_document_rich_color_reset_and_contrast.py::test_rich_colors_follow_theme_and_undo_as_one_edit`
A Playwright run times out waiting for `#doc-email-richbody p` to contain a
`span` after a colour is applied.
**This one is not flaky.** Three consecutive runs failed identically, each at
about 31 seconds. It was previously written off as timing noise and that was
wrong. The cause is not established, and until it is, treat it as a possible
real defect in the rich-text colour path rather than a platform artifact.
## Keeping this current
Re-measure on a clean checkout of `lab` with the prerequisites above, and
update the header revision, the counts and any entry that changed. A failure
that appears and is not listed here is a regression until shown otherwise.
+139
View File
@@ -15,6 +15,13 @@ reference; that file is the standard the refactor works toward.
## Running focused subsets (taxonomy markers)
The shared static-server fixture binds an ephemeral loopback port and publishes
`ODYSSEUS_TEST_STATIC_ORIGIN` to browser tests and their Node subprocesses.
`ODYSSEUS_TEST_STATIC_PORT` can pin a port for an external client; leave it unset
for parallel runs. An occupied explicit port is refused rather than reused. The
server handles each connection on its own thread, so a speculative browser
connection that never sends a request cannot stall the requests behind it.
`tests/conftest.py` tags every test at collection time with two markers derived
from its filename by `tests/_taxonomy.py`: an `area_*` marker (e.g.
`area_security`) and a finer `sub_*` marker (e.g. `sub_owner_scope`). This adds
@@ -33,6 +40,8 @@ the sub-area. The `area_*` names are registered in `pyproject.toml`; the dynamic
`sub_*` names are registered before collection by `pytest_configure` in
`tests/conftest.py`, so unknown-mark warnings still flag genuine typos.
The full suite does not come back clean on every machine. [KNOWN_FAILURES.md](KNOWN_FAILURES.md) lists which failures are expected, which are test bugs worth fixing, and the prerequisites a clean run needs; anything not on that list is a regression until shown otherwise.
For common focused runs, use `tests/run_focus.py`. It validates area and
sub-area names, accepts sub-areas with or without the `sub_` prefix, and passes
extra pytest arguments after `--`:
@@ -83,6 +92,76 @@ fast lane; the test stays runnable directly, e.g.:
./venv/bin/python -m pytest -m slow
```
## Parallel shards (`--shard N/M`)
CI no longer runs the whole suite as one workload. The `python-tests` job is a
four-way matrix, and each job runs one section:
```bash
./venv/bin/python -m pytest -q --shard 1/4
```
`tests/_shards.py` owns the partition and `tests/conftest.py` applies it. The
unit of a shard is a **test file**, so tests that share module state stay
together, and assignment is a total function of the file path - every file
lands in exactly one shard, and the four shards together run every test exactly
once. The partition is deliberately *not* built on the `area_*` markers: those
do not partition the suite, because a file may carry a hand-applied `area_*`
mark on top of the one derived from its filename.
Sharding deselects; it does not narrow collection. Every test module is still
imported, in the same order, in every shard, so the import-time stubbing in
`conftest.py` behaves identically whether the suite runs whole or in sections.
Only the deselected tests' call phase is skipped.
Balance comes from the `slow` marker: a `slow` item is weighted far above an
ordinary one, and files are packed heaviest-first into the lightest shard. The
plan depends only on the collected file set, so every parallel job computes the
same one from the same commit. As more tests earn a `slow` mark from duration
evidence, the sections even out further - no duration table to keep current.
`--shard 1/1` is a no-op, and a selector that is malformed or out of range ends
the run with a usage error rather than quietly testing a subset. If you change
the shard count, change `DEFAULT_SHARD_COUNT` and the `ci.yml` matrix together;
`tests/test_shards.py` fails when they drift apart.
## Local pytest workers
Install the application environment and parallel test tooling with
`python -m pip install -r requirements-dev.txt`. Parallelism is opt-in; ordinary
pytest remains serial, and the four CI shards are unchanged.
```bash
python -m pytest -q -n 4 -p no:cacheprovider --max-worker-restart=0
python -m pytest -q -n 0 -p no:cacheprovider # full serial release oracle
```
See [the Wave 6 measurements](WAVE6_TEST_PERFORMANCE_REPORT.md) before choosing
a worker count. `-n auto` uses physical cores through xdist's psutil extra; it
still needs enough memory for each worker's collection and application imports.
Before collection, `tests.helpers.worker_runtime` gives each process private
data, attachment, embedding-cache, browser-runtime, and temporary directories.
pytest's basetemp is the controller root's `pytest` directory, with xdist's
`popen-gw<n>` beneath it, which keeps `tmp_path` Unix sockets inside the
107-byte path limit. An explicit `--basetemp` still wins.
An atomic random suffix separates concurrent invocations with the same worker
label; paths inside the root have stable names. Function fixtures still own
their databases and test-specific state. Normal teardown removes the root and
restores the environment. A hard-killed standalone process can leave its owned
root behind, but later runs allocate a fresh namespace and never adopt it.
`APP_PORT` opts into tests against an externally launched smoke application.
Run that suite with `-n 0` so its accounts, endpoints, and application data have
one owner. Unset `APP_PORT` for ordinary unit/regression invocations. Selected
live smoke tests are rejected under xdist with a collection failure; `-m
"not serial"` may exclude them. The existing smoke skips when no application
is launched remain visible in full-suite counts.
Measurements disable pytest's advisory cache to keep concurrent invocations
from sharing last-failed metadata. They also disable worker restart so crashes
remain immediately visible. No test retries or default worker count are added.
## Order-sensitivity reporting (report-only)
`tests/run_order_report.py` runs pytest with the collected test items shuffled
@@ -137,6 +216,50 @@ 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
the shipped ordered stylesheet cascade, 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.
## Release smoke suite
`tests/smoke/` drives every advertised feature area once, end to end,
against a real instance - the safety net the unit suite does not provide
for a route move or a module split. One command boots the worktree and
runs it:
```bash
scripts/odysseus-smoke # boot, run every area, stop again
scripts/odysseus-smoke --keep-up # leave the instance running
scripts/odysseus-smoke --areas # the coverage table, without booting
```
It reads its target instance out of the environment (`APP_PORT` through
`internal_api_base()`, plus the dev admin account), so under a plain
`pytest` with nothing booted every scenario skips with the reason and
the full suite stays green. Models are served by a deterministic
loopback stub, never a live endpoint; email uses the repo's existing
`ODYSSEUS_EMAIL_FIXTURE` path.
The report is a per-area table that also prints the areas the suite
deliberately does not cover, so it cannot be read as coverage of
everything it omits. `tests/smoke/README.md` documents what is in each
list and why.
## Core principles
- Keep PRs small and homogeneous: one kind of change per PR.
@@ -153,6 +276,22 @@ 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.
- App styles live across an ordered cascade; reading one fragment 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 shipped cascade.
- 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
+215
View File
@@ -0,0 +1,215 @@
# Wave 6 test performance and worker isolation
Starting branch: `wave6/test-performance-foundation`.
Starting HEAD: `bfa5ebb379713790bd1b47c75237fb1fbe989375`.
Starting tree: `3aa232c1c7d479d7ed48e22aa59ddfe8f53058ad`.
The interrupted working tree was inspected before edits. It contained changes
in `pyproject.toml`, `tests/conftest.py`, `tests/test_research_report_read.py`,
and `tests/test_stt_leak.py`, plus untracked `requirements-dev.txt`,
`tests/helpers/worker_runtime.py`, and `tests/test_worker_runtime.py`.
All seven files belonged to this lane. No working-tree state was discarded.
The interrupted implementation established private filesystem defaults before
application imports, isolated saved research reports and STT temporary-file
observations, declared optional xdist tooling, and guarded externally launched
smoke tests. The temporary-directory context manager, restoration tests,
subprocess inheritance test, report fixture, and STT fixture were coherent.
The root integration and refusal diagnostics needed additional validation and
correction. None of the recovered files was rejected or replaced wholesale.
The final root wiring preserves the existing generated configuration-reference
locations and does not add test fixture environment reads to the public
configuration inventory. The first serial profile exposed two stale-reference
failures caused by the interrupted wiring. Both are fixed within test code;
`website/configuration-reference.md` and its generator remain unchanged.
The live-smoke guard now runs after marker/shard deselection, before xdist
publishes runnable items. It emits a normal collection failure. Raising a
worker UsageError after notification had produced an xdist internal error and
lost the useful refusal message; the diagnostic probe exposed this and the
final probe proves the intended failure is visible. No smoke request executes
in that refused invocation.
## Resource ownership
`tests/helpers/worker_runtime.py` owns an atomically created temporary root for
each pytest process. The worker label is diagnostic, not an allocation key.
The random suffix prevents two independent invocations of `gw0` from adopting
each other's files. Paths below that root are stable: `data`, `mail`,
`fastembed`, `runtime`, `tmp`, and the controller's `pytest` basetemp. This is
deterministic ownership, not a fixed reusable path. Ordinary serial runs use
the same architecture with label `main`; xdist knowledge stays in the helper
and root hooks.
| Resource | Isolation boundary |
| --- | --- |
| DATABASE_URL and collection engine | Existing foundation forces `sqlite:///:memory:` before import in each process; inherited developer URLs are never opened. |
| File-backed SQLite | Existing fixtures own temporary engines/files, restore bindings without replacing ORM classes, dispose engines before deletion. |
| Postgres | Audited tests use mock dialects/DDL engines; this lane does not connect workers to a shared Postgres database. |
| Runtime data, jobs, publications, process/browser registries | Existing constants derive from private ODYSSEUS_DATA_DIR before collection; narrower fixtures still patch their owned roots. |
| Effects/provenance/evidence | In-memory structures are process-local; file-backed resource publications live under private data/process/browser roots or explicit fixture workspaces. |
| Attachments and embedding cache | Dedicated environment overrides point inside the owned root. |
| Search/cache/generated artifacts | Data-derived caches use the owned data root; explicit artifact fixtures use tmp_path. Installed source/browser assets are read together. |
| Temporary files and shell logs | TMPDIR/TMP/TEMP and cached tempfile.tempdir point into the owned root and are inherited by subprocesses. pytest's basetemp is the controller root's `pytest` directory, with distinct xdist `popen-gw<n>` worker basetemps beneath it. |
| Browser profiles and Unix sockets | Private runtime/data roots; existing short AF_UNIX socket helper owns atomic /tmp directories rather than long pytest paths. |
| Static HTTP servers | Each process holds its kernel-assigned loopback socket; fixed external pins are rejected for parallel execution. Connections get their own threads, so a silent client cannot stall others. |
| Chroma refusal probes | Bound non-listening sockets reserve closed ports until teardown, replacing bind-and-release guesses. |
| Launcher refusal probe | An owned ephemeral listener exercises foreign-server refusal without a host-dependent derived-port skip. Port derivation retains its separate assertions. |
| Process tests | Sleeper fixture owns process sessions/groups, signals the group before reaping its leader, and propagates cleanup errors; it previously killed only the shell and swallowed errors. |
| Module globals/environment | Process-local under xdist; foundation import-state/database restoration guards and test monkeypatches remain intact. |
| Pytest advisory cache | Measurements disable cacheprovider. Built-in worker last-failed/node-ID writers are suppressed by pytest; concurrent controllers should not share advisory metadata. No suite test uses the cache fixture. |
| Live smoke application | Explicit APP_PORT opts into external application ownership; selected live smoke tests require -n 0. Existing no-application skips remain visible. |
Normal cleanup restores the caller's environment/tempfile cache and removes
only the root owned by that context. Cleanup exceptions are not suppressed.
A hard-killed standalone process can leave its root behind; subsequent runs
allocate a different root, so abandoned state is not reused. This is isolation
against crash residue, not a claim that arbitrary SIGKILL removes every file
or process. No global sweeper, kill-by-name, PID-derived namespace, or guessed
per-worker port was added.
The audit found actual filesystem/port/process fixture boundaries to repair,
not a need to serialize ordinary unit tests. Process-local globals alone are
not cross-worker shared state. A delegated broad audit was partial and offered
speculative concerns; those were checked locally rather than accepted as
proven defects.
## Dependencies and release modes
Foundation requirements declared pytest and pytest-asyncio, but not xdist.
The recovered development declaration is retained: `requirements-dev.txt`
includes `requirements.txt` and `pytest-xdist[psutil]>=3.8,<4`. Runtime dependency
requirements remain unchanged. Installed tooling: pytest 9.1.1, xdist 3.8.0,
psutil 7.2.2, Python 3.11. Physical cores: 8; logical CPUs: 16. The psutil extra
makes auto choose physical cores on this host.
Parallelism stays opt-in. The full serial release oracle and four deterministic
CI shards remain unchanged. The two strict negative-web production xfails
remain xfails: memory-only turns are still incorrectly short-circuited with
"Web access is disabled." No production/runtime file, Wave 4 behavior, CI
workflow, or production contract was repaired in this lane.
## Takeover after the interrupted session
Implementation commits `a94fc54c` and `915e6ed1` were inherited at takeover
HEAD `915e6ed12571b0c9036a3546c334c3561443a310`, tree
`6a646603ace8e40e1497212c85ef47b57443f043`. The uncommitted README section and
this report were retained and completed. The inherited commits were audited
against their diffs and kept unchanged.
Two full-suite `-n 2` runs on that HEAD each failed one test. Neither failure
was a product defect, and both are fixed in test infrastructure:
1. `test_legacy_cleanup_against_a_private_real_tmux_server` failed
deterministically under any worker count with `File name too long`. This
lane had moved pytest's default basetemp beneath the private `TMPDIR`,
which gave
`/tmp/ody-main-XXXXXXXX/tmp/pytest-of-<user>/pytest-0/popen-gw0/<test>/tmux.sock`,
110 bytes against Linux's 107-byte AF_UNIX limit. The same path was 87 bytes
before this lane and 100 bytes in a serial run, which is why the serial
profile passed. The controller now sets basetemp to the private root's
`pytest` directory, and xdist hands each worker `popen-gw<n>` beneath it.
That path is now 81 bytes regardless of the username length. An explicit
`--basetemp` is still honoured. `test_worker_runtime.py` binds an AF_UNIX
socket at the same path budget without needing tmux, and fails under
`-n 2` without the fix.
2. With the basetemp fix in place, the next `-n 2` run had a single different
failure. `test_reordering_two_conflicting_declarations_moves_the_digest`
ran 31.7 s instead of about 5.3 s, and node crashed with
`route.fetch: Request context disposed`. That message hides the real
failure, a 30 s `page.goto` stall: the capture's `finally` closed the
browser while a stylesheet `route.fetch` was still pending. The shared
static server was a single-threaded `socketserver.TCPServer`. Chromium
sometimes opens a speculative connection that never sends a request, and
every queued request then waited behind it. A server-instrumented capture
loop under full CPU saturation reproduced it: 4 of 12 captures had a
request-less connection holding the server for about 28.9 s, and those
captures failed. With a threaded server, 12 of 12 captures passed. The idle
connection still appeared in 5 of them but lived 1.0 to 1.5 s without
blocking anything. The fixture now uses `ThreadingTCPServer` with daemon
handler threads. `test_static_server_serves_this_worktree` holds a silent
connection open while it fetches; against the serial server, that request
times out after 5 s. This defect predates the lane and depends on load,
which parallel execution increases. Assertions, timeouts, and the capture
harness are unchanged.
Both fixes keep the existing first-location lines that
`website/configuration-reference.md` records, and that page is unchanged. The
abandoned root of the first `-n 2` run, killed by its monitor, was
`/tmp/ody-main-x7hmcf4_` (261 MB, last written before takeover). No process
held it, and it was removed. No later run left a root behind.
## Measurements
Host: Python 3.11.15, pytest 9.1.1, pytest-xdist 3.8.0, psutil 7.2.2, 8 physical
cores, 16 logical CPUs, 29 GiB RAM. Every run collects the same full suite. The
command is `python -m pytest -q -p no:cacheprovider --durations=100 -rsx
--max-worker-restart=0 -o faulthandler_timeout=60 -n <N>`. The serial
performance-lane profile predates the last option pair. The measurement wrapper
records wall time and descendant RSS. After each run it checks for surviving
descendants (pid plus create time), new TCP listeners, mutated files under
`data/` and the inherited data directory, and leftover `/tmp/ody-main-*`
runtime roots.
| Run | Code | Workers | Wall s | Passed | Failed | Skipped | XFail | Subtests | Peak RSS |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Isolation foundation oracle | `bfa5ebb3` | 0 | — | 12,556 | 0 | 59 | 2 | 6 | — |
| Lane serial profile | pre-`a94fc54c` wiring | 0 | 506.4 | 12,558 | 2* | 59 | 2 | 6 | — |
| full-n2 | `915e6ed1` | 2 | n/a† | — | 1 (`F` marker) | — | — | — | — |
| full-n2-measured | `915e6ed1` | 2 | 262.1 | 12,559 | 1 (tmux) | 59 | 2 | 6 | 5.5 GB |
| full-n2-r2 | + basetemp fix | 2 | 271.1 | 12,560 | 1 (CSS stall) | 59 | 2 | 6 | 6.3 GB |
| full-n2-r3 | final | 2 | 267.6 | 12,561 | 0 | 59 | 2 | 6 | 5.7 GB |
| full-n4-r1 | final | 4 | 155.7 | 12,561 | 0 | 59 | 2 | 6 | 9.1 GB |
| full-n4-r2 | final | 4 | 155.5 | 12,561 | 0 | 59 | 2 | 6 | 8.7 GB |
| serial-final | final | 0 | 496.3 | 12,561 | 0 | 59 | 2 | 6 | 3.9 GB |
\* Stale configuration-reference failures from the interrupted wiring, fixed
before `a94fc54c`. † The monitor crashed on a hardened Chromium process
(`AccessDenied`) after pytest reached 100%, so no summary was retained. Its
single progress-line `F` matches the deterministic tmux failure.
Every run with the final code had no worker crashes, no hangs, no surviving
test descendants, no new listeners, no persistent-state mutations, and no
remaining runtime roots. Passed counts increase only with the lane's added
tests. The skips are the same 59: 16 smoke tests without `APP_PORT` plus
opt-in live and platform gates. The two xfails are the known strict
negative-web production defects.
Monitoring gaps: the wrapper could not read `/proc/<pid>/environ` for two to
four short-lived `python` descendants per run (`AccessDenied`). That only limits
worker-ID observation. Leak detection uses process identity, not the
environment, and reported nothing. Hardened Chromium processes are tolerated
as unreadable instead of aborting the measurement. No unrelated process was
signalled.
### Scaling and recommendation
Compared with the 496.3 s final serial oracle, two workers are 1.85x faster
(267.6 s) and four workers are 3.19x faster (155.6 s mean). Going from two to
four workers is 1.72x faster. Each worker collects 12,622 items in about 11 s
(0.9 GB RSS) before running anything. The longest single test is the
computed-style capture at about 23 s. The auth-concurrency, rich-document
browser, media, and generated-reference groups keep their real work.
Recommended local configuration: `-n 4`. Both full repeats were green and
within 0.3 s of each other, at about 2.2 GB RSS per worker.
`-n auto` (8 workers here) was not run. Projected from the measured per-worker
peak, it needs about 18 GB, against about 21 GB available on this desktop host
while its browser and Chroma services run. Fixed collection cost and the 23 s
longest test bound the best case to roughly 85 to 100 s. The CSS stall above
also showed that browser timing is sensitive to load. That gain does not
justify the risk of swapping or OOM, or of timing distortion. Evaluate six
workers on a host with more headroom before considering `-n auto`.
The full serial run remains the release oracle and the CI shards are
unchanged. Live smoke tests under `APP_PORT` are the only serial-only tests.
They share one external application, its accounts, and its endpoints, and
are refused under xdist.
No new product defect was found. A follow-up for test tooling: when a
`route.fetch` handler is still pending, `tests/css_snapshot/capture.mjs` can
replace a navigation error with `Request context disposed`. Fixing that would
make future capture failures easier to diagnose; it is not needed for
correctness here.
+156
View File
@@ -0,0 +1,156 @@
"""Deterministic, balanced partition of the test suite into parallel shards.
The suite runs ~7.8k tests in a single pytest workload. This module splits that
workload into N sections that CI runs as parallel jobs, so wall-clock time is
bounded by the slowest section rather than by the whole suite.
Two properties matter more than speed, and both are structural here rather than
checked after the fact:
* **Exhaustive and disjoint.** Shard assignment is a total function of the test
*file*, so every test file lands in exactly one shard. No test can be dropped
by a marker typo, and none runs twice. This is deliberately not built on the
``area_*`` taxonomy markers: those are not a partition in practice, because a
test may also carry a hand-applied ``area_*`` mark on top of the one
``tests/conftest.py`` derives from its filename (``test_hwfit_container_
visibility_warning.py`` carries three).
* **Whole files stay together.** Tests in one file share module state and are
written to run in file order, so a file is the smallest unit a shard can hold.
Balance uses the existing ``slow`` marker as its weight signal rather than a
committed duration table that would go stale silently. Packing is greedy
longest-processing-time-first, which is deterministic for a given file set -
every parallel job computes the identical plan from the same commit.
This module imports nothing from the application or from pytest - only the
standard library - so the planner is directly unit-testable. The pytest wiring
lives in ``tests/conftest.py``. See ``tests/README.md``.
"""
from __future__ import annotations
import re
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
# Number of sections CI runs in parallel. Kept here as documentation of the
# intended default; the shard count actually used comes from the --shard value.
DEFAULT_SHARD_COUNT = 4
# Relative cost of one test item. Measured on dev at 2026-10-02 over a full
# `pytest -q --durations=25` run: the five `slow`-marked items average 9.0 s
# each and the remaining 7789 items average 0.018 s, a ratio of roughly 500.
# Exact values do not matter - only that a `slow` item outweighs a whole
# ordinary file, so the packer spreads the slow ones across sections first.
DEFAULT_ITEM_WEIGHT = 1.0
SLOW_ITEM_WEIGHT = 500.0
_SHARD_SPEC_PATTERN = re.compile(r"\A(\d+)/(\d+)\Z")
class ShardSpecError(ValueError):
"""Raised when a ``--shard`` value is not a usable ``N/M`` selector."""
@dataclass(frozen=True)
class ShardSpec:
"""A one-based shard selector: shard ``index`` of ``count``."""
index: int
count: int
@property
def selects_everything(self) -> bool:
"""True when the selector is a no-op (``1/1``) and nothing is deselected."""
return self.count == 1
def __str__(self) -> str:
return f"{self.index}/{self.count}"
def parse_shard_spec(value: str) -> ShardSpec:
"""Parse ``"N/M"`` into a :class:`ShardSpec`.
Rejects anything that would silently run the wrong subset: a malformed
value, a zero or negative part, or an index past the shard count. Surrounding
whitespace is tolerated because CI passes the value through a shell variable.
"""
match = _SHARD_SPEC_PATTERN.match(value.strip())
if match is None:
raise ShardSpecError(
f"invalid shard {value!r}: expected N/M, e.g. 1/{DEFAULT_SHARD_COUNT}"
)
index, count = int(match.group(1)), int(match.group(2))
if count < 1:
raise ShardSpecError(f"invalid shard {value!r}: shard count must be >= 1")
if not 1 <= index <= count:
raise ShardSpecError(
f"invalid shard {value!r}: shard index must be between 1 and {count}"
)
return ShardSpec(index=index, count=count)
def item_weight(is_slow: bool) -> float:
"""Weight of a single test item, by whether it carries the ``slow`` marker."""
return SLOW_ITEM_WEIGHT if is_slow else DEFAULT_ITEM_WEIGHT
def accumulate_file_weights(entries: Iterable[tuple[str, bool]]) -> dict[str, float]:
"""Sum per-item weights into a per-file total.
``entries`` yields ``(file_key, is_slow)`` for each collected test item, so a
file's weight reflects both how many tests it holds and how slow they are.
"""
weights: dict[str, float] = {}
for file_key, is_slow in entries:
weights[file_key] = weights.get(file_key, 0.0) + item_weight(is_slow)
return weights
def plan_shards(
file_weights: Mapping[str, float], count: int
) -> tuple[frozenset[str], ...]:
"""Partition the files of ``file_weights`` into ``count`` balanced shards.
Greedy longest-processing-time-first: heaviest file first, each one placed in
the lightest shard so far. Ties break on the file key and then on the lowest
shard index, so the plan depends only on the input and is identical in every
parallel job. Returns one frozenset per shard, in shard order; shards may be
empty when there are fewer files than shards.
"""
if count < 1:
raise ShardSpecError(f"shard count must be >= 1, got {count}")
buckets: list[set[str]] = [set() for _ in range(count)]
loads = [0.0] * count
# Heaviest first, with the file key as a deterministic tie-break.
ordered = sorted(file_weights.items(), key=lambda item: (-item[1], item[0]))
for file_key, weight in ordered:
target = min(range(count), key=lambda index: (loads[index], index))
buckets[target].add(file_key)
loads[target] += weight
return tuple(frozenset(bucket) for bucket in buckets)
def shard_loads(
file_weights: Mapping[str, float], plan: tuple[frozenset[str], ...]
) -> tuple[float, ...]:
"""Total weight of each shard in ``plan`` - the balance the packer achieved."""
return tuple(
sum(file_weights[file_key] for file_key in bucket) for bucket in plan
)
def relative_file_key(path: str | Path, root: str | Path | None = None) -> str:
"""Stable per-file key: the posix path relative to ``root`` when possible.
Falls back to the absolute posix path when ``path`` lies outside ``root`` or
the relationship cannot be resolved, which keeps the key defined for every
collected item rather than dropping one from the plan.
"""
resolved = Path(path)
if root is not None:
try:
return resolved.resolve().relative_to(Path(root).resolve()).as_posix()
except (OSError, ValueError):
pass
return resolved.as_posix()
+18
View File
@@ -0,0 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { deliveryMessages, researchCardState } from '../static/js/backgroundToolJobs.js';
test('research cards distinguish live work, handoff, completion and no evidence', () => {
assert.match(researchCardState({ status: 'running', rounds: 2, progress: { phase: 'reading', round: 1, total_sources: 3 } }).detail, /Round 1\/2 · 3 sources/);
assert.equal(researchCardState({ status: 'ready' }).label, 'Preparing chat update');
assert.equal(researchCardState({ status: 'delivered', source_count: 4 }).tone, 'done');
assert.equal(researchCardState({ status: 'delivered', outcome: 'no_sources' }).label, 'No sources found');
assert.equal(researchCardState({ status: 'ready', outcome: 'error' }).tone, 'error');
});
test('only new delivered messages append; history reload and repeat polls deduplicate', () => {
const job = { status: 'delivered', message: { content: 'Found it', metadata: { _db_id: 'result-1' } } };
assert.deepEqual(deliveryMessages([job, job, { status: 'running' }], []), [job.message]);
assert.deepEqual(deliveryMessages([job], ['result-1']), []);
assert.deepEqual(deliveryMessages([{ status: 'ready', message: job.message }], []), []);
});
+91
View File
@@ -0,0 +1,91 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
const chat = await readFile(new URL('../static/js/chat.js', import.meta.url), 'utf8');
const documentSource = await readFile(new URL('../static/js/document.js', import.meta.url), 'utf8');
const start = chat.indexOf(' let _ttftDisplayTimer = null;');
const end = chat.indexOf(' const clearFirstTokenWaitTimers =', start);
assert.ok(start >= 0 && end > start);
const statusCode = chat.slice(start, end);
const finishStart = chat.indexOf(' let finishEditorButton = null;');
const finishEnd = chat.indexOf(' let roundFinalized = false;', finishStart);
assert.ok(finishStart >= 0 && finishEnd > finishStart);
const finishCode = chat.slice(finishStart, finishEnd);
test('editor counts use the existing agent status spinner and keep ticking', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.route('http://progress.test/**', async route => {
const path = new URL(route.request().url()).pathname;
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<main></main>' });
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
return route.fulfill({ contentType: 'text/javascript', body: source });
});
await page.goto('http://progress.test/');
const result = await page.evaluate(async code => {
const { create } = await import('/static/js/spinner.js');
const spinner = create('Processing request', 'right', 'wave');
document.querySelector('main').appendChild(spinner.createElement());
const _ttftStartedAt = performance.now() - 54000;
const setup = eval(code + `
_editorProgress = {kind:'suggestions', phase:'preparing'};
_startTtftDisplay();
const preparing = spinner.element.textContent;
_editorProgress = {kind:'suggestions', phase:'drafting', proposed:9};
const agentClass = spinner.element.classList.contains('ai-spinner');
window.stopStatus = _stopTtftDisplay;
({preparing, agentClass});
`);
await new Promise(resolve => setTimeout(resolve, 220));
const proposed = spinner.element.textContent;
window.stopStatus();
return { ...setup, proposed };
}, statusCode);
assert.match(result.preparing, /Reviewing · 54\.\ds/);
assert.match(result.proposed, /9 proposed · 54\.\ds/);
assert.equal(result.agentClass, true);
assert.equal(documentSource.includes('doc-ai-progress'), false);
} finally {
await browser.close();
}
});
test('finish action uses the exact run and keeps the agent thread visible', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const seen = [];
await page.route('http://progress.test/**', async route => {
if (new URL(route.request().url()).pathname === '/') {
return route.fulfill({ contentType: 'text/html', body: '<main></main>' });
}
seen.push({ url: route.request().url(), runId: route.request().headers()['x-odysseus-run-id'] });
return route.fulfill({ contentType: 'application/json', body: '{"accepted":true}' });
});
await page.goto('http://progress.test/');
const result = await page.evaluate(async code => {
const lastToolThread = document.createElement('div');
lastToolThread.className = 'agent-thread';
document.querySelector('main').appendChild(lastToolThread);
const streamSessionId = 'editor-session';
const _streamRunIds = new Map([[streamSessionId, 'exact-run']]);
const API_BASE = '';
const uiModule = { showError() { throw new Error('finish failed'); } };
eval(code + '\nofferFinishEditorTurn();');
const button = lastToolThread.querySelector('button');
const agentStyle = button.classList.contains('continue-btn') && button.classList.contains('resume-btn');
button.click();
await new Promise(resolve => setTimeout(resolve, 60));
return { agentStyle, text: button.textContent, threadVisible: lastToolThread.isConnected };
}, finishCode);
assert.deepEqual(result, { agentStyle: true, text: 'Finishing…', threadVisible: true });
assert.equal(seen.length, 1);
assert.equal(seen[0].runId, 'exact-run');
assert.match(seen[0].url, /\/api\/chat\/finish\/editor-session$/);
} finally {
await browser.close();
}
});
+42
View File
@@ -0,0 +1,42 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const source = await readFile(new URL('../static/js/sessions.js', import.meta.url), 'utf8');
const start = source.indexOf('const _sessionImageDeletionChoices = new Map();');
const end = source.indexOf('export async function deleteCurrentSessionFromTopMenu()', start);
function harness({ counts = [2], answer = true, ok = true } = {}) {
const prompts = [], errors = [];
const ui = { styledConfirm: async (...args) => { prompts.push(args); return answer; }, showError: text => errors.push(text) };
let index = 0;
const fetch = async () => ({ ok, json: async () => ({ image_count: counts[index++] }) });
const api = new Function('fetch', 'uiModule', 'API_BASE', source.slice(start, end) + ';return {confirm: _confirmSessionDeletion, url: _sessionDeletionUrl};')(fetch, ui, '');
return { ...api, prompts, errors };
}
test('chat images are kept by the primary confirmation choice', async () => {
const h = harness();
assert.equal(await h.confirm(['one']), true);
assert.match(h.prompts[0][0], /images in Gallery/);
assert.equal(h.prompts[0][1].confirmText, 'Delete chat only');
assert.equal(h.url('one'), '/api/session/one?delete_images=false');
});
test('explicit image deletion applies to all selected chats once', async () => {
const h = harness({ counts: [0, 3], answer: 'alternate' });
assert.equal(await h.confirm(['one', 'two']), true);
assert.equal(h.prompts.length, 1);
assert.match(h.url('one'), /delete_images=true$/);
assert.match(h.url('two'), /delete_images=true$/);
assert.match(h.url('two'), /delete_images=false$/);
});
test('cancel and failed preview do not authorize deleting images', async () => {
for (const options of [{ answer: false }, { ok: false }]) {
const h = harness(options);
assert.equal(await h.confirm(['one']), false);
assert.match(h.url('one'), /delete_images=false$/);
if (options.ok === false) { assert.equal(h.prompts.length, 0); assert.equal(h.errors.length, 1); }
}
});
test('chats without images get the ordinary confirmation', async () => {
const h = harness({ counts: [0] });
assert.equal(await h.confirm(['one']), true);
assert.equal(h.prompts[0][1].alternateText, undefined);
});
+55
View File
@@ -0,0 +1,55 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
const chat = await readFile(new URL('../static/js/chat.js', import.meta.url), 'utf8');
const renderCode = chat.slice(chat.indexOf(' function _finishProcessingWhenVisible('), chat.indexOf(' let _nextIsError = false;'));
test('processing remains until visible reply content replaces it in the same bubble', async () => {
const browser = await chromium.launch({headless: true});
try {
const page = await browser.newPage();
await page.route('http://handoff.test/**', async route => {
const path = new URL(route.request().url()).pathname;
if (path === '/') return route.fulfill({contentType: 'text/html', body: '<main><div class="msg-ai"><div class="body"><span class="ai-spinner">Processing request</span></div></div></main>'});
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
return route.fulfill({contentType: 'text/javascript', body: source});
});
await page.goto('http://handoff.test/');
const result = await page.evaluate(async renderCode => {
const roundHolder = document.querySelector('.msg-ai');
const spinnerNode = document.querySelector('.ai-spinner');
const spinner = {element: spinnerNode, destroy() { this.element.remove(); this.element = null; }};
Object.assign(window, {
roundHolder, streamSessionId: 'chat', _renderStream: null,
sessionModule: {getCurrentSessionId: () => 'chat'},
_suppressThinkingForPersona: () => false, _docFenceOpened: false,
uiModule: {scrollHistory() {}},
markdownModule: {
squashOutsideCode: text => text,
processWithThinking: text => text.trim() ? `<p>${text}</p>` : '',
},
_ensureStreamLayout(body) {
let content = body.querySelector('.stream-content');
if (!content) {
content = document.createElement('div');
content.className = 'stream-content';
body.appendChild(content);
}
return content;
},
createStreamRenderer: (await import('/static/js/streamingRenderer.js')).createStreamRenderer,
});
const render = new Function('spinner', renderCode + '\nreturn _renderStream;')(spinner);
render({knownNormal: true, displayText: '\n'});
const waiting = spinnerNode.isConnected && roundHolder.innerText.includes('Processing request');
render({knownNormal: true, displayText: '\nHello'});
const visible = document.querySelector('.stream-content');
return {waiting, sameBubble: roundHolder === document.querySelector('.msg-ai'),
spinnerRemoved: !spinnerNode.isConnected, answer: visible.innerText.trim(),
initialFade: !!visible.querySelector('.token-new')};
}, renderCode);
assert.deepEqual(result, {waiting: true, sameBubble: true, spinnerRemoved: true, answer: 'Hello', initialFade: false});
} finally { await browser.close(); }
});
+171
View File
@@ -0,0 +1,171 @@
"""The isolation contract of `odysseus dev`.
The launcher exists so that two checkouts on one machine cannot share
runtime state by accident. Every test here pins one of the guarantees
that makes that true: derived ports never land on a port the project
already means something by, a ChromaDB we did not start is refused
rather than adopted, a checkout wired into a service manager is not
bootable, and nothing is signalled on the strength of a pid alone.
"""
import argparse
import os
import socket
import pytest
from tests.helpers.cli_loader import load_script
@pytest.fixture
def cli():
return load_script("odysseus-dev")
@pytest.fixture
def worktree(tmp_path):
"""A directory shaped enough like a checkout for the launcher to accept it."""
for marker in ("app.py", "setup.py", "requirements.txt"):
(tmp_path / marker).write_text("")
(tmp_path / "venv" / "bin").mkdir(parents=True)
(tmp_path / "venv" / "bin" / "python").write_text("")
return tmp_path
def up_args(**overrides):
defaults = dict(
port=None, chroma_port=None, no_chroma=False, venv=None, from_pr=None,
remote="origin", foreground=False, timeout=5, pretty=False,
)
defaults.update(overrides)
return argparse.Namespace(**defaults)
def test_derived_ports_are_stable_distinct_and_never_reserved(cli):
first = cli.derive_ports("/checkouts/alpha")
assert first == cli.derive_ports("/checkouts/alpha")
assert first != cli.derive_ports("/checkouts/beta")
assert first["chroma"] == first["app"] + 1
assert first["test_static"] == first["app"] + 2
# No path can derive onto a port the project already owns — 7860 is a
# normal start-macos.sh launch, 8100 somebody else's vector store.
for index in range(500):
for port in cli.derive_ports(f"/checkouts/w{index}").values():
assert cli.reserved_reason(port) is None, port
def test_reserved_ports_are_refused_even_when_asked_for(cli, worktree, monkeypatch):
monkeypatch.chdir(worktree)
with pytest.raises(SystemExit):
cli.resolve_ports(worktree, up_args(port=7860))
with pytest.raises(SystemExit):
cli.resolve_ports(worktree, up_args(chroma_port=8100))
def test_root_is_resolved_from_the_working_directory(cli, worktree):
nested = worktree / "static" / "js"
nested.mkdir(parents=True)
assert cli.find_repo_root(nested) == worktree.resolve()
assert cli.find_repo_root(worktree.parent) is None
def test_a_checkout_run_by_a_service_manager_is_not_bootable(cli, worktree, monkeypatch):
units = worktree.parent / "units"
units.mkdir()
(units / "com.odysseus.server.plist").write_text(
f"<plist><string>{worktree.resolve()}/start-macos.sh</string></plist>"
)
assert cli.managed_by_service(worktree, unit_dirs=[units]).endswith(".plist")
unrelated = worktree.parent / "somewhere-else"
unrelated.mkdir()
assert cli.managed_by_service(unrelated, unit_dirs=[units]) is None
monkeypatch.chdir(worktree)
monkeypatch.setattr(cli, "service_unit_dirs", lambda: [units])
with pytest.raises(SystemExit):
cli.cmd_up(up_args())
def test_a_chromadb_we_did_not_start_is_refused_not_adopted(cli, worktree, monkeypatch):
monkeypatch.chdir(worktree)
monkeypatch.setattr(cli, "service_unit_dirs", list)
ports = cli.derive_ports(worktree)
foreign = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
foreign.bind(("127.0.0.1", 0))
ports["chroma"] = foreign.getsockname()[1]
# Port derivation is covered above. This refusal test owns a held socket
# rather than depending on a derived port being free on the host.
monkeypatch.setattr(cli, "derive_ports", lambda _root: ports)
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")
+305 -13
View File
@@ -4,18 +4,17 @@ import os
import types
import importlib.util
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Importing core.database below runs init_db() at import time, and its default
# (sqlite:///./data/app.db) can't be opened in a clean worktree because SQLite
# won't create the missing ./data parent dir - pytest then dies during
# collection, before any test module loads. Default to an in-memory DB for the
# test session so collection is deterministic and writes no repo-local
# artifacts. An explicit DATABASE_URL (a real test/CI database) is preserved.
# This only unblocks collection/import-time init; it does not provide a shared
# file-backed DB across processes - tests needing that must set DATABASE_URL.
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
# Isolate import-time database and filesystem defaults before collection.
# File-backed databases remain fixture-owned. Cleanup restores the caller.
# Keep the existing generated environment-reference locations stable.
_database_environment = pytest.MonkeyPatch()
_database_environment.setenv("DATABASE_URL", "sqlite:///:memory:")
from tests.helpers.worker_runtime import bootstrap_runtime, configure_runtime
_runtime_environment = bootstrap_runtime()
# Pre-import real heavy modules BEFORE any test file's module-level stubs can
# replace them with MagicMock. Some test files (e.g. test_llm_core_sanitize_*)
@@ -61,6 +60,37 @@ if "src.database" not in sys.modules:
# collection, which breaks session import in subsequent tests).
import core.models # noqa: E402
def pytest_addoption(parser):
"""Add ``--shard N/M`` so CI can run the suite as parallel sections."""
group = parser.getgroup("sharding", "parallel test sharding")
group.addoption(
"--shard",
action="store",
default=None,
metavar="N/M",
help=(
"run only shard N of M (1-based), e.g. --shard 1/4. Shards partition "
"the suite by test file, so together they run every test exactly "
"once. See tests/_shards.py."
),
)
def _shard_spec(config):
"""Parse the ``--shard`` option into a ShardSpec, or None when unset."""
from tests._shards import ShardSpecError, parse_shard_spec
value = config.getoption("shard")
if value is None:
return None
try:
return parse_shard_spec(value)
except ShardSpecError as error:
# UsageError fails the run immediately rather than silently running a
# subset nobody asked for - a dropped shard is invisible in a green CI.
raise pytest.UsageError(str(error)) from error
def pytest_configure(config):
"""Register the dynamic taxonomy ``sub_*`` markers before collection.
@@ -69,6 +99,8 @@ def pytest_configure(config):
unknown-mark warnings still surface genuine typos outside the taxonomy. This
only registers marker names; it imports no production module.
"""
config.add_cleanup(_database_environment.undo)
import pathlib
from tests._taxonomy import discover_markers
@@ -78,18 +110,278 @@ def pytest_configure(config):
if marker_name.startswith("sub_"):
config.addinivalue_line("markers", f"{marker_name}: taxonomy sub-area marker")
# Validate --shard before collection so a bad selector fails the run up
# front instead of after a few minutes of collecting.
_shard_spec(config)
def pytest_collection_modifyitems(config, items):
"""Tag each collected test with its taxonomy ``area_*`` and ``sub_*`` markers.
"""Tag each collected test with its taxonomy markers, then apply ``--shard``.
Collection-time only: this adds markers and nothing else. It does not skip,
reorder, or deselect tests, mutate fixtures or the environment, or import any
Tagging is collection-time only: it adds markers and nothing else. It does
not skip, reorder, mutate fixtures or the environment, or import any
production module. See ``tests/_taxonomy.py`` for the classification rules.
Sharding deselects the test files that belong to another shard. It runs
after collection, so every test module is still imported, in the same order,
in every shard - the import-time stubbing above behaves identically whether
the suite runs whole or as one section of it. Only the deselected tests'
call phase is skipped. See ``tests/_shards.py`` for the partition.
"""
import pytest
from tests._taxonomy import markers_for_path
for item in items:
path = getattr(item, "path", None) or item.fspath
for marker_name in markers_for_path(path):
item.add_marker(getattr(pytest.mark, marker_name))
spec = _shard_spec(config)
if spec is None or spec.selects_everything:
return
from tests._shards import accumulate_file_weights, plan_shards, relative_file_key
root = getattr(config, "rootpath", None)
keys = [
relative_file_key(getattr(item, "path", None) or item.fspath, root)
for item in items
]
weights = accumulate_file_weights(
(key, item.get_closest_marker("slow") is not None)
for key, item in zip(keys, items)
)
selected_files = plan_shards(weights, spec.count)[spec.index - 1]
selected, deselected = [], []
for key, item in zip(keys, items):
(selected if key in selected_files else deselected).append(item)
if deselected:
config.hook.pytest_deselected(items=deselected)
items[:] = selected
@pytest.fixture(scope="session", autouse=True)
def _serve_test_static():
"""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
root_dir = Path(__file__).resolve().parent.parent
class _Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(root_dir), **kwargs)
def log_message(self, format, *args):
pass
def guess_type(self, path):
if path.endswith(".js") or path.endswith(".mjs"):
return "application/javascript"
if path.endswith(".css"):
return "text/css"
return super().guess_type(path)
class _Server(socketserver.ThreadingTCPServer):
allow_reuse_address = daemon_threads = True
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
# One thread per connection: Chromium can hold a speculative connection
# open without a request, which stalled serial service for ~30s.
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
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()
@pytest.fixture(scope="session")
def _effects_store_root(tmp_path_factory):
return tmp_path_factory.mktemp("effects")
@pytest.fixture(autouse=True)
def _isolated_effects_store(_effects_store_root):
"""Keep durable effect claims out of the developer's real data directory.
Restored manually: requesting the shared ``monkeypatch`` here would move
its teardown after ``_no_leaked_module_stubs`` and misreport test stubs.
"""
from src.agent_runtime import effect_log
previous = effect_log.EFFECTS_DIR
effect_log.EFFECTS_DIR = str(_effects_store_root)
try:
yield
finally:
effect_log.EFFECTS_DIR = previous
@pytest.fixture(autouse=True)
def _no_leaked_module_stubs():
"""Fail the test that leaves a bare ``src.*``/``core.*`` stub behind.
Several test modules install empty stand-in modules so an import-heavy
production module can be loaded under the mocks above. When one of those
writes is not undone, the stub stays in ``sys.modules`` for the rest of the
session and every later test that imports the real module silently gets an
empty one instead. The suite still passes as a whole, because the victims
usually run before the leak; it only breaks under a different collection
order, which is why this class of bug reaches CI green.
This fixture is declared in the root conftest, so it is set up before any
test-module fixture and torn down after all of them — a stub that a test's
own teardown removes is not reported. The leaked entries are dropped here
as well as reported, so the failure stays attributed to the test that
introduced it instead of cascading into the rest of the run.
Bare stubs present before the test starts are ignored: this guards against
new leaks, it does not police import state the session began with.
"""
from tests.helpers.import_state import bare_module_stubs, clear_module
before = bare_module_stubs()
yield
leaked = sorted(bare_module_stubs() - before)
if not leaked:
return
for name in leaked:
clear_module(name)
pytest.fail(
"test left bare module stub(s) in sys.modules: "
+ ", ".join(leaked)
+ ". Register the stub through monkeypatch.setitem(sys.modules, ...) "
"or tests.helpers.import_state.preserve_import_state so it is undone "
"at teardown.",
pytrace=False,
)
@pytest.fixture(autouse=True)
def _no_context_window_network_probe(request):
"""Keep the turn context-window resolver offline in tests.
Compact turns resolve their window before the first model request, and
most tests drive them with placeholder endpoints. Only the resolver's two
I/O edges are replaced: URL resolution (DNS/Tailscale lookups) and its
HTTP client, which records each attempted metadata request and fails it
as a transport error. Everything else (route wiring, caching, credential
scoping, evidence selection) runs for real, so an unintended extra probe
stays visible through the ``context_probe_ledger`` fixture.
Modules that install their own fake client opt out with a module-level
``CONTEXT_PROBE_NETWORK = True``.
"""
ledger = []
if getattr(request.module, "CONTEXT_PROBE_NETWORK", False):
yield ledger
return
try:
from src.agent_runtime import context_resolution
except Exception:
yield ledger
return
class _OfflineMetadataClient:
def __init__(self, timeout=None):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def get(self, url, headers=None):
ledger.append({"url": url, "headers": dict(headers or {})})
raise context_resolution.httpx.ConnectError("network disabled in tests")
def _offline_provider_urls(endpoint_url):
base = endpoint_url.split("/v1")[0] if "/v1" in endpoint_url else endpoint_url.rstrip("/")
return base + "/v1/models", endpoint_url
# A private patcher keeps the shared ``monkeypatch`` fixture's teardown
# order unchanged for tests that check their own sys.modules hygiene.
patcher = pytest.MonkeyPatch()
patcher.setattr(context_resolution, "_http_client", _OfflineMetadataClient)
patcher.setattr(context_resolution, "_provider_urls", _offline_provider_urls)
context_resolution.clear_probe_cache()
try:
yield ledger
finally:
patcher.undo()
context_resolution.clear_probe_cache()
@pytest.fixture
def context_probe_ledger(_no_context_window_network_probe):
"""Metadata requests the context resolver attempted during this test."""
return _no_context_window_network_probe
# Before pytest's tmpdir plugin reads the basetemp this sets.
@pytest.hookimpl(specname="pytest_configure", tryfirst=True)
def pytest_configure_worker_runtime(config):
configure_runtime(config, _runtime_environment)
@pytest.hookimpl(specname="pytest_collection_modifyitems", tryfirst=True)
def pytest_collection_worker_runtime(items):
# Mark before pytest applies -m. The final guard also respects --shard.
for item in items:
if "smoke" in item.path.parts:
item.add_marker(pytest.mark.serial)
@pytest.hookimpl(tryfirst=True)
def pytest_collection_finish(session):
"""Refuse shared live resources after marker and shard deselection."""
config = session.config
parallel = bool(getattr(config.option, "numprocesses", None)) or hasattr(config, "workerinput")
if (os.environ.get("APP_PORT") and parallel
and any(item.get_closest_marker("serial") for item in session.items)):
message = (
"live smoke tests share one external application, accounts, and endpoints; "
"run tests/smoke with -n 0"
)
# A worker UsageError here races xdist's collection notification and
# can lose its message. Emit a normal collection failure before xdist
# sees any runnable items. Nothing may contact the external instance.
config.hook.pytest_collectreport(report=pytest.CollectReport(
nodeid="tests/smoke", outcome="failed", longrepr=message, result=[],
))
session.items.clear()
+47
View File
@@ -0,0 +1,47 @@
"""Captured spawns exercise the production runner without signalling fake PIDs."""
import asyncio
import json
import os
from types import SimpleNamespace
from src import containment
def capture_owned_spawn(monkeypatch, tmp_path):
captured = {}
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path.parent / (tmp_path.name + "-control") / "grants.json")
monkeypatch.setattr(containment, "_pgid_of", lambda pid: pid)
async def fake_exec(*argv, **kwargs):
captured.update(argv=argv, kwargs=kwargs, command=argv[-1])
if "--info-fd" in argv:
fd = int(argv[argv.index("--info-fd") + 1])
os.write(fd, json.dumps({"child-pid": 99999998}).encode())
stdout = asyncio.StreamReader()
if "ody-boundary" in argv:
stdout.feed_data((argv[argv.index("ody-boundary") + 1] + "\n").encode())
stdout.feed_data(b"ok")
stdout.feed_eof()
stderr = asyncio.StreamReader()
stderr.feed_eof()
async def wait():
return 0
async def drain():
return None
writer = SimpleNamespace(write=lambda data: None, drain=drain,
close=lambda: captured.update(stdin_closed=True))
return SimpleNamespace(pid=99999999, stdout=stdout, stderr=stderr,
stdin=writer, returncode=0, wait=wait)
async def release(*args, **kwargs):
return containment.ReleaseOutcome(dead=True, escalated=False)
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
real_capture = containment.process_ownership.capture
monkeypatch.setattr(containment.process_ownership, "capture", lambda pid:
{"pid": pid, "start_token": "captured-fake"} if pid in (99999998, 99999999) else real_capture(pid))
if hasattr(containment.os, "pidfd_open"):
monkeypatch.delattr(containment.os, "pidfd_open")
monkeypatch.setattr(containment, "_release_awaited", release)
return captured
+153
View File
@@ -0,0 +1,153 @@
# Computed-style snapshot harness
The app CSS is an ordered multi-file cascade. 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 the app cascade 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;
- removes `autofocus` before parsing: focus states are outside this inventory,
and the browser's asynchronous autofocus step otherwise races the capture;
- pauses CSS animations at time zero and finishes CSS transitions before each
measurement, including newly revealed modals and newly mounted bench nodes.
Animation and transition declarations are still captured; the harness does
not inject `animation: none` or `transition: none`;
- pins Chromium's standard font preference to `Times New Roman` via CDP,
without overriding any author declaration;
- canonicalizes only the `BlinkMacSystemFont` family token to `"system-ui"`,
the spelling Chromium uses for that alias on macOS. Other family names and
their order remain significant;
- measures the `custom-system-prompt` element's `max-height` in `lh`, as opted
into by its inventory entry. Its authored `30lh` resolves to different pixel
heights with different fallback fonts; the line count remains significant;
- 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.
- **Browser upgrades and additional platforms.** The original macOS baseline
and Linux captures were compared property by property through exact hash
recovery; the proven platform differences are now controlled above. A new
capture on macOS has not been performed. New browser serialization changes
still need investigation rather than automatic baseline regeneration.
- **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.
`test_capture_is_independent_of_elapsed_time_and_font_metrics` perturbs capture
timing and font metrics, checks autofocus suppression, and proves that animation
keyframes, metadata and relative line counts still affect measurements. The
existing cascade-order self-test still detects a reordered declaration.
The PR #40 canonical baseline and its exact justification are documented in
[`pr40-validation.md`](pr40-validation.md). All 122 properties and 676 inventory
elements remain covered; only one element/property opts into line-relative
measurement.
+767
View File
@@ -0,0 +1,767 @@
{
"digest": "9868d50a542b7ad1",
"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": "3ebe8cb381e35746",
"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": "6a0ce314ee2fb9cf",
"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": "2a07cdd8ec2b4d26",
"rename-session-modal": "9345624780009d14",
"root": "820190c144fda419",
"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": "7024d434f65886c9",
"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": "1b4f2e6dc6aa2ec6",
".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": "459d17627f2e83e6",
".ge-layers-list": "0b4bfbd17d414d6d",
".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": "bb0f88ce29ae7637",
".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": "6f8529bca11d0e80",
"setup-note": "028127c742bf5f87",
"submit": "5e371dbe68196bfd",
"toggle-link": "289ae7be0ef6e564",
"username": "42f0c35485f59dea",
"version-label": "cc443cc7790ff3b4"
}
},
"variants": {
"app-shell": {
"desktop-dark-comfortable": "65b23a35b5a34126",
"desktop-dark-compact": "1bac3a0aa4467555",
"desktop-dark-spacious": "944583b1854fecbb",
"desktop-light-comfortable": "d279432f8c120b58",
"desktop-light-compact": "aa768c34ba3c7abc",
"desktop-light-spacious": "b74f53e95bfa1d82",
"laptop-dark-comfortable": "d98d16f3d60ed275",
"laptop-dark-compact": "c3eaf3c4278a0120",
"laptop-dark-spacious": "a085fe0f7d9eb66c",
"laptop-light-comfortable": "0d7c64f324bebe87",
"laptop-light-compact": "da50bf03b0f1be58",
"laptop-light-spacious": "a6789fd05a66bf5a",
"phone-dark-comfortable": "21b94f88da549eb5",
"phone-dark-compact": "b9aa7def2b85414e",
"phone-dark-spacious": "21b94f88da549eb5",
"phone-light-comfortable": "4a0cf45db775ea7d",
"phone-light-compact": "f7fbcb94d99c8730",
"phone-light-spacious": "4a0cf45db775ea7d",
"tablet-dark-comfortable": "e18256ce58398f11",
"tablet-dark-compact": "3cf3e52a10313d44",
"tablet-dark-spacious": "e18256ce58398f11",
"tablet-light-comfortable": "c2a45155c391f26c",
"tablet-light-compact": "9fa7dc03dd02b723",
"tablet-light-spacious": "c2a45155c391f26c"
},
"bench": {
"desktop-dark-comfortable": "f9fa9d54db5ad45a",
"desktop-dark-compact": "7603a804cb2e23a2",
"desktop-dark-spacious": "b0c69306349fc078",
"desktop-light-comfortable": "d3e98c4c1e90686f",
"desktop-light-compact": "a04ae70088bc8b35",
"desktop-light-spacious": "4aa01a125f541429",
"laptop-dark-comfortable": "025e60ebe84e0f98",
"laptop-dark-compact": "53d6555bb7a6b5bb",
"laptop-dark-spacious": "c124d9ce1633d027",
"laptop-light-comfortable": "e8328c45f5127bb0",
"laptop-light-compact": "dc2da3534534c888",
"laptop-light-spacious": "abe5483724c987e0",
"phone-dark-comfortable": "67ca3c78e6b9495a",
"phone-dark-compact": "ad6f3a9f5c8d81cd",
"phone-dark-spacious": "1e6f66e56519ba5d",
"phone-light-comfortable": "ef876073cc2dc45e",
"phone-light-compact": "2ef6f5dc7a646be9",
"phone-light-spacious": "b0d4cd9bd0361966",
"tablet-dark-comfortable": "58922240b216e786",
"tablet-dark-compact": "3991c22f99a394e0",
"tablet-dark-spacious": "9848c3b7debac974",
"tablet-light-comfortable": "12dfbad36172de20",
"tablet-light-compact": "80d4843e622f1353",
"tablet-light-spacious": "c420b8d46c991a44"
},
"login": {
"desktop-dark-comfortable": "3cf4809db0f8e119",
"desktop-dark-compact": "3cf4809db0f8e119",
"desktop-dark-spacious": "3cf4809db0f8e119",
"desktop-light-comfortable": "3cf4809db0f8e119",
"desktop-light-compact": "3cf4809db0f8e119",
"desktop-light-spacious": "3cf4809db0f8e119",
"laptop-dark-comfortable": "1e6b6522bcb4a942",
"laptop-dark-compact": "1e6b6522bcb4a942",
"laptop-dark-spacious": "1e6b6522bcb4a942",
"laptop-light-comfortable": "1e6b6522bcb4a942",
"laptop-light-compact": "1e6b6522bcb4a942",
"laptop-light-spacious": "1e6b6522bcb4a942",
"phone-dark-comfortable": "e4b7998ae024fdaf",
"phone-dark-compact": "e4b7998ae024fdaf",
"phone-dark-spacious": "e4b7998ae024fdaf",
"phone-light-comfortable": "e4b7998ae024fdaf",
"phone-light-compact": "e4b7998ae024fdaf",
"phone-light-spacious": "e4b7998ae024fdaf",
"tablet-dark-comfortable": "321622b649b2f151",
"tablet-dark-compact": "321622b649b2f151",
"tablet-dark-spacious": "321622b649b2f151",
"tablet-light-comfortable": "321622b649b2f151",
"tablet-light-compact": "321622b649b2f151",
"tablet-light-spacious": "321622b649b2f151"
}
}
}
+40
View File
@@ -0,0 +1,40 @@
<!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/css/00-tokens.css">
<link rel="stylesheet" href="/static/css/01-agent-chat.css">
<link rel="stylesheet" href="/static/css/02-compare.css">
<link rel="stylesheet" href="/static/css/03-agent-chat.css">
<link rel="stylesheet" href="/static/css/04-memory.css">
<link rel="stylesheet" href="/static/css/05-documents.css">
<link rel="stylesheet" href="/static/css/06-admin-settings.css">
<link rel="stylesheet" href="/static/css/07-documents.css">
<link rel="stylesheet" href="/static/css/08-skills.css">
<link rel="stylesheet" href="/static/css/09-gallery.css">
<link rel="stylesheet" href="/static/css/10-cookbook.css">
<link rel="stylesheet" href="/static/css/11-tasks.css">
<link rel="stylesheet" href="/static/css/12-gallery.css">
<link rel="stylesheet" href="/static/css/13-image-editor.css">
<link rel="stylesheet" href="/static/css/14-email.css">
<link rel="stylesheet" href="/static/css/15-notes.css">
<link rel="stylesheet" href="/static/css/16-calendar.css">
<link rel="stylesheet" href="/static/css/17-research.css">
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css">
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css">
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.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>
+325
View File
@@ -0,0 +1,325 @@
// 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) {
// The selector is not in this sheet, or appears once. The stylesheet is
// split across several files, so that is expected for most of them: the
// caller decides whether any sheet matched at all.
return null;
}
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, lineRelativeProperties = []) {
// Style/layout is flushed when querying animations. Sample CSS animations
// at the start, and settle transitions to their destination. WAAPI timing
// overrides leave animation/transition declarations in getComputedStyle.
for (const animation of document.getAnimations()) {
if (animation instanceof CSSTransition) animation.finish();
else {
animation.pause();
animation.currentTime = 0;
}
}
const cs = getComputedStyle(el, pseudo || undefined);
const values = {};
for (const prop of properties) {
let value = cs.getPropertyValue(prop);
// Chromium on macOS serializes this alias as a quoted system-ui family;
// Linux preserves the alias spelling. Keep every other family and order.
if (prop === 'font-family') {
value = value.replace(/(^|,\s*)BlinkMacSystemFont(?=\s*(?:,|$))/g, '$1"system-ui"');
}
values[prop] = value;
}
if (lineRelativeProperties.length) {
// `normal` line-height uses the installed fallback font's metrics. Measure
// one lh with this element's font, then retain the authored line count
// rather than the platform's pixel height. Only inventory opt-ins use it.
const ruler = document.createElement('div');
ruler.style.cssText = 'all:initial;position:absolute;left:-10000px;height:1lh;';
for (const prop of ['font-family', 'font-size', 'font-weight', 'font-style',
'font-stretch', 'font-variant', 'line-height']) {
ruler.style.setProperty(prop, cs.getPropertyValue(prop));
}
document.body.appendChild(ruler);
const lineHeight = parseFloat(getComputedStyle(ruler).height);
ruler.remove();
for (const prop of lineRelativeProperties) {
if (values[prop]?.endsWith('px')) {
values[prop] = `${Number((parseFloat(values[prop]) / lineHeight).toFixed(6))}lh`;
}
}
}
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,
entry.lineRelativeProperties || []);
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 {
let swapped = 0;
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();
const cdp = await context.newCDPSession(tab);
// Pin the UA standard font preference rather than overriding author
// CSS. macOS defaults to Times; Linux defaults to Times New Roman.
await cdp.send('Page.setFontFamilies', { fontFamilies: { standard: 'Times New Roman' } });
// 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) {
// The cascade is spread over several files, so find the one that
// actually holds two top-level blocks of the selector and rewrite
// only that one. Every other sheet passes through untouched.
await tab.route('**/static/**/*.css*', async route => {
const response = await route.fetch();
const original = await response.text();
const body = swapRuleOccurrences(original, job.swapRule);
if (body === null) return route.fulfill({ response, body: original });
swapped += 1;
await route.fulfill({ response, body, headers: { ...response.headers(), 'content-type': 'text/css; charset=utf-8' } });
});
}
const documentPath = page.url.split('?')[0];
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, '');
// Focus states are outside this idle-state inventory. Autofocus can
// run after load, racing the measurement and changing outline-offset.
html = html.replace(/(<[^>]*?)\sautofocus(?=[\s=>])(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, '$1');
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'}`);
}
if (job.measurementDelayMs) await tab.waitForTimeout(job.measurementDelayMs);
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();
}
}
if (job.swapRule && swapped === 0) {
throw new Error(`swap-rule: no stylesheet had two top-level blocks for "${job.swapRule}"`);
}
} 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
+190
View File
@@ -0,0 +1,190 @@
# PR #40 final harness validation
Starting branch: `review/pr40-final-lab-validation`.
Starting HEAD: `6f14439e4179b8ad674660e59ca0bea96f1a6ead` (clean).
This cleanup changes snapshot tooling, test harnesses, baselines and documentation.
It changes no application production implementation.
## Reproduction and root cause
Both the computed-style baseline failure and the obsolete markdown VM loader
failure reproduced on the reconciled branch and preserved lab source at
`fff55a786c5c40daebf64d1a12b576d6c889b1a3`. The snapshot mismatch also reproduced
on the original recording revision, `73b1726d`. No canonical lab checkout was used.
Chromium: `151.0.7922.34`; Node: `22.23.1`; Python: `3.12.3`.
All 42 pre-existing Linux/macOS element-hash mismatches were explained by exact
recovery of the committed hashes from raw captures:
| Elements | Proven difference |
|---|---|
| Two document roots | Default font-family `Times` on macOS versus `"Times New Roman"` on Linux |
| 39 elements | `BlinkMacSystemFont` serializes as `"system-ui"` on macOS |
| `custom-system-prompt` | The same alias difference, plus authored `30lh` resolving to `480px` on macOS and `450px` on Linux |
Independent repeated unmodified captures also changed the message textarea outline
offset from `0px` to `2px`: asynchronous autofocus races measurement. Animations
also depend on elapsed time; an idle cascade inventory needs a defined sample time.
## Canonical snapshot contract
- Pin the UA standard font preference through CDP; author declarations still win.
- Normalize only the proven unquoted BlinkMacSystemFont family token; preserve all other families and order.
- Remove autofocus before parsing; focus states were already outside this inventory.
- Pause CSS animations at time zero and finish transitions; keep their computed declarations.
- Opt only `custom-system-prompt/max-height` into line-relative measurement; the `30lh` line count remains significant.
- Preserve all 122 properties, 676 elements and 24 variants.
Two full controlled captures, separated by a 150ms measurement delay on every
page/variant, were byte-identical. Their canonical digest is
`9868d50a542b7ad1`. There were no missing inventory entries. The controlled
lab/merged comparison still differs on exactly five intended PR #40 elements:
| Element | Intended reconciled change |
|---|---|
| app-shell/reasoning-effort-btn | Moved control to the Chat Context home; visibility changes |
| bench/.reasoning-effort-prefix | Removed old narrow composer hiding rule |
| bench/.doc-suggestion-card | Responsive overflow, display and minimum sizing |
| bench/.ge-layers-header | Wrapping layer controls |
| bench/.ge-layers-list | Minimum height and bottom padding |
Only 11 element hashes change from the old committed baseline: these five, the
two pinned root fonts, the two autofocus controls (message and login username),
welcome-screen at the defined animation start, and the line-relative textarea cap.
The other 665 element hashes are unchanged. The new baseline was written from the
proven identical full captures, not from an unexplained failing local capture.
The new browser self-test perturbs timing and font metrics, checks autofocus
suppression, retains animation/transition metadata, distinguishes 30lh from 31lh,
and detects changed animation keyframes. The existing cascade-order test still
detects reordering conflicting declarations.
## Markdown and environment reference
The standalone codefence script previously stripped imports/exports with regexes
and evaluated the result as a classic VM script. Its ui.js pattern missed the
versioned ES-module import. It now uses the existing streaming markdown ESM loader
and keeps its original regression assertions. A Python wrapper gates it in normal pytest.
No production markdown.js change was needed.
The env-reference suite passed before edits (13 tests). Adding capture timing
support moved the snapshot tooling environment read from line 249 to 254. The
generator was rerun only after the resulting reference mismatch was proven; its
diff changes exactly that one location.
The current fetched-page contract was verified before edits: the provider-facing
schema omits top-level anyOf for provider compatibility; compact preview validation
requires url or urls. `test_fetch_requires_a_page_in_full_and_compact_contracts` and
its whole targeted module passed (10 tests).
## Validation
Tests use a fresh isolated virtual environment installed from `requirements.txt`:
`/tmp/pr40-final-validation-venv`. Dependency consistency: all 107 packages compatible.
Optional PyMuPDF and openpyxl remain absent. Their attachment skips are explicit
importorskip contracts; PyMuPDF and spreadsheet extraction extras are optional in
requirements-optional.txt. Live endpoint tests require explicit opt-in fixtures.
- Required snapshot/CSS/markdown/env/schema/attachment gates: **138 passed, 3 intentional skips**.
- Additional CSS and streaming Python gates: **11 passed**.
- Reconstructed runtime suite: **3543 passed, 32 intentional skips, 2 expected xfails**, 173 files.
- Runtime selection: changed reconciliation test modules, tests matching changed production stems, turn contract/tool/browser/runtime/markdown/CSS suites and model-tool-mode, preview recovery, form roundtrip and env-reference gates.
- Expected xfails: two pre-existing negative-web-wording cases in test_runtime_behavior_regressions.py; their markers document partially detected negative instructions on lab.
- Python compileall: 1680 tracked Python files passed.
- Node syntax checks: 279 tracked .js files and 82 tracked .mjs files passed.
- git diff --check passed; conflict-marker scan found no tracked files with conflict markers.
## Every executable tests .mjs gate
Invoked with `node --experimental-vm-modules`; browser fixtures route locally or
use isolated DOMs. The live email UI fixture was not supplied.
| File | Result |
|---|---|
| `tests/backgroundToolJobs.test.mjs` | PASS |
| `tests/chatEditorProgress.test.mjs` | PASS |
| `tests/chatImageDeletion.test.mjs` | PASS |
| `tests/chatProcessingHandoff.test.mjs` | PASS |
| `tests/documentSelectionCaret.mjs` | PASS |
| `tests/editor-ai-cancel.mjs` | PASS |
| `tests/editor-layer-styles.mjs` | PASS |
| `tests/editorRichUpdate.mjs` | PASS |
| `tests/editorSuggestionApply.mjs` | PASS |
| `tests/editorSuggestionButtons.mjs` | PASS |
| `tests/emailReplyBrowser.test.mjs` | PASS; skipped 1 (opt-in UI fixture absent) |
| `tests/emailReplyStream.test.mjs` | PASS |
| `tests/generatedImageResult.test.mjs` | PASS |
| `tests/historyResumeRendering.test.mjs` | PASS |
| `tests/live_thinking_scheduler.test.mjs` | PASS |
| `tests/markdown_codefence_placeholder_regression.mjs` | PASS |
| `tests/noteTestOracle.test.mjs` | PASS |
| `tests/notesDraftAutosave.test.mjs` | PASS |
| `tests/researchMobileButtons.test.mjs` | PASS |
| `tests/schemaThinkingProbe.test.mjs` | PASS |
| `tests/sidebarNewChat.test.mjs` | PASS |
| `tests/skillsApproval.test.mjs` | PASS |
| `tests/toolFollowupOracle.test.mjs` | PASS |
| `tests/tool_followup_oracle.test.mjs` | PASS |
| `tests/turnRendering.test.mjs` | PASS |
| `tests/streaming/invariant.test.mjs` | PASS |
| `tests/streaming/segmenter.test.mjs` | PASS |
| `tests/helpers/test_settings_shell_coordinator.mjs` | PASS |
`tests/css_snapshot/capture.mjs` ran repeatedly with valid capture jobs through
the snapshot gate, including all 72 page/variant combinations. Other helpers
(document_source.mjs, stylesheets.mjs, streaming/corpus.mjs and markdownHarness.mjs)
are imported support modules, not standalone gates.
## Remaining limits
A new macOS capture was not available. Cross-platform normalization is backed by
exact old macOS hash recovery and controlled Linux captures, with no broad property
exclusions. Browser upgrades may introduce new serialization differences requiring
fresh investigation. Optional/live fixtures were intentionally skipped. Unused
`.session-run-state` CSS remains follow-up debt; it was not removed.
Full canonical pytest was run after every code/test edit in the fresh requirements environment.
Command: `DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rsx`.
Result: **11396 passed, 53 skipped, 2 xfailed, 185 warnings, 6 subtests passed in 451.08s (0:07:31)**.
Declared skip/xfail contracts:
```text
SKIPPED [1] tests/smoke/test_calendar_smoke.py:16: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_chat_smoke.py:20: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_chat_smoke.py:35: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_chat_smoke.py:59: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_compare_smoke.py:39: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_cookbook_smoke.py:18: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_cookbook_smoke.py:31: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_cookbook_smoke.py:41: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_documents_rag_smoke.py:31: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_documents_smoke.py:12: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_email_smoke.py:75: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_memory_smoke.py:19: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_notes_smoke.py:11: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_settings_smoke.py:16: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_tasks_smoke.py:16: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/smoke/test_uploads_smoke.py:11: APP_PORT is not set, so there is no instance to drive. Run the suite with `scripts/odysseus-smoke`, which boots this worktree and exports it.
SKIPPED [1] tests/test_ajax_email_live.py:21: opt-in live Ajax endpoint
SKIPPED [5] tests/test_ajax_email_live.py:55: opt-in live Ajax endpoint
SKIPPED [12] tests/test_ajax_email_live.py:74: opt-in live Ajax endpoint
SKIPPED [2] tests/test_ajax_email_live.py:141: opt-in live Ajax endpoint
SKIPPED [8] tests/test_ajax_email_live.py:175: opt-in live Ajax endpoint
SKIPPED [1] tests/test_cookbook_helpers.py:875: Windows Ollama CLI startup guard
SKIPPED [1] tests/test_email_attachment_text.py:19: could not import 'fitz': No module named 'fitz'
SKIPPED [1] tests/test_email_attachment_text.py:41: could not import 'openpyxl': No module named 'openpyxl'
SKIPPED [1] tests/test_email_attachment_text.py:110: Opt-in Ajax fixture test
SKIPPED [1] tests/test_inspect_media_tool.py:574: needs an ffmpeg built without webp
SKIPPED [1] tests/test_inspect_media_tool.py:934: rsvg-convert required
SKIPPED [1] tests/test_markitdown_runtime.py:64: could not import 'markitdown': No module named 'markitdown'
SKIPPED [1] tests/test_result_reference_followup.py:96: Opt-in live Ajax replay
SKIPPED [1] tests/test_upload_content_detection_magic.py:41: libmagic/python-magic not installed in this environment
XFAIL tests/test_runtime_behavior_regressions.py::test_negative_web_wording_withholds_the_web_tools_unhandled[Summarise what you already know. Do not search the web.] - negative web wording is only partially detected on lab@c499c01b; these phrasings still get the web tools offered
XFAIL tests/test_runtime_behavior_regressions.py::test_negative_web_wording_withholds_the_web_tools_unhandled[No web search please, just tell me what you know about Python decorators.] - negative web wording is only partially detected on lab@c499c01b; these phrasings still get the web tools offered
```
Final commit SHA and clean status are reported in the session completion message.
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import { documentSource } from './helpers/document_source.mjs';
import { chromium } from 'playwright';
const source = documentSource();
const start = source.indexOf(' function clearSelection(');
const cleanup = source.slice(start, source.indexOf(' function clearSelectionAt(', start));
assert.equal((source.match(/if \(_selections.length\) clearSelection\(\{ preserveCaret: true \}\);/g) || []).length, 2);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setContent('<div id="rich" contenteditable="true">before SELECT after</div><span id="doc-selection-badge"></span>');
await page.evaluate(cleanup => {
let _selections = [{ kind: 'rich' }];
const _richSelectionHighlightName = 'test-selection';
const rich = document.getElementById('rich');
const _emailRichbodyActive = () => rich;
const _scheduleDocumentStats = () => {};
eval(cleanup + '\nwindow.clearPinned = clearSelection;');
rich.addEventListener('input', () => {
if (_selections.length) window.clearPinned({ preserveCaret: true });
});
rich.focus();
const range = document.createRange();
range.setStart(rich.firstChild, 7);
range.setEnd(rich.firstChild, 13);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}, cleanup);
await page.keyboard.type('new');
const result = await page.evaluate(() => ({
text: document.getElementById('rich').textContent,
offset: window.getSelection().anchorOffset,
collapsed: window.getSelection().isCollapsed,
ranges: window.getSelection().rangeCount,
badge: document.getElementById('doc-selection-badge').style.display,
}));
assert.equal(result.text, 'before new after');
assert.equal(result.offset, 10);
assert.equal(result.collapsed, true);
assert.equal(result.ranges, 1);
assert.equal(result.badge, 'none');
await page.evaluate(() => window.clearPinned());
assert.equal(await page.evaluate(() => window.getSelection().rangeCount), 0);
console.log('PASS: replacement typing preserves caret; explicit clear still removes selection.');
} finally {
await browser.close();
}
+15
View File
@@ -0,0 +1,15 @@
# Browser End-to-End Tests
The photo editor release gate launches an isolated, authentication-disabled
Odysseus server on port `7013` with a temporary SQLite database.
```bash
npm install
npm run test:photo-editor:install
npm run test:photo-editor
```
Set `PHOTO_EDITOR_E2E_PORT` to use another port. Failure screenshots and traces
are written to `test-results/photo-editor/`. The server uses the repository
`.venv` automatically; set `ODYSSEUS_TEST_PYTHON` to another Python executable
when using a different environment layout.
@@ -0,0 +1,367 @@
const { test, expect } = require('@playwright/test');
const {
dragOnCanvas,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click();
const menu = page.locator('.ge-add-layer-menu');
await expect(menu).toBeVisible();
await menu.locator(`[data-adjustment-type="${type}"]`).click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
}
test('Levels is a retained stack layer with clipping, masks, history, and persistence', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 260 }, 'Adjustment layers E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.18, y: 0.25 }, { x: 0.78, y: 0.7 });
const before = await flattenedPixelDigest(page);
await addAdjustment(page, 'levels');
await page.locator('.ge-adj-channel-select').selectOption('red');
await page.locator('.ge-adj-row input[data-key="outWhite"]').fill('128');
await page.locator('.ge-adj-row input[data-key="outWhite"]').dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
let current = await editorState(page);
const levels = current.layers.find(layer => layer.kind === 'adjustment');
expect(levels.adjustment.type).toBe('levels');
expect(levels.adjustment.params.channels.red.outWhite).toBe(128);
expect(await flattenedPixelDigest(page)).not.toEqual(before);
const row = page.locator(`.ge-layer-item[data-layer-id="${levels.id}"]`);
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).clipped).toBe(true);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === levels.id).masks).toHaveLength(1);
await row.locator('.ge-layer-vis').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
await row.locator('.ge-layer-vis').click();
await row.locator('.ge-layer-opacity').fill('45');
await row.locator('.ge-layer-opacity').dispatchEvent('input');
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBeCloseTo(.45, 2);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBe(1);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBeCloseTo(.45, 2);
const draftId = await waitForDraft(page);
const expected = await editorState(page);
const expectedPixels = await flattenedPixelDigest(page);
const exportedPng = await page.evaluate(async () => {
const editor = await import('/static/js/galleryEditor.js');
return editor.exportPNG();
});
expect(await encodedImagePixelDigest(
page,
Buffer.from(exportedPng.split(',', 2)[1], 'base64'),
)).toEqual(expectedPixels);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.find(layer => layer.id === levels.id)).toEqual(expected.layers.find(layer => layer.id === levels.id));
expect(await flattenedPixelDigest(page)).toEqual(expectedPixels);
});
test('Curves supports editable RGB and channel points with reset and cancel', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 260 }, 'Curves E2E');
await addAdjustment(page, 'curves');
const curve = page.locator('.ge-curves-canvas');
const box = await curve.boundingBox();
await page.mouse.click(box.x + box.width * .5, box.y + box.height * .28);
await page.locator('.ge-adj-channel-select').selectOption('blue');
const blueBox = await curve.boundingBox();
await page.mouse.click(blueBox.x + blueBox.width * .4, blueBox.y + blueBox.height * .68);
await page.locator('[data-adj-action="ok"]').click();
let current = await editorState(page);
const curves = current.layers.find(layer => layer.kind === 'adjustment');
expect(curves.adjustment.type).toBe('curves');
expect(curves.adjustment.params.points.rgb).toHaveLength(3);
expect(curves.adjustment.params.points.blue).toHaveLength(3);
const row = page.locator(`.ge-layer-item[data-layer-id="${curves.id}"]`);
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
await page.locator('[data-adj-action="reset"]').click();
await page.locator('[data-adj-action="cancel"]').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === curves.id).adjustment.params.points.rgb).toHaveLength(3);
});
test('color adjustment controls retain neutral reusable parameters', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Color adjustments E2E');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const gradient = layer.ctx.createLinearGradient(0, 0, layer.canvas.width, layer.canvas.height);
gradient.addColorStop(0, '#b8324f');
gradient.addColorStop(.5, '#56a56d');
gradient.addColorStop(1, '#315fc4');
layer.ctx.fillStyle = gradient;
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
window.galleryEditorComposite?.();
});
const cases = [
{ type: 'exposure', key: 'exposure', value: '100', check: p => p.exposure === 1 },
{ type: 'white-balance', key: 'temperature', value: '70', check: p => p.temperature === 70 },
{ type: 'hue-saturation', key: 'lightness', value: '18', check: p => p.lightness === 18 },
{ type: 'vibrance', key: 'vibrance', value: '55', check: p => p.vibrance === 55 },
{ type: 'black-white', key: 'red', value: '72', compare: true, check: p => p.red === 72 && p.green === 59 && p.blue === 11 },
{ type: 'shadows-highlights', key: 'shadows', value: '48', check: p => p.shadows === 48 },
{ type: 'color-balance', key: 'shadows-r', value: '45', check: p => p.shadows.r === 45 },
{ type: 'selective-color', key: 'cyan', value: '35', check: p => p.ranges.reds.cyan === 35 },
];
let priorDigest = await flattenedPixelDigest(page);
for (const item of cases) {
await addAdjustment(page, item.type);
const control = page.locator(`.ge-adj-row input[data-key="${item.key}"]`);
await control.fill(item.value);
await control.dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const current = await editorState(page);
const added = current.layers.filter(layer => layer.kind === 'adjustment').at(-1);
expect(added.adjustment.type).toBe(item.type);
expect(item.check(added.adjustment.params)).toBe(true);
const nextDigest = await flattenedPixelDigest(page);
expect(nextDigest).not.toBe(priorDigest);
if (item.compare) {
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
await page.locator('[data-adj-action="compare"]').click();
expect(await flattenedPixelDigest(page)).toEqual(priorDigest);
await page.locator('[data-adj-action="compare"]').click();
expect(await flattenedPixelDigest(page)).toEqual(nextDigest);
await page.locator('[data-adj-action="cancel"]').click();
}
priorDigest = nextDigest;
}
await addAdjustment(page, 'gradient-map');
await page.locator('[data-gradient-key="shadows"]').fill('#123456');
await page.locator('[data-gradient-key="highlights"]').fill('#f0d080');
await page.locator('[data-gradient-reverse]').check();
await page.locator('[data-adj-action="ok"]').click();
const current = await editorState(page);
const gradient = current.layers.filter(layer => layer.kind === 'adjustment').at(-1);
expect(gradient.adjustment).toEqual({
type: 'gradient-map',
params: { shadows: '#123456', highlights: '#f0d080', midpoint: 50, reverse: true },
});
const expectedPixels = await flattenedPixelDigest(page);
const draftId = await waitForDraft(page);
const expectedState = await editorState(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.filter(layer => layer.kind === 'adjustment')).toEqual(
expectedState.layers.filter(layer => layer.kind === 'adjustment'),
);
expect(await flattenedPixelDigest(page)).toEqual(expectedPixels);
});
test('adjustment presets apply through the retained popup', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Adjustment presets E2E');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.fillStyle = '#5577aa';
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
window.galleryEditorComposite?.();
});
const before = await flattenedPixelDigest(page);
await addAdjustment(page, 'exposure');
await page.locator('.ge-adj-preset-select').selectOption('Lift Exposure');
await page.locator('[data-adj-action="ok"]').click();
const current = await editorState(page);
const exposure = current.layers.find(layer => layer.kind === 'adjustment');
expect(exposure.adjustment.params.exposure).toBe(0.45);
expect(await flattenedPixelDigest(page)).not.toEqual(before);
});
test('adjustment popup keeps controls inside a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile adjustment E2E');
await addAdjustment(page, 'exposure');
const popup = await page.locator('.ge-adj-popup').boundingBox();
expect(popup).not.toBeNull();
expect(popup.x).toBeGreaterThanOrEqual(0);
expect(popup.x + popup.width).toBeLessThanOrEqual(320);
for (const row of await page.locator('.ge-adj-row').all()) {
const box = await row.boundingBox();
expect(box).not.toBeNull();
expect(box.x).toBeGreaterThanOrEqual(popup.x);
expect(box.x + box.width).toBeLessThanOrEqual(popup.x + popup.width);
}
await expect(page.locator('.ge-adj-foot [data-adj-action="cancel"]')).toBeVisible();
await expect(page.locator('.ge-adj-foot [data-adj-action="ok"]')).toBeVisible();
});
test('gradient map controls remain usable on a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile gradient map E2E');
await addAdjustment(page, 'gradient-map');
const popup = await page.locator('.ge-adj-popup').boundingBox();
const colors = page.locator('.ge-gradient-color-row');
const colorBox = await colors.boundingBox();
expect(popup).not.toBeNull();
expect(colorBox).not.toBeNull();
expect(colorBox.x).toBeGreaterThanOrEqual(popup.x);
expect(colorBox.x + colorBox.width).toBeLessThanOrEqual(popup.x + popup.width);
expect(await page.locator('.ge-gradient-color-row').evaluate(el => (
getComputedStyle(el).gridTemplateColumns.trim().split(/\s+/).length
))).toBe(1);
});
test('all adjustment popups remain usable on a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile adjustment matrix E2E');
for (const type of [
'brightness-contrast', 'exposure', 'white-balance', 'hue-saturation',
'vibrance', 'black-white', 'shadows-highlights', 'levels', 'curves',
'color-balance', 'selective-color', 'gradient-map',
]) {
await addAdjustment(page, type);
const popup = await page.locator('.ge-adj-popup').boundingBox();
expect(popup, `${type} popup should be visible`).not.toBeNull();
expect(popup.x).toBeGreaterThanOrEqual(0);
expect(popup.x + popup.width).toBeLessThanOrEqual(320);
const body = page.locator('.ge-adj-body');
expect(await body.evaluate(el => el.scrollWidth <= el.clientWidth + 1), `${type} body should not overflow horizontally`).toBe(true);
await expect(page.locator('.ge-adj-foot [data-adj-action="cancel"]')).toBeVisible();
await expect(page.locator('.ge-adj-foot [data-adj-action="ok"]')).toBeVisible();
await page.locator('[data-adj-action="cancel"]').click();
}
});
test('retained Gaussian Blur survives flattening and draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained effects E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
const before = await flattenedPixelDigest(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
let current = await editorState(page);
const layer = current.layers.find(item => item.effects?.length);
expect(layer.effects).toHaveLength(1);
expect(layer.effects[0].type).toBe('gaussian-blur');
expect(layer.effects[0].params.radius).toBe(14);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
const reopened = current.layers.find(item => item.effects?.length);
expect(reopened.effects).toEqual(layer.effects);
expect(await flattenedPixelDigest(page)).toEqual(after);
await page.locator('.ge-effect-sub-item .ge-adj-sub-name').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('22');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].params.radius).toBe(22);
});
test('retained Sharpen keeps editable amount and survives draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained sharpen E2E');
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-preset-crisp-detail"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
expect(await page.locator('.ge-filter-row input[data-key="amount"]').inputValue()).toBe('35');
await page.locator('.ge-filter-row input[data-key="amount"]').fill('75');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
let current = await editorState(page);
let layer = current.layers.find(item => item.effects?.length);
expect(layer.effects[0].type).toBe('sharpen');
expect(layer.effects[0].params.amount).toBeCloseTo(0.75, 2);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
layer = current.layers.find(item => item.effects?.length);
expect(layer.effects[0].type).toBe('sharpen');
expect(layer.effects[0].params.amount).toBeCloseTo(0.75, 2);
});
test('retained overlay and shadow effects keep editable metadata', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained color effects E2E');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(canvasBox.x + 24, canvasBox.y + 24);
await page.mouse.down();
await page.mouse.move(canvasBox.x + 150, canvasBox.y + 140);
await page.mouse.up();
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
await page.locator('button[title="Add effect mask from selection"]').click();
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-drop-shadow"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="blur"]').fill('18');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
let current = await editorState(page);
let layer = current.layers.find(item => item.effects?.length);
expect(layer.effects.map(effect => effect.type)).toEqual(['color-overlay', 'drop-shadow']);
expect(layer.effects[0].params.color).toBe('#336699');
expect(layer.effects[0].mask.size).toEqual([320, 220]);
await page.locator('button[title="Hide effect mask"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].mask.visible).toBe(false);
await page.locator('button[title="Remove effect mask"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].mask).toBeNull();
expect(layer.effects[0].params.opacity).toBeCloseTo(0.35, 2);
expect(layer.effects[1].params.blur).toBe(18);
await page.locator('.ge-effect-sub-item').nth(0).locator('.ge-layer-vis').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].visible).toBe(false);
await page.locator('.ge-effect-sub-item').nth(1).locator('button[title="Move effect up"]').click();
current = await editorState(page);
layer = current.layers.find(item => item.effects?.length);
expect(layer.effects.map(effect => effect.type)).toEqual(['drop-shadow', 'color-overlay']);
await page.locator('.ge-effect-sub-item').nth(1).locator('button[title="Delete effect"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects).toHaveLength(1);
});
test('retained Stroke uses layer alpha and persists its controls', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-stroke"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="width"]').fill('9');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const layer = (await editorState(page)).layers.find(item => item.effects?.length);
expect(layer.effects[0].type).toBe('stroke');
expect(layer.effects[0].params.width).toBe(9);
const beforeRasterize = await flattenedPixelDigest(page);
await page.locator('.ge-effect-sub-item button[title="Rasterize effects"]').click();
await expect.poll(async () => (await editorState(page)).layers.find(item => item.name === 'Edit').effects.length).toBe(0);
const rasterized = (await editorState(page)).layers.find(item => item.name === 'Edit');
expect(rasterized.effects).toHaveLength(0);
expect(await flattenedPixelDigest(page)).toEqual(beforeRasterize);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
expect((await editorState(page)).layers.find(item => item.name === 'Edit').effects).toHaveLength(0);
});
@@ -0,0 +1,107 @@
const { test, expect } = require('@playwright/test');
async function addAppStyles(page) {
const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs');
for (const url of await stylesheetUrls()) {
await page.addStyleTag({ url });
}
}
test('mobile compare uses tabs to show one mounted pane at a time', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/login');
await addAppStyles(page);
await page.evaluate(async () => {
const { default: state } = await import('/static/js/compare/state.js');
const { mountMobilePaneTabs } = await import('/static/js/compare/panes.js?v=20260903comparemodeicons1');
document.body.innerHTML = '<main id="compare-test-host" class="chat-container compare-active"><div class="compare-grid" data-cols="3"></div></main>';
const host = document.getElementById('compare-test-host');
host.style.cssText = 'position:fixed;inset:0;display:flex;flex-direction:column;padding-top:12px;';
const grid = host.querySelector('.compare-grid');
state._blindMode = false;
state._parallel = true;
state._selectedModels = [
{ name: 'Alpha model' },
{ name: 'Beta model' },
{ name: 'Gamma model' },
];
state._activeMobilePane = 0;
state._selectedModels.forEach((model, index) => {
const pane = document.createElement('section');
pane.className = 'compare-pane';
pane.dataset.pane = String(index);
pane.innerHTML = `<header class="pane-header"><button id="cmp-title-${index}" class="pane-title-btn">${model.name}</button></header><div class="chat-history">Response ${index + 1}</div>`;
grid.appendChild(pane);
});
mountMobilePaneTabs(host, grid);
});
const tabs = page.locator('.compare-mobile-tab');
const panes = page.locator('.compare-pane');
await expect(tabs).toHaveCount(3);
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true');
await expect(panes.nth(0)).toBeVisible();
await expect(panes.nth(1)).toBeHidden();
await expect(panes.nth(2)).toBeHidden();
await tabs.nth(1).click();
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true');
await expect(panes.nth(0)).toBeHidden();
await expect(panes.nth(1)).toBeVisible();
await expect(panes.nth(2)).toBeHidden();
const geometry = await page.evaluate(() => ({
grid: document.querySelector('.compare-grid').getBoundingClientRect().toJSON(),
pane: document.querySelector('.compare-pane-mobile-active').getBoundingClientRect().toJSON(),
visiblePaneCount: Array.from(document.querySelectorAll('.compare-pane')).filter((pane) => getComputedStyle(pane).display !== 'none').length,
}));
expect(geometry.visiblePaneCount).toBe(1);
expect(Math.abs((geometry.grid.width - 16) - geometry.pane.width)).toBeLessThan(2);
});
test('mobile compare probe keeps feedback below models and actions split', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/login');
await addAppStyles(page);
await page.evaluate(() => {
document.body.innerHTML = `
<div class="compare-probe-overlay">
<section class="compare-probe-card">
<div class="compare-probe-title">Checking models...</div>
<div class="compare-probe-list">
<div class="compare-probe-row"><span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">Alpha</span></div>
<div class="compare-probe-row fail"><span class="compare-probe-spinner fail">×</span><span class="compare-probe-name">Beta</span></div>
</div>
<div class="compare-probe-feedback">
<div class="compare-probe-detail"><span class="compare-probe-detail-message">Insufficient balance</span><button class="compare-probe-action-btn"><svg></svg><span>Retry</span></button><button class="compare-probe-action-btn"><svg></svg><span>Swap</span></button></div>
</div>
<div class="compare-probe-footer"><button class="cmp-btn-secondary compare-probe-footer-btn">Go Back</button><button class="cmp-btn-primary compare-probe-footer-btn compare-probe-start-anyway">Start Anyway</button></div>
</section>
</div>`;
});
const layout = await page.evaluate(() => {
const list = document.querySelector('.compare-probe-list').getBoundingClientRect();
const feedback = document.querySelector('.compare-probe-feedback').getBoundingClientRect();
const back = document.querySelector('.compare-probe-footer-btn').getBoundingClientRect();
const start = document.querySelector('.compare-probe-start-anyway').getBoundingClientRect();
const spinnerStyle = getComputedStyle(document.querySelector('.compare-probe-spinner'));
const cardStyle = getComputedStyle(document.querySelector('.compare-probe-card'));
const startStyle = getComputedStyle(document.querySelector('.compare-probe-start-anyway'));
return {
feedbackBelowList: feedback.top >= list.bottom,
splitActions: back.left < start.left && start.right > 350,
spinnerTransform: spinnerStyle.transform,
cardRadius: cardStyle.borderRadius,
startBackground: startStyle.backgroundColor,
};
});
expect(layout.feedbackBelowList).toBe(true);
expect(layout.splitActions).toBe(true);
expect(layout.spinnerTransform).toContain('-2');
expect(layout.cardRadius).toBe('8px');
expect(layout.startBackground).not.toBe('rgba(0, 0, 0, 0)');
});
@@ -0,0 +1,47 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers.js');
test('brush cursor matches the rendered brush diameter and replaces the crosshair', async ({ page }) => {
await openBlankEditor(page, { width: 400, height: 300 });
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 80;
});
const canvas = page.locator('.ge-main-canvas');
const canvasBox = await canvas.boundingBox();
await page.mouse.move(canvasBox.x + canvasBox.width / 2, canvasBox.y + canvasBox.height / 2);
const cursor = page.locator('.ge-brush-cursor');
await expect(cursor).toBeVisible();
await expect(canvas).toHaveCSS('cursor', 'none');
const measurements = await page.evaluate(() => {
const canvasEl = document.querySelector('.ge-main-canvas');
const cursorEl = document.querySelector('.ge-brush-cursor');
const canvasRect = canvasEl.getBoundingClientRect();
const cursorRect = cursorEl.getBoundingClientRect();
return {
expectedWidth: 80 * canvasRect.width / canvasEl.width,
expectedHeight: 80 * canvasRect.height / canvasEl.height,
cursorWidth: cursorRect.width,
cursorHeight: cursorRect.height,
cursorZ: Number(getComputedStyle(cursorEl).zIndex),
galleryZ: Number(getComputedStyle(document.getElementById('gallery-modal')).zIndex || 0),
};
});
expect(measurements.cursorWidth).toBeCloseTo(measurements.expectedWidth, 1);
expect(measurements.cursorHeight).toBeCloseTo(measurements.expectedHeight, 1);
expect(measurements.cursorZ).toBeGreaterThan(measurements.galleryZ);
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 160;
});
await page.mouse.move(canvasBox.x + canvasBox.width / 2 + 1, canvasBox.y + canvasBox.height / 2);
await expect.poll(async () => (await cursor.boundingBox()).width)
.toBeCloseTo(measurements.expectedWidth * 2, 1);
});
+197
View File
@@ -0,0 +1,197 @@
const { test, expect } = require('@playwright/test');
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
async function canvasPoint(page, xRatio, yRatio) {
const box = await page.locator('.ge-main-canvas').boundingBox();
return { x: box.x + box.width * xRatio, y: box.y + box.height * yRatio };
}
test('brush presets and shared stroke controls remain reusable', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await expect(page.locator('#ge-brush-spacing')).toBeVisible();
await page.locator('#ge-brush-spacing').fill('28');
await page.locator('#ge-brush-smoothing').fill('52');
await page.locator('#ge-brush-blend').selectOption('multiply');
await page.locator('.ge-pressure-option').filter({ hasText: 'Opacity' }).locator('.toggle-slider').click();
await expect(page.locator('#ge-pressure-opacity')).toBeChecked();
page.once('dialog', dialog => dialog.accept('My Detail Brush'));
await page.locator('#ge-brush-preset-save').click();
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(1);
await page.locator('#ge-brush-spacing').fill('5');
await page.locator('#ge-brush-preset').selectOption({ label: 'My Detail Brush' });
await expect(page.locator('#ge-brush-spacing')).toHaveValue('28');
await expect(page.locator('#ge-brush-smoothing')).toHaveValue('52');
await expect(page.locator('#ge-brush-blend')).toHaveValue('multiply');
await page.locator('#ge-brush-preset-delete').click();
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(0);
});
test('long sampled strokes stay responsive and undo atomically', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-size-slider').fill('700');
await page.locator('#ge-brush-spacing').fill('8');
await page.locator('#ge-brush-smoothing').fill('65');
const activeLayerId = (await editorState(page)).activeLayerId;
const thumb = page.locator(`.ge-layer-item[data-layer-id="${activeLayerId}"] .ge-layer-inline-thumb`);
const thumbBefore = await thumb.evaluate(canvas => canvas.toDataURL());
const before = await flattenedPixelDigest(page);
const start = await canvasPoint(page, 0.08, 0.5);
const end = await canvasPoint(page, 0.92, 0.5);
const startedAt = Date.now();
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 240 });
await page.mouse.up();
expect(Date.now() - startedAt).toBeLessThan(5000);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
await expect(thumb).toHaveAttribute('width', '68');
expect(await thumb.evaluate(canvas => canvas.toDataURL())).not.toEqual(thumbBefore);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
await page.locator('#ge-redo').click();
expect(await flattenedPixelDigest(page)).toEqual(after);
});
test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#33aa55';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
const sample = await canvasPoint(page, 0.3, 0.3);
await page.mouse.click(sample.x, sample.y);
await page.locator('.ge-tool-btn[data-tool="eyedropper"]').click();
await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
await page.locator('#ge-eyedropper-sample').selectOption('composite');
await page.mouse.move(sample.x, sample.y);
await expect(page.locator('#ge-eyedropper-live-value')).toHaveText('#33aa55');
await expect(page.locator('#ge-eyedropper-live-rgb')).toHaveText('RGB 51 170 85');
await expect(page.locator('#ge-eyedropper-live-hsl')).toHaveText('HSL 137 54% 43%');
await expect(page.locator('#ge-eyedropper-live-swatch')).not.toHaveClass(/empty/);
await expect(page.locator('#ge-eyedropper-loupe')).toBeVisible();
expect((await page.locator('#ge-eyedropper-loupe').evaluate(canvas => canvas.getContext('2d').getImageData(42, 42, 1, 1).data[3]))).toBe(255);
await page.mouse.click(sample.x, sample.y);
await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55');
for (const tool of ['dodge', 'burn']) {
const before = await flattenedPixelDigest(page);
await page.locator(`.ge-tool-btn[data-tool="${tool}"]`).click();
const from = await canvasPoint(page, 0.35, tool === 'dodge' ? 0.45 : 0.6);
const to = await canvasPoint(page, 0.65, tool === 'dodge' ? 0.45 : 0.6);
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(to.x, to.y, { steps: 20 });
await page.mouse.up();
expect((await editorState(page)).layers.length).toBeGreaterThan(0);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
}
const blemish = await canvasPoint(page, 0.72, 0.3);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#111111';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.mouse.click(blemish.x, blemish.y);
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
const beforeHeal = await flattenedPixelDigest(page);
const healTarget = blemish;
await page.mouse.move(healTarget.x, healTarget.y);
await page.mouse.down();
await page.mouse.move(healTarget.x + 50, healTarget.y, { steps: 12 });
await page.mouse.up();
expect(await flattenedPixelDigest(page)).not.toEqual(beforeHeal);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(beforeHeal);
});
test('healing brush can use an optional sampled source', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Sampled healing E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-size-slider').fill('420');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#d24b62';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
const source = await canvasPoint(page, 0.28, 0.5);
await page.mouse.click(source.x, source.y);
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
await page.keyboard.down('Alt');
await page.mouse.click(source.x, source.y);
await page.keyboard.up('Alt');
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.cloneSourceX !== null && state.cloneSourceY !== null;
})).toBe(true);
const target = await canvasPoint(page, 0.72, 0.5);
await page.mouse.click(target.x, target.y);
const result = await page.evaluate(async ({ xRatio, yRatio }) => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const x = Math.round(layer.canvas.width * xRatio);
const y = Math.round(layer.canvas.height * yRatio);
const point = layer.ctx.getImageData(x, y, 1, 1).data;
return { hasSource: !!state.cloneSourceSnapshot, pixel: [...point] };
}, { xRatio: 0.72, yRatio: 0.5 });
expect(result.hasSource).toBe(true);
expect(result.pixel[0]).toBeGreaterThan(80);
expect(result.pixel[3]).toBeGreaterThan(0);
await expect(page.locator('#ge-clone-source-label')).toHaveText('Active layer sampled');
await page.locator('#ge-clone-source-clear').click();
await expect(page.locator('#ge-clone-source-label')).toHaveText('No source selected');
});
test('smudge carries nearby pixels along a stroke and undoes atomically', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Smudge E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-size-slider').fill('700');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#d24b62';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
const source = await canvasPoint(page, 0.28, 0.5);
const target = await canvasPoint(page, 0.55, 0.5);
await page.mouse.click(source.x, source.y);
const before = await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const x = Math.round(canvas.width * 0.55);
const y = Math.round(canvas.height * 0.5);
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
});
await page.locator('.ge-tool-btn[data-tool="smudge"]').click();
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
await page.mouse.move(source.x, source.y);
await page.mouse.down();
await page.mouse.move(target.x, target.y, { steps: 20 });
await page.mouse.up();
const after = await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const x = Math.round(canvas.width * 0.55);
const y = Math.round(canvas.height * 0.5);
const ctx = canvas.getContext('2d');
return [...ctx.getImageData(x, y, 1, 1).data];
});
expect(before[0]).toBeGreaterThan(220);
expect(before[3]).toBe(255);
expect(after[0]).toBeLessThan(220);
expect(after[2]).toBeLessThan(220);
expect(after[3]).toBeGreaterThan(0);
await page.locator('#ge-undo').click();
expect(await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const x = Math.round(canvas.width * 0.55);
const y = Math.round(canvas.height * 0.5);
return canvas.getContext('2d').getImageData(x, y, 1, 1).data[3];
})).toBe(255);
});
@@ -0,0 +1,43 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor } = require('./helpers.js');
async function mainCanvasHash(page) {
return page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let hash = 2166136261;
for (let index = 0; index < data.length; index += 4) {
hash ^= data[index];
hash = Math.imul(hash, 16777619);
hash ^= data[index + 1];
hash = Math.imul(hash, 16777619);
hash ^= data[index + 2];
hash = Math.imul(hash, 16777619);
hash ^= data[index + 3];
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
});
}
test('before compare is visual-only and toggles back to the edited document', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Compare mode E2E');
await expect(page.locator('#ge-compare-btn')).toBeVisible();
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
const baselineHash = await mainCanvasHash(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
const editedState = await editorState(page);
const editedHash = await mainCanvasHash(page);
expect(editedHash).not.toBe(baselineHash);
await page.locator('#ge-compare-btn').click();
await expect(page.locator('#ge-compare-btn')).toHaveAttribute('aria-pressed', 'true');
expect(await mainCanvasHash(page)).toBe(baselineHash);
expect((await editorState(page)).layers).toEqual(editedState.layers);
await page.locator('#ge-compare-btn').click();
await expect(page.locator('#ge-compare-btn')).toHaveAttribute('aria-pressed', 'false');
expect(await mainCanvasHash(page)).toBe(editedHash);
});
@@ -0,0 +1,106 @@
const fs = require('node:fs');
const { test, expect } = require('@playwright/test');
const {
compareExportPixels,
dragOnCanvas,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
test('layered document survives edit, mask, transform, crop, reopen, and export', async ({ page, request }) => {
await openBlankEditor(page);
const initial = await editorState(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.20, y: 0.30 }, { x: 0.72, y: 0.60 });
const painted = await editorState(page);
const paintedLayer = painted.layers.find(layer => layer.name === 'Edit');
const initialLayer = initial.layers.find(layer => layer.name === 'Edit');
expect(paintedLayer.pixelHash).not.toBe(initialLayer.pixelHash);
const editItem = page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first();
await editItem.click();
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
let current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').masks).toHaveLength(1);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('#ge-transform-w')).toBeVisible();
await page.locator('#ge-transform-w').fill('560');
await page.locator('#ge-transform-apply').click();
current = await editorState(page);
const transformed = current.layers.find(layer => layer.name === 'Edit');
expect(transformed.size[0]).toBe(560);
expect(transformed.masks[0].size).toEqual(transformed.size);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').size[0]).toBe(640);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').size[0]).toBe(560);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await expect(page.locator('#ge-text-section')).toBeVisible();
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvasBox.x + canvasBox.width * 0.28, canvasBox.y + canvasBox.height * 0.22);
await page.locator('#ge-text-content').fill('Durable photo edit');
await page.locator('#ge-text-size').fill('42');
await page.locator('#ge-text-size').press('Enter');
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Durable photo edit' })).toBeVisible();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await dragOnCanvas(page, { x: 0.08, y: 0.10 }, { x: 0.92, y: 0.88 });
await expect(page.locator('.ge-crop-apply-btn')).toBeVisible();
await page.locator('.ge-crop-apply-btn').click();
const cropped = await editorState(page);
expect(cropped.dimensions[0]).toBeLessThan(640);
expect(cropped.dimensions[1]).toBeLessThan(480);
await page.locator('#ge-undo').click();
expect((await editorState(page)).dimensions).toEqual([640, 480]);
await page.locator('#ge-redo').click();
expect((await editorState(page)).dimensions).toEqual(cropped.dimensions);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
const beforeReopenPixels = await flattenedPixelDigest(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.dimensions).toEqual(beforeReopen.dimensions);
expect(reopened.activeLayerId).toBe(beforeReopen.activeLayerId);
expect(reopened.layers).toEqual(beforeReopen.layers);
expect(await flattenedPixelDigest(page)).toEqual(beforeReopenPixels);
await openExportDialog(page);
await page.locator('[data-format="png"]').click();
await page.locator('#ge-export-width').fill('320');
const expectedHeight = Number(await page.locator('#ge-export-height').inputValue());
await page.locator('#ge-export-filename').fill('photo-editor-release-gate');
const downloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('photo-editor-release-gate.png');
const bytes = fs.readFileSync(await download.path());
expect(bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(bytes.readUInt32BE(16)).toBe(320);
expect(bytes.readUInt32BE(20)).toBe(expectedHeight);
const resizedComparison = await compareExportPixels(page, bytes, { width: 320, height: expectedHeight });
expect(resizedComparison.meanAbsoluteError).toBeLessThanOrEqual(1);
expect(resizedComparison.maximumError).toBeLessThanOrEqual(32);
expect(resizedComparison.changedPixelRatio).toBeLessThanOrEqual(0.08);
await openExportDialog(page);
await page.locator('[data-format="png"]').click();
await page.locator('#ge-export-filename').fill('photo-editor-native-fidelity');
const nativeDownloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const nativeDownload = await nativeDownloadPromise;
const nativeBytes = fs.readFileSync(await nativeDownload.path());
expect(await encodedImagePixelDigest(page, nativeBytes)).toEqual(beforeReopenPixels);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,191 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Gesture cancellation');
const canvas = page.locator('.ge-main-canvas');
const box = await canvas.boundingBox();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await page.mouse.move(box.x + 40, box.y + 35);
await page.mouse.down();
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { cropping: state.cropping, rect: state.cropRect };
})).toMatchObject({ cropping: true, rect: { w: 150, h: 115 } });
await page.locator('.ge-tool-btn[data-tool="move"]').dispatchEvent('click');
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { tool: state.tool, cropping: state.cropping, moving: state.cropMoving, rect: state.cropRect };
})).toEqual({ tool: 'move', cropping: false, moving: false, rect: null });
await page.mouse.up();
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
const beforeMove = await editorState(page);
const bounds = beforeMove.selection.bounds;
await page.mouse.move(
box.x + bounds.x + bounds.width / 2,
box.y + bounds.y + bounds.height / 2,
);
await page.mouse.down();
await page.mouse.move(box.x + bounds.x + bounds.width / 2 + 35, box.y + bounds.y + bounds.height / 2 + 20);
expect((await editorState(page)).selection.bounds).not.toEqual(bounds);
await page.locator('.ge-tool-btn[data-tool="brush"]').dispatchEvent('click');
const afterSwitch = await editorState(page);
expect(afterSwitch.selection.bounds).toEqual(bounds);
expect(afterSwitch.undo).toBe(beforeMove.undo);
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { tool: state.tool, selectionMoving: state.selectionMoving, marqueeActive: state.marqueeActive };
})).toEqual({ tool: 'brush', selectionMoving: false, marqueeActive: false });
await page.mouse.up();
expect((await editorState(page)).selection.bounds).toEqual(bounds);
});
test('Escape cancels an active crop without changing the document', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Escape crop cancellation');
const canvas = page.locator('.ge-main-canvas');
const box = await canvas.boundingBox();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await page.mouse.move(box.x + 40, box.y + 35);
await page.mouse.down();
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
await page.keyboard.press('Escape');
await page.mouse.up();
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { cropping: state.cropping, moving: state.cropMoving, rect: state.cropRect };
})).toEqual({ cropping: false, moving: false, rect: null });
});
test('touch crop and selection gestures complete through the shared lifecycle', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch gestures');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30);
await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145);
await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145);
await expect(page.locator('.ge-crop-apply')).toBeVisible();
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.cropRect;
})).toMatchObject({ w: 155, h: 115 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await touch('touchStart', canvasBox.x + 45, canvasBox.y + 40);
await touch('touchMove', canvasBox.x + 165, canvasBox.y + 125);
await touch('touchEnd', canvasBox.x + 165, canvasBox.y + 125);
expect((await editorState(page)).selection.bounds).toBeTruthy();
} finally {
await context.close();
}
});
test('touch brush paints and undoes as one stroke', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch brush');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
const before = await flattenedPixelDigest(page);
await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5);
await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
await touch('touchEnd', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
} finally {
await context.close();
}
});
test('touch cancellation rolls back a partial brush stroke', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch cancellation');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
const before = await flattenedPixelDigest(page);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: canvasBox.x + 70, y: canvasBox.y + 110, id: 1 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: canvasBox.x + 230, y: canvasBox.y + 110, id: 1 }],
});
await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] });
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.drawing;
})).toBe(false);
expect(await flattenedPixelDigest(page)).toEqual(before);
} finally {
await context.close();
}
});
test('pen input completes a crop gesture', async ({ page, context, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP pen injection');
await openBlankEditor(page, { width: 320, height: 240 }, 'Pen crop gesture');
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
const box = await page.locator('.ge-main-canvas').boundingBox();
const cdp = await context.newCDPSession(page);
await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed', pointerType: 'pen', button: 'left', buttons: 1,
clickCount: 1, x: box.x + 30, y: box.y + 25,
});
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseMoved', pointerType: 'pen', button: 'none', buttons: 1,
x: box.x + 175, y: box.y + 130,
});
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseReleased', pointerType: 'pen', button: 'left', buttons: 0,
clickCount: 1, x: box.x + 175, y: box.y + 130,
});
await expect(page.locator('.ge-crop-apply')).toBeVisible();
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { cropping: state.cropping, rect: state.cropRect };
})).toEqual({ cropping: false, rect: { x: 30, y: 25, w: 145, h: 105 } });
});
@@ -0,0 +1,191 @@
const fs = require('node:fs');
const { test, expect } = require('@playwright/test');
const {
compareExportPixels,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
async function seedGradient(page) {
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const gradient = layer.ctx.createLinearGradient(0, 0, layer.canvas.width, layer.canvas.height);
gradient.addColorStop(0, '#d43f67');
gradient.addColorStop(0.5, '#4aa884');
gradient.addColorStop(1, '#3868d4');
layer.ctx.fillStyle = gradient;
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
window.galleryEditorComposite?.();
});
}
async function downloadFormat(page, format, filename) {
await openExportDialog(page);
await page.locator(`[data-format="${format}"]`).click();
await page.locator('#ge-export-filename').fill(filename);
const downloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const download = await downloadPromise;
return {
name: download.suggestedFilename(),
bytes: fs.readFileSync(await download.path()),
};
}
async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click();
await expect(page.locator('.ge-add-layer-menu')).toBeVisible();
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${type}"]`).click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
}
async function exportCurrentPng(page) {
const dataUrl = await page.evaluate(async () => {
const editor = await import('/static/js/galleryEditor.js');
return editor.exportPNG();
});
return Buffer.from(dataUrl.split(',', 2)[1], 'base64');
}
test('every retained adjustment family exports the visible composite exactly', async ({ page }) => {
await openBlankEditor(page, { width: 128, height: 96 }, 'Adjustment export matrix');
await seedGradient(page);
const cases = [
['brightness-contrast', 'brightness', '35'],
['exposure', 'exposure', '35'],
['white-balance', 'temperature', '30'],
['hue-saturation', 'hue', '30'],
['vibrance', 'vibrance', '45'],
['black-white', 'red', '60'],
['shadows-highlights', 'shadows', '35'],
['levels', 'inBlack', '20'],
['color-balance', 'shadows-r', '35'],
['selective-color', 'cyan', '30'],
];
for (const [type, key, value] of cases) {
await addAdjustment(page, type);
const control = page.locator(`.ge-adj-row input[data-key="${key}"]`);
await control.fill(value);
await control.dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const visibleDigest = await flattenedPixelDigest(page);
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(visibleDigest);
}
await addAdjustment(page, 'curves');
const curve = page.locator('.ge-curves-canvas');
const curveBox = await curve.boundingBox();
await page.mouse.click(curveBox.x + curveBox.width * 0.5, curveBox.y + curveBox.height * 0.28);
await page.locator('[data-adj-action="ok"]').click();
const curveDigest = await flattenedPixelDigest(page);
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(curveDigest);
await addAdjustment(page, 'gradient-map');
await page.locator('[data-gradient-key="shadows"]').fill('#102030');
await page.locator('[data-gradient-key="shadows"]').dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const gradientDigest = await flattenedPixelDigest(page);
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(gradientDigest);
});
test('composited correction exports preserve pixels and format metadata', async ({ page }) => {
await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity');
await seedGradient(page);
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('35');
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const renderedPixels = await flattenedPixelDigest(page);
const png = await downloadFormat(page, 'png', 'correction');
expect(png.name).toBe('correction.png');
expect(png.bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(png.bytes.readUInt32BE(16)).toBe(160);
expect(png.bytes.readUInt32BE(20)).toBe(120);
expect(await encodedImagePixelDigest(page, png.bytes)).toEqual(renderedPixels);
const jpeg = await downloadFormat(page, 'jpeg', 'correction-jpeg');
expect(jpeg.name).toBe('correction-jpeg.jpg');
expect(jpeg.bytes[0]).toBe(0xff);
expect(jpeg.bytes[1]).toBe(0xd8);
const webp = await downloadFormat(page, 'webp', 'correction-webp');
expect(webp.name).toBe('correction-webp.webp');
expect(webp.bytes.subarray(0, 4).toString('ascii')).toBe('RIFF');
expect(webp.bytes.subarray(8, 12).toString('ascii')).toBe('WEBP');
const comparison = await compareExportPixels(page, png.bytes, { width: 160, height: 120 });
expect(comparison.meanAbsoluteError).toBe(0);
expect(comparison.maximumError).toBe(0);
const exposure = (await editorState(page)).layers.find(layer => layer.kind === 'adjustment');
const exposureRow = page.locator(`.ge-layer-item[data-layer-id="${exposure.id}"]`);
await exposureRow.locator('.ge-layer-opacity').fill('62');
await exposureRow.locator('.ge-layer-opacity').dispatchEvent('input');
const opacityState = await editorState(page);
expect(opacityState.layers.find(layer => layer.id === exposure.id).opacity).toBeCloseTo(.62, 2);
const opacityPixels = await flattenedPixelDigest(page);
const opacityPng = await downloadFormat(page, 'png', 'correction-opacity');
expect(await encodedImagePixelDigest(page, opacityPng.bytes)).toEqual(opacityPixels);
expect(await compareExportPixels(page, opacityPng.bytes, { width: 160, height: 120 })).toEqual({
meanAbsoluteError: 0,
maximumError: 0,
changedPixelRatio: 0,
});
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.id === exposure.id).opacity).toBeCloseTo(.62, 2);
expect(await flattenedPixelDigest(page)).toEqual(opacityPixels);
});
test('export preview clears stale matte pixels when transparency changes', async ({ page }) => {
await openBlankEditor(page, { width: 160, height: 120 }, 'Export preview');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
for (const layer of state.layers) layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.fillStyle = '#2671c8';
layer.ctx.fillRect(40, 30, 80, 60);
window.galleryEditorComposite?.();
});
await openExportDialog(page);
const previewPixel = () => page.locator('.ge-export-preview').evaluate(canvas => (
[...canvas.getContext('2d').getImageData(0, 0, 1, 1).data]
));
expect(await previewPixel()).toEqual([0, 0, 0, 0]);
await page.locator('#ge-export-transparency').uncheck();
await page.locator('#ge-export-matte').click();
await page.locator('.cp-hex').fill('#d94747');
await page.locator('.cp-hex').press('Enter');
expect(await previewPixel()).toEqual([217, 71, 71, 255]);
await page.locator('#ge-export-transparency').check();
expect(await previewPixel()).toEqual([0, 0, 0, 0]);
await page.locator('.ge-export-close').click();
});
test('closing export returns focus to the launch control', async ({ page }) => {
await openBlankEditor(page, { width: 160, height: 120 }, 'Export focus');
const launch = page.locator('#ge-save-menu-btn');
await launch.focus();
await launch.click();
await page.locator('#ge-download').click();
await expect(page.locator('.ge-export-dialog')).toBeVisible();
await page.locator('.ge-export-close').click();
await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe('ge-save-menu-btn');
});
@@ -0,0 +1,47 @@
const { test, expect } = require('@playwright/test');
test('gallery photos body uses tall viewport without cropping or stretching cards', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1200 });
await page.route('**/api/gallery/library?**', async route => {
const pixel = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="160" height="160"%3E%3Crect width="160" height="160" fill="%23666"/%3E%3C/svg%3E';
const items = Array.from({ length: 60 }, (_, index) => ({
id: `layout-${index}`,
url: pixel,
thumbnail_url: pixel,
filename: `Layout ${index}.png`,
prompt: `Layout ${index}`,
model: 'imported',
created_at: '2026-08-31T00:00:00Z',
}));
await route.fulfill({ json: { items, total: items.length, has_more: false } });
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.locator('#tool-gallery-btn').click();
await expect(page.locator('#gallery-modal')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#gallery-grid .gallery-card')).toHaveCount(61);
await expect(page.locator('#gallery-grid')).not.toHaveClass(/gallery-just-opened/, { timeout: 2_000 });
const layout = await page.evaluate(() => {
const modal = document.querySelector('.gallery-modal-content').getBoundingClientRect();
const body = document.querySelector('#gallery-modal .modal-body').getBoundingClientRect();
const grid = document.querySelector('#gallery-grid').getBoundingClientRect();
const upload = document.querySelector('#gallery-upload-tile').getBoundingClientRect();
const cards = [...document.querySelectorAll('#gallery-grid .gallery-card')]
.slice(0, 12)
.map(card => card.getBoundingClientRect());
return { modal, body, grid, upload, cards, viewportHeight: innerHeight };
});
expect(layout.grid.height).toBeGreaterThan(layout.viewportHeight * 0.65);
expect(layout.grid.bottom).toBeLessThanOrEqual(layout.body.bottom + 1);
expect(Math.abs(layout.upload.width - layout.upload.height)).toBeLessThanOrEqual(2);
for (let i = 0; i < layout.cards.length; i += 1) {
for (let j = i + 1; j < layout.cards.length; j += 1) {
const a = layout.cards[i];
const b = layout.cards[j];
const overlaps = a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
expect(overlaps).toBe(false);
}
}
});
@@ -0,0 +1,108 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const {
compareExportPixels,
dragOnCanvas,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
async function downloadPng(page, filename) {
await openExportDialog(page);
await page.locator('[data-format="png"]').click();
await page.locator('#ge-export-filename').fill(filename);
const downloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const download = await downloadPromise;
return fs.readFileSync(await download.path());
}
test('group retained effects edit, toggle, reorder surface, and survive reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 360, height: 240 }, 'Group effects E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 48;
state.brushSoftness = 0;
});
await dragOnCanvas(page, { x: 0.25, y: 0.35 }, { x: 0.75, y: 0.65 });
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
let current = await editorState(page);
expect(current.groups[0].effects).toHaveLength(1);
expect(current.groups[0].effects[0].params.radius).toBe(14);
const afterAdd = await flattenedPixelDigest(page);
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
await page.locator('.ge-group-effect-sub-item .ge-adj-sub-name').click();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('22');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
current = await editorState(page);
expect(current.groups[0].effects[0].params.radius).toBe(22);
expect(await flattenedPixelDigest(page)).not.toEqual(afterAdd);
await page.locator('.ge-group-effect-sub-item .ge-layer-vis').click();
expect((await editorState(page)).groups[0].effects[0].visible).toBe(false);
await page.locator('.ge-group-effect-sub-item .ge-layer-vis').click();
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
expect((await editorState(page)).groups).toEqual(beforeReopen.groups);
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
await page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`).click();
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('group retained effects export the visible composite and survive reopen', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Group effects export E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 64;
state.brushSoftness = 0;
});
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('40');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const renderedPixels = await flattenedPixelDigest(page);
const pngBytes = await downloadPng(page, 'group-effects');
const comparison = await compareExportPixels(page, pngBytes, { width: 240, height: 180 });
expect(comparison.meanAbsoluteError).toBe(0);
expect(comparison.maximumError).toBe(0);
expect(await flattenedPixelDigest(page)).toEqual(renderedPixels);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.groups[0].effects).toHaveLength(1);
expect(reopened.groups[0].effects[0]).toMatchObject({
type: 'color-overlay',
params: { color: '#336699', opacity: 0.4 },
});
});
+68
View File
@@ -0,0 +1,68 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function compositeAlphaAtCenter(page) {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const x = Math.floor(state.imgWidth / 2);
const y = Math.floor(state.imgHeight / 2);
return state.documentCompositeCanvas.getContext('2d').getImageData(x, y, 1, 1).data[3];
});
}
test('group mask paints, toggles, survives history and reopen, and protects ungroup', async ({ page, request }) => {
await openBlankEditor(page, { width: 420, height: 300 }, 'Group mask E2E');
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await page.locator('#ge-layer-tools .ge-group-mask-btn').click();
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
await expect(page.locator('#ge-layer-tools button[title="Delete group masks before ungrouping"]')).toBeDisabled();
let current = await editorState(page);
expect(current.groups[0].masks).toHaveLength(1);
expect(current.groups[0].masks[0]).toMatchObject({ mode: 'group', size: [420, 300] });
const whiteMaskHash = current.groups[0].masks[0].pixelHash;
expect(await compositeAlphaAtCenter(page)).toBe(255);
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 90;
state.eraserSoftness = 0;
});
await dragOnCanvas(page, { x: 0.46, y: 0.5 }, { x: 0.54, y: 0.5 });
current = await editorState(page);
const erasedMaskHash = current.groups[0].masks[0].pixelHash;
expect(erasedMaskHash).not.toBe(whiteMaskHash);
expect(await compositeAlphaAtCenter(page)).toBe(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups[0].masks[0].pixelHash).toBe(whiteMaskHash);
expect(await compositeAlphaAtCenter(page)).toBe(255);
await page.locator('#ge-redo').click();
expect((await editorState(page)).groups[0].masks[0].pixelHash).toBe(erasedMaskHash);
expect(await compositeAlphaAtCenter(page)).toBe(0);
const maskRow = page.locator('.ge-group-mask-sub-item');
await maskRow.locator('.ge-layer-vis').click();
expect(await compositeAlphaAtCenter(page)).toBe(255);
await maskRow.locator('.ge-layer-vis').click();
expect(await compositeAlphaAtCenter(page)).toBe(0);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.groups).toEqual(beforeReopen.groups);
expect(await compositeAlphaAtCenter(page)).toBe(0);
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
await page.locator('.ge-group-mask-sub-item button[title="Delete group mask"]').click();
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(0);
await groupRow.click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
expect((await editorState(page)).groups).toHaveLength(0);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,107 @@
const { test, expect } = require('@playwright/test');
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('group drag reorders the complete subtree with undo and server persistence', async ({ page, request }) => {
await openBlankEditor(page, { width: 420, height: 300 }, 'Group reorder E2E');
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
let current = await editorState(page);
const group = current.groups[0];
const originalOrder = current.layers.map(layer => layer.id);
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page);
const looseId = current.activeLayerId;
expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
const handle = page.locator(`.ge-layer-group-row[data-group-id="${group.id}"] .ge-layer-group-drag`);
const looseRow = page.locator(`.ge-layer-item[data-layer-id="${looseId}"]`);
const handleBox = await handle.boundingBox();
const looseBox = await looseRow.boundingBox();
const dragReadiness = await page.evaluate(async ({ groupId, point }) => {
const { state } = await import('/static/js/editor/state.js');
const { groupSiblingUnits } = await import('/static/js/editor/layer-groups.js');
const group = state.layerGroups.find(item => item.id === groupId);
const hit = document.elementFromPoint(point.x, point.y);
return {
siblingIds: groupSiblingUnits(state, group.parentId || null).map(unit => unit.id),
hitHandle: !!hit?.closest('.ge-layer-group-drag'),
};
}, { groupId: group.id, point: { x: handleBox.x + handleBox.width / 2, y: handleBox.y + handleBox.height / 2 } });
expect(dragReadiness.hitHandle, JSON.stringify(dragReadiness)).toBe(true);
expect(dragReadiness.siblingIds).toEqual([group.id, looseId]);
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
await page.mouse.down();
await page.mouse.move(looseBox.x + looseBox.width / 2, looseBox.y + 1, { steps: 8 });
await expect(page.locator('.ge-group-drop-line')).toBeVisible();
await page.mouse.up();
current = await editorState(page);
expect(current.layers.map(layer => layer.id)).toEqual([looseId, ...originalOrder]);
expect(current.groups[0].layerIds).toEqual(group.layerIds);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([looseId, ...originalOrder]);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.map(layer => layer.id)).toEqual(beforeReopen.layers.map(layer => layer.id));
expect(current.groups).toEqual(beforeReopen.groups);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('mobile layer drag handle supports long-press reorder', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder');
await page.locator('.ge-layers-header').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
const before = await editorState(page);
// State is bottom-to-top; the panel is top-to-bottom.
const dragId = before.layers[0].id;
const targetId = before.layers[before.layers.length - 1].id;
const bottomHandle = page.locator(`.ge-layer-item[data-layer-id="${dragId}"] .ge-layer-drag`);
const topRow = page.locator(`.ge-layer-item[data-layer-id="${targetId}"]`);
await bottomHandle.scrollIntoViewIfNeeded();
const handleBox = await bottomHandle.boundingBox();
const topBox = await topRow.boundingBox();
expect(handleBox.width).toBeGreaterThanOrEqual(24);
const point = { x: handleBox.x + handleBox.width / 2, y: handleBox.y + handleBox.height / 2 };
const hitHandle = await page.evaluate(({ x, y }) => {
const hit = document.elementFromPoint(x, y);
return !!hit?.closest('.ge-layer-drag');
}, point);
expect(hitHandle).toBe(true);
const cdp = await page.context().newCDPSession(page);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: point.x, y: point.y, id: 1, radiusX: 8, radiusY: 8 }],
});
await page.waitForTimeout(450);
await expect(page.locator(`.ge-layer-item[data-layer-id="${dragId}"]`)).toHaveClass(/dragging/);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: point.x, y: topBox.y + 1, id: 1, radiusX: 8, radiusY: 8 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
await page.waitForTimeout(120);
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([targetId, dragId]);
} finally {
await context.close();
}
});
+375
View File
@@ -0,0 +1,375 @@
const { expect } = require('@playwright/test');
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
let lastError = null;
for (let pageAttempt = 0; pageAttempt < 3; pageAttempt += 1) {
try {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.evaluate(async () => {
window.__photoEditorImport = async path => {
let lastImportError = null;
for (let attempt = 0; attempt < 4; attempt += 1) {
const url = attempt === 0 ? path : `${path}?e2e_retry=${attempt}-${Date.now()}`;
try { return await import(url); } catch (error) { lastImportError = error; }
await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1)));
}
throw lastImportError;
};
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}, { size, name });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
lastError = null;
break;
} catch (error) {
lastError = error;
await page.goto('about:blank');
await page.waitForTimeout(250 * (pageAttempt + 1));
}
}
if (lastError) throw lastError;
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.layers.length;
})).toBe(2);
}
async function editorState(page) {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const hashCanvas = canvas => {
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let hash = 2166136261;
for (let index = 0; index < data.length; index += Math.max(4, Math.floor(data.length / 20_000 / 4) * 4)) {
const alpha = data[index + 3] / 255;
hash ^= Math.round(data[index] * alpha);
hash = Math.imul(hash, 16777619);
hash ^= Math.round(data[index + 1] * alpha);
hash = Math.imul(hash, 16777619);
hash ^= Math.round(data[index + 2] * alpha);
hash = Math.imul(hash, 16777619);
hash ^= data[index + 3];
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
};
const selectionBounds = canvas => {
if (!canvas) return null;
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let left = canvas.width, top = canvas.height, right = -1, bottom = -1;
for (let y = 0; y < canvas.height; y += 1) for (let x = 0; x < canvas.width; x += 1) {
if (data[(y * canvas.width + x) * 4 + 3] < 1) continue;
left = Math.min(left, x); top = Math.min(top, y);
right = Math.max(right, x); bottom = Math.max(bottom, y);
}
return right < left ? null : { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
};
return {
documentRenderReady: !!state.documentRenderReady,
dimensions: [state.imgWidth, state.imgHeight],
activeLayerId: state.activeLayerId,
selectedLayerIds: [...(state.selectedLayerIds || [])],
groups: (state.layerGroups || []).map(group => ({
id: group.id,
name: group.name,
layerIds: [...group.layerIds],
parentId: group.parentId || null,
visible: group.visible !== false,
opacity: group.opacity,
blendMode: group.blendMode,
locked: !!group.locked,
collapsed: !!group.collapsed,
activeMaskId: group.activeMaskId || null,
effects: (group.effects || []).map(effect => ({
id: effect.id,
type: effect.type,
name: effect.name,
visible: effect.visible !== false,
opacity: effect.opacity,
params: JSON.parse(JSON.stringify(effect.params || {})),
})),
masks: (group.masks || []).map(mask => ({
id: mask.id,
name: mask.name,
visible: mask.visible !== false,
density: Number.isFinite(Number(mask.density)) ? mask.density : 1,
feather: Number.isFinite(Number(mask.feather)) ? mask.feather : 0,
mode: mask.mode,
size: [mask.canvas.width, mask.canvas.height],
pixelHash: hashCanvas(mask.canvas),
})),
})),
layers: state.layers.map(layer => ({
id: layer.id,
name: layer.name,
kind: layer.kind || 'raster',
effects: (layer.effects || []).map(effect => ({
id: effect.id,
type: effect.type,
name: effect.name,
visible: effect.visible !== false,
opacity: effect.opacity,
mask: effect.mask ? {
visible: effect.mask.visible !== false,
size: [effect.mask.canvas?.width || effect.mask.canvasW, effect.mask.canvas?.height || effect.mask.canvasH],
} : null,
params: JSON.parse(JSON.stringify(effect.params || {})),
})),
visible: layer.visible !== false,
opacity: layer.opacity,
locked: !!layer.locked,
locks: {
pixels: !!layer.locks?.pixels,
transparency: !!layer.locks?.transparency,
position: !!layer.locks?.position,
},
clipped: !!layer.clipped,
size: [layer.canvas.width, layer.canvas.height],
offset: state.layerOffsets.get(layer.id) || { x: 0, y: 0 },
pixelHash: hashCanvas(layer.canvas),
text: layer.text ? {
content: layer.text.content,
fontSize: layer.text.fontSize,
fontFamily: layer.text.fontFamily,
lineHeight: layer.text.lineHeight,
letterSpacing: layer.text.letterSpacing,
frameWidth: layer.text.frameWidth,
frameHeight: layer.text.frameHeight,
verticalAlign: layer.text.verticalAlign,
autoWidth: layer.text.autoWidth,
transform: { ...layer.text.transform },
} : null,
shape: layer.shape ? {
type: layer.shape.type,
width: layer.shape.width,
height: layer.shape.height,
fillColor: layer.shape.fillColor,
fillType: layer.shape.fillType,
gradientStart: layer.shape.gradientStart,
gradientMid: layer.shape.gradientMid,
gradientMidEnabled: layer.shape.gradientMidEnabled,
gradientMidPosition: layer.shape.gradientMidPosition,
gradientEnd: layer.shape.gradientEnd,
gradientStops: layer.shape.gradientStops ? layer.shape.gradientStops.map(stop => ({ ...stop })) : undefined,
gradientAngle: layer.shape.gradientAngle,
strokeColor: layer.shape.strokeColor,
strokeWidth: layer.shape.strokeWidth,
cornerRadius: layer.shape.cornerRadius,
sides: layer.shape.sides,
transform: { ...layer.shape.transform },
} : null,
adjustment: layer.adjustment ? JSON.parse(JSON.stringify(layer.adjustment)) : null,
placed: layer.kind === 'placed' && layer.placed?.sourceCanvas ? {
sourceSize: [layer.placed.sourceCanvas.width, layer.placed.sourceCanvas.height],
sourceName: layer.placed.sourceName,
matrix: [...layer.placed.matrix],
sourcePixelHash: hashCanvas(layer.placed.sourceCanvas),
} : null,
masks: (layer.masks || []).map(mask => ({
id: mask.id,
mode: mask.mode,
space: mask.space,
linked: mask.mode === 'layer' ? mask.linked !== false : true,
density: Number.isFinite(Number(mask.density)) ? mask.density : 1,
feather: Number.isFinite(Number(mask.feather)) ? mask.feather : 0,
offset: { x: Number(mask.offset?.x) || 0, y: Number(mask.offset?.y) || 0 },
size: [mask.canvas.width, mask.canvas.height],
pixelHash: hashCanvas(mask.canvas),
})),
})),
guides: state.guides,
draftId: state.draftId,
undo: state.undoStack.length,
redo: state.redoStack.length,
quickMaskActive: !!state.quickMaskActive,
selection: state.wandMask ? {
space: state.wandMaskSpace,
source: state.selectionSource,
size: [state.wandMask.width, state.wandMask.height],
pixelHash: hashCanvas(state.wandMask),
bounds: selectionBounds(state.wandMask),
} : null,
savedSelections: (state.savedSelections || []).map(selection => ({
id: selection.id,
name: selection.name,
size: [selection.canvas.width, selection.canvas.height],
pixelHash: hashCanvas(selection.canvas),
bounds: selectionBounds(selection.canvas),
})),
lastSelection: state.lastSelection?.canvas ? {
pixelHash: hashCanvas(state.lastSelection.canvas),
bounds: selectionBounds(state.lastSelection.canvas),
} : null,
persistIdle: !state.persistTimer && !state.persistInFlight,
};
});
}
async function dragOnCanvas(page, start, end) {
const box = await page.locator('.ge-main-canvas').boundingBox();
if (!box) throw new Error('Canvas has no visible bounds');
await page.mouse.move(box.x + box.width * start.x, box.y + box.height * start.y);
await page.mouse.down();
await page.mouse.move(box.x + box.width * end.x, box.y + box.height * end.y, { steps: 10 });
await page.mouse.up();
}
async function waitForDraft(page) {
await expect.poll(async () => {
const current = await editorState(page);
return !!current.draftId && current.persistIdle;
}, { timeout: 15_000 }).toBe(true);
return (await editorState(page)).draftId;
}
async function reopenDraft(page, draftId) {
await page.evaluate(async id => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
window.__galleryAllowCloseEditor = true;
editor.closeEditor();
await editor.openEditor(null, null, null, 'Reopened E2E document', id);
}, draftId);
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
await expect.poll(async () => (await editorState(page)).draftId).toBe(draftId);
await expect.poll(async () => (await editorState(page)).documentRenderReady, {
timeout: 20_000,
}).toBe(true);
}
async function openExportDialog(page) {
await page.locator('#ge-save-menu-btn').click();
await page.locator('#ge-download').click();
await expect(page.locator('.ge-export-dialog')).toBeVisible();
}
async function flattenedPixelDigest(page, size = null) {
return page.evaluate(async targetSize => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
const image = new Image();
image.src = editor.exportPNG();
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = targetSize?.width || image.naturalWidth;
canvas.height = targetSize?.height || image.naturalHeight;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const raw = context.getImageData(0, 0, canvas.width, canvas.height).data;
const pixels = new Uint8ClampedArray(raw.length);
for (let index = 0; index < raw.length; index += 4) {
const alpha = raw[index + 3] / 255;
pixels[index] = Math.round(raw[index] * alpha);
pixels[index + 1] = Math.round(raw[index + 1] * alpha);
pixels[index + 2] = Math.round(raw[index + 2] * alpha);
pixels[index + 3] = raw[index + 3];
}
const digest = await crypto.subtle.digest('SHA-256', pixels);
return {
width: canvas.width,
height: canvas.height,
sha256: Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''),
};
}, size);
}
async function encodedImagePixelDigest(page, bytes) {
return page.evaluate(async base64 => {
const binary = atob(base64);
const encoded = Uint8Array.from(binary, character => character.charCodeAt(0));
const bitmap = await createImageBitmap(new Blob([encoded], { type: 'image/png' }));
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
bitmap.close();
const raw = context.getImageData(0, 0, canvas.width, canvas.height).data;
const pixels = new Uint8ClampedArray(raw.length);
for (let index = 0; index < raw.length; index += 4) {
const alpha = raw[index + 3] / 255;
pixels[index] = Math.round(raw[index] * alpha);
pixels[index + 1] = Math.round(raw[index + 1] * alpha);
pixels[index + 2] = Math.round(raw[index + 2] * alpha);
pixels[index + 3] = raw[index + 3];
}
const digest = await crypto.subtle.digest('SHA-256', pixels);
return {
width: canvas.width,
height: canvas.height,
sha256: Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''),
};
}, bytes.toString('base64'));
}
async function compareExportPixels(page, bytes, size) {
return page.evaluate(async ({ base64, targetSize }) => {
const decode = async source => {
const image = new Image();
image.src = source;
await image.decode();
return image;
};
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
const expectedImage = await decode(editor.exportPNG());
const binary = atob(base64);
const actualImage = await decode(URL.createObjectURL(new Blob([
Uint8Array.from(binary, character => character.charCodeAt(0)),
], { type: 'image/png' })));
const pixels = image => {
const canvas = document.createElement('canvas');
canvas.width = targetSize.width;
canvas.height = targetSize.height;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return context.getImageData(0, 0, canvas.width, canvas.height).data;
};
const expected = pixels(expectedImage);
const actual = pixels(actualImage);
URL.revokeObjectURL(actualImage.src);
let total = 0;
let maximum = 0;
let changedPixels = 0;
for (let index = 0; index < expected.length; index += 4) {
const expectedAlpha = expected[index + 3] / 255;
const actualAlpha = actual[index + 3] / 255;
let pixelMaximum = Math.abs(expected[index + 3] - actual[index + 3]);
total += pixelMaximum;
for (let channel = 0; channel < 3; channel += 1) {
const delta = Math.abs(
expected[index + channel] * expectedAlpha - actual[index + channel] * actualAlpha,
);
total += delta;
pixelMaximum = Math.max(pixelMaximum, delta);
}
maximum = Math.max(maximum, pixelMaximum);
if (pixelMaximum > 1) changedPixels += 1;
}
return {
meanAbsoluteError: total / expected.length,
maximumError: maximum,
changedPixelRatio: changedPixels / (expected.length / 4),
};
}, { base64: bytes.toString('base64'), targetSize: size });
}
module.exports = {
compareExportPixels,
dragOnCanvas,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
};
@@ -0,0 +1,249 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, editorState, dragOnCanvas } = require('./helpers');
test.beforeEach(async ({ page }) => {
// The isolated editor server has no logged-in notification owner.
await page.route('**/api/tasks/notification-logs*', route => route.fulfill({
json: { logs: [], notifications: [] },
}));
});
test('top toolbar stays on one row and its menus remain clickable at narrow widths', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
const tip = page.getByRole('button', { name: 'Got it', exact: true });
if (await tip.isVisible()) await tip.click();
for (const width of [1280, 900, 600, 390]) {
await page.setViewportSize({ width, height: 850 });
await expect.poll(() => page.locator('.ge-topbar').evaluate(bar => {
const left = bar.querySelector('.ge-topbar-left').getBoundingClientRect();
const right = bar.querySelector('.ge-topbar-right').getBoundingClientRect();
return Math.abs((left.top + left.bottom) / 2 - (right.top + right.bottom) / 2);
})).toBeLessThan(2);
await page.locator('#ge-save-menu-btn').scrollIntoViewIfNeeded();
await expect(page.locator('#ge-save-menu-btn')).toHaveCount(1);
await expect(page.locator('#ge-draft-status')).toHaveCount(1);
const status = page.locator('#ge-save-menu-btn > #ge-draft-status');
await expect(status).toBeVisible();
await expect(page.locator('#ge-save-menu-btn .ge-save-state-icon:not([hidden])')).toHaveCount(1);
await expect(status).toBeVisible();
await expect(page.locator('.ge-topbar-right > #ge-draft-status')).toHaveCount(0);
await page.locator('#ge-image-menu-btn').click();
await expect(page.locator('#ge-image-menu')).toBeVisible();
await page.locator('[data-image-action="rotate-90"]').click();
await expect(page.locator('#ge-image-menu')).toBeHidden();
await page.screenshot({ path: `/tmp/editor-toolbar-${width}.png` });
}
});
test('committed paint refreshes the layer thumbnail and flashes only the edited row', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
const activeId = (await editorState(page)).activeLayerId;
const row = page.locator(`.ge-layer-item[data-layer-id="${activeId}"]`);
const before = await row.locator('.ge-layer-inline-thumb').evaluate(canvas => canvas.toDataURL());
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
await expect(row).toHaveClass(/ge-layer-action-flash/);
expect(await row.locator('.ge-layer-inline-thumb').evaluate(canvas => canvas.toDataURL())).not.toBe(before);
await expect(page.locator('.ge-layer-action-flash')).toHaveCount(1);
await expect(row).not.toHaveClass(/ge-layer-action-flash/, { timeout: 2000 });
});
test('filter Escape restores pixels and preserves redo history', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
await dragOnCanvas(page, { x: 0.7, y: 0.2 }, { x: 0.2, y: 0.7 });
await page.keyboard.press('Control+z');
const before = await editorState(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="blur-gaussian"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.ge-filter-modal')).toHaveCount(0);
const after = await editorState(page);
expect(after.layers).toEqual(before.layers);
expect(after.undo).toBe(before.undo);
expect(after.redo).toBe(before.redo);
});
test('focused fields own undo, selection and clipboard without changing layers', async ({ page }) => {
await openBlankEditor(page);
await page.locator('[data-tool="text"]').click();
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(box.x + 80, box.y + 80);
const input = page.locator('.ge-direct-text-editor');
await input.fill('Editable text');
await input.press('Control+Enter');
// A tool's ordinary text input must also retain native clipboard ownership.
await page.locator('#ge-text-frame-width').focus();
const before = await editorState(page);
await page.keyboard.press('Control+z');
await page.keyboard.press('Control+j');
await page.keyboard.press('Control+a');
await page.keyboard.press('Control+c');
await page.evaluate(() => {
document.activeElement.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true }));
});
const after = await editorState(page);
expect(after.layers).toEqual(before.layers);
expect(after.undo).toBe(before.undo);
expect(after.selection).toEqual(before.selection);
});
test('tool keys never delete a selection and Ctrl J copies only selected pixels', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.fillStyle = '#f00';
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
});
await page.keyboard.press('m');
await expect(page.locator('[data-tool="marquee"]')).toHaveClass(/active/);
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.4, y: 0.4 });
const original = await editorState(page);
await page.keyboard.press('c');
await expect(page.locator('[data-tool="crop"]')).toHaveClass(/active/);
await page.keyboard.press('d');
expect((await editorState(page)).layers).toEqual(original.layers);
expect((await editorState(page)).selection).toEqual(original.selection);
await page.keyboard.press('Control+j');
expect((await editorState(page)).layers).toHaveLength(original.layers.length + 1);
const copiedAlpha = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
return [layer.ctx.getImageData(50, 50, 1, 1).data[3], layer.ctx.getImageData(250, 180, 1, 1).data[3]];
});
expect(copiedAlpha).toEqual([255, 0]);
await page.keyboard.press('Control+z');
expect((await editorState(page)).layers).toEqual(original.layers);
await page.keyboard.press('Control+d');
expect((await editorState(page)).selection).toBeNull();
await page.keyboard.press('s');
await expect(page.locator('[data-tool="clone"]')).toHaveClass(/active/);
});
test('Shift Alt intersects a marquee and reopening does not duplicate shortcuts', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await page.evaluate(async () => {
const editor = await import('/static/js/galleryEditor.js');
await editor.openEditor(null, null, { w: 320, h: 240 }, 'Reopened');
});
await page.keyboard.press('m');
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.6, y: 0.6 });
await page.keyboard.down('Shift');
await page.keyboard.down('Alt');
await dragOnCanvas(page, { x: 0.4, y: 0.4 }, { x: 0.9, y: 0.9 });
await page.keyboard.up('Alt');
await page.keyboard.up('Shift');
const selection = (await editorState(page)).selection.bounds;
expect(selection.x).toBeGreaterThanOrEqual(127);
expect(selection.width).toBeLessThanOrEqual(65);
const before = await editorState(page);
await page.keyboard.press('Control+j');
expect((await editorState(page)).layers.length).toBe(before.layers.length + 1);
});
test('cut removes selected pixels without creating a layer and undo restores them', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.fillStyle = '#f00';
layer.ctx.fillRect(0, 0, 320, 240);
});
await page.keyboard.press('m');
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.4, y: 0.4 });
const before = await editorState(page);
await page.keyboard.press('Control+x');
await expect.poll(async () => (await editorState(page)).selection).toBeNull();
const cut = await editorState(page);
expect(cut.layers.length).toBe(before.layers.length);
expect(cut.layers.find(layer => layer.id === before.activeLayerId).pixelHash)
.not.toBe(before.layers.find(layer => layer.id === before.activeLayerId).pixelHash);
await page.keyboard.press('Control+z');
expect((await editorState(page)).layers).toEqual(before.layers);
expect((await editorState(page)).selection).toEqual(before.selection);
});
test('selection delete and fill edit an offset mask, preserving parent pixels', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.fillStyle = '#f00';
layer.ctx.fillRect(0, 0, 320, 240);
const canvas = document.createElement('canvas');
canvas.width = 200; canvas.height = 160;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, 200, 160);
layer.masks = [{ id: 'test-mask', name: 'Mask', mode: 'layer', space: 'layer',
canvas, ctx, offset: { x: 10, y: 10 }, visible: true, linked: true }];
layer.activeMaskId = 'test-mask';
state.layerOffsets.set(layer.id, { x: 20, y: 10 });
state.color = '#ffffff';
});
await page.keyboard.press('m');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
const before = await editorState(page);
const parentBefore = before.layers.find(layer => layer.id === before.activeLayerId);
await page.keyboard.press('Delete');
await expect.poll(async () => (await editorState(page)).selection).toBeNull();
const erased = (await editorState(page)).layers.find(layer => layer.id === before.activeLayerId);
expect(erased.pixelHash).toBe(parentBefore.pixelHash);
expect(erased.masks[0].pixelHash).not.toBe(parentBefore.masks[0].pixelHash);
await page.locator('#ge-image-menu-btn').click();
await page.locator('[data-image-action="fill"]').click();
await expect.poll(async () => (await editorState(page)).layers.find(layer => layer.id === before.activeLayerId).masks[0].pixelHash)
.toBe(parentBefore.masks[0].pixelHash);
expect((await editorState(page)).layers.find(layer => layer.id === before.activeLayerId).pixelHash).toBe(parentBefore.pixelHash);
await page.keyboard.press('Control+c');
const clipboard = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { pixel: [...state.internalClipboard.getContext('2d').getImageData(50, 50, 1, 1).data], offset: state.internalClipboardOffset };
});
expect(clipboard).toEqual({ pixel: [255, 255, 255, 255], offset: { x: 30, y: 20 } });
await page.evaluate(() => document.body.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true })));
const pasted = await editorState(page);
expect(pasted.layers).toHaveLength(before.layers.length + 1);
expect(pasted.layers.find(layer => layer.id === pasted.activeLayerId).offset).toEqual({ x: 30, y: 20 });
await page.keyboard.press('Control+z');
expect((await editorState(page)).layers).toHaveLength(before.layers.length);
});
test('focus loss releases a held brush and does not resume it on return', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await page.keyboard.press('b');
const before = await editorState(page);
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(canvas.x + 50, canvas.y + 50);
await page.mouse.down();
await page.mouse.move(canvas.x + 90, canvas.y + 70, { steps: 5 });
await page.evaluate(() => window.dispatchEvent(new Event('blur')));
expect(await page.evaluate(async () => (await import('/static/js/editor/state.js')).state.drawing)).toBe(false);
const released = await editorState(page);
await page.mouse.move(canvas.x + 150, canvas.y + 100, { steps: 5 });
await page.mouse.up();
expect((await editorState(page)).layers).toEqual(released.layers);
expect((await editorState(page)).undo).toBe(before.undo + 1);
await page.keyboard.press('Control+z');
expect((await editorState(page)).layers).toEqual(before.layers);
});
test('switching away from a held brush ends one stroke and desktop reselect keeps controls open', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await page.keyboard.press('b');
await page.keyboard.press('b');
await expect(page.locator('.ge-controls')).not.toHaveClass(/dismissed/);
const before = await editorState(page);
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(canvas.x + 50, canvas.y + 50);
await page.mouse.down();
await page.mouse.move(canvas.x + 90, canvas.y + 70, { steps: 5 });
await page.keyboard.press('v');
expect(await page.evaluate(async () => (await import('/static/js/editor/state.js')).state.drawing)).toBe(false);
await page.mouse.up();
expect((await editorState(page)).undo).toBe(before.undo + 1);
await page.keyboard.press('Control+z');
expect((await editorState(page)).layers).toEqual(before.layers);
});
@@ -0,0 +1,57 @@
const { test, expect } = require('@playwright/test');
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function sampleComposite(page) {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const ctx = state.documentCompositeCanvas.getContext('2d');
return {
left: [...ctx.getImageData(40, 50, 1, 1).data],
right: [...ctx.getImageData(160, 50, 1, 1).data],
};
});
}
test('clipping mask uses base alpha and survives undo, redo, and server reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 200, height: 100 }, 'Clipping E2E');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const base = state.layers.find(layer => layer.name === 'Background');
const color = state.layers.find(layer => layer.name === 'Edit');
for (const layer of [base, color]) layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
base.ctx.fillStyle = '#ff0000';
base.ctx.fillRect(0, 0, 100, 100);
color.ctx.fillStyle = '#0000ff';
color.ctx.fillRect(0, 0, 200, 100);
});
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await expect(editRow).toHaveClass(/clipped/);
await expect(editRow.locator('.ge-layer-clipped-marker')).toBeVisible();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
let pixels = await sampleComposite(page);
expect(pixels.left).toEqual([0, 0, 255, 255]);
expect(pixels.right[3]).toBe(0);
await page.locator('#ge-undo').click();
expect((await sampleComposite(page)).right).toEqual([0, 0, 255, 255]);
await page.locator('#ge-redo').click();
expect((await sampleComposite(page)).right[3]).toBe(0);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
expect((await sampleComposite(page)).right[3]).toBe(0);
await expect(page.locator('.ge-layer-item.clipped .ge-layer-clipped-marker')).toBeVisible();
await page.locator('#ge-layer-tools button[title="Merge down into layer below"]').click();
expect((await editorState(page)).layers).toHaveLength(1);
expect((await sampleComposite(page)).right[3]).toBe(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
expect((await sampleComposite(page)).right).toEqual([0, 0, 255, 255]);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,65 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('independent layer locks enforce operations and survive undo and reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 360, height: 260 }, 'Layer locks E2E');
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
const lockButton = editRow.locator('.ge-layer-lock-btn');
const opacityRow = editRow.locator('.ge-layer-opacity-row');
const lockBox = await lockButton.boundingBox();
const opacityBox = await opacityRow.boundingBox();
expect(lockBox).toBeTruthy();
expect(opacityBox).toBeTruthy();
expect(lockBox.y + lockBox.height).toBeLessThanOrEqual(opacityBox.y + 1);
const toggleLock = async type => {
await lockButton.click();
const menu = page.locator('#ge-layer-lock-menu');
await expect(menu).toBeVisible();
await menu.locator(`[data-lock-type="${type}"]`).click();
};
await toggleLock('position');
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').locks.position).toBe(true);
await expect(lockButton).toHaveClass(/active/);
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.55, y: 0.50 });
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 0, y: 0 });
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').locks.position).toBe(false);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').locks.position).toBe(true);
await toggleLock('position');
await toggleLock('pixels');
const emptyHash = (await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash;
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.58, y: 0.50 });
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash).toBe(emptyHash);
await toggleLock('pixels');
await toggleLock('transparency');
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.58, y: 0.50 });
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash).toBe(emptyHash);
await toggleLock('transparency');
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.58, y: 0.50 });
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash).not.toBe(emptyHash);
await toggleLock('pixels');
await toggleLock('transparency');
await toggleLock('position');
const beforeReopen = await editorState(page);
const expectedLocks = beforeReopen.layers.find(layer => layer.name === 'Edit').locks;
expect(expectedLocks).toEqual({ pixels: true, transparency: true, position: true });
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.name === 'Edit').locks).toEqual(expectedLocks);
await expect(page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first().locator('.ge-layer-lock-btn')).toHaveClass(/active/);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,141 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
test('layer mask link controls independent movement and survives reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Linked layer mask');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.35 }, { x: 0.75, y: 0.6 });
let current = await editorState(page);
const layerId = current.activeLayerId;
const layerRow = page.locator(`.ge-layer-item[data-layer-id="${layerId}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
current = await editorState(page);
const originalLayer = current.layers.find(layer => layer.id === layerId);
expect(originalLayer.masks[0].linked).toBe(true);
expect(originalLayer.masks[0].offset).toEqual({ x: 0, y: 0 });
const originalMaskHash = originalLayer.masks[0].pixelHash;
await maskRow.locator('button[title="Invert mask"]').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].pixelHash)
.not.toBe(originalMaskHash);
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].pixelHash)
.toBe(originalMaskHash);
await maskRow.locator('details.ge-mask-properties > summary').click();
await maskRow.locator('input.ge-mask-density').fill('50');
await maskRow.locator('input.ge-mask-density').dispatchEvent('change');
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].density).toBe(0.5);
await maskRow.locator('input.ge-mask-feather').fill('12');
await maskRow.locator('input.ge-mask-feather').dispatchEvent('change');
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].feather).toBe(12);
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].density).toBe(1);
expect(current.layers.find(layer => layer.id === layerId).masks[0].feather).toBe(0);
await maskRow.locator('button[title="Inspect mask"]').click();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.maskInspectMode;
})).toBe(true);
const inspectedPixel = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return [...state.mainCtx.getImageData(Math.floor(state.imgWidth / 2), Math.floor(state.imgHeight / 2), 1, 1).data];
});
expect(inspectedPixel[0]).toBeGreaterThan(200);
expect(inspectedPixel[1]).toBeGreaterThan(200);
expect(inspectedPixel[2]).toBeGreaterThan(200);
await page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' })
.locator('button[title="Inspect mask"]').click();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.maskInspectMode;
})).toBe(false);
await maskRow.locator('.ge-mask-link-btn').click();
await expect(maskRow.locator('.ge-mask-link-btn')).toHaveAttribute('aria-pressed', 'false');
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await dragOnCanvas(page, { x: 0.45, y: 0.45 }, { x: 0.55, y: 0.5 });
current = await editorState(page);
let layer = current.layers.find(item => item.id === layerId);
expect(layer.offset).toEqual(originalLayer.offset);
expect(layer.pixelHash).toBe(originalLayer.pixelHash);
expect(layer.masks[0].linked).toBe(false);
expect(layer.masks[0].offset.x).toBeGreaterThan(0);
expect(layer.masks[0].offset.y).toBeGreaterThan(0);
const fixedDocumentMask = {
x: layer.offset.x + layer.masks[0].offset.x,
y: layer.offset.y + layer.masks[0].offset.y,
};
await layerRow.click();
await dragOnCanvas(page, { x: 0.4, y: 0.4 }, { x: 0.5, y: 0.45 });
current = await editorState(page);
layer = current.layers.find(item => item.id === layerId);
expect(layer.offset.x).toBeGreaterThan(originalLayer.offset.x);
expect(layer.offset.y).toBeGreaterThan(originalLayer.offset.y);
expect({
x: layer.offset.x + layer.masks[0].offset.x,
y: layer.offset.y + layer.masks[0].offset.y,
}).toEqual(fixedDocumentMask);
const beforeTransform = layer;
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-w').fill('280');
await page.locator('#ge-transform-w').dispatchEvent('input');
await page.locator('#ge-transform-apply').click();
current = await editorState(page);
layer = current.layers.find(item => item.id === layerId);
expect(layer.size[0]).toBe(280);
expect({
x: layer.offset.x + layer.masks[0].offset.x,
y: layer.offset.y + layer.masks[0].offset.y,
}).toEqual(fixedDocumentMask);
await page.locator('#ge-undo').click();
await expect.poll(async () => {
const state = await editorState(page);
return state.layers.find(item => item.id === layerId).size[0];
}).toBe(beforeTransform.size[0]);
await maskRow.locator('.ge-mask-link-btn').click();
current = await editorState(page);
const relinked = current.layers.find(item => item.id === layerId);
expect(relinked.masks[0].linked).toBe(true);
const linkedRelativeOffset = { ...relinked.masks[0].offset };
await layerRow.click();
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.45, y: 0.35 });
current = await editorState(page);
layer = current.layers.find(item => item.id === layerId);
expect(layer.masks[0].offset).toEqual(linkedRelativeOffset);
await page.locator('#ge-undo').click();
await expect.poll(async () => {
const state = await editorState(page);
return state.layers.find(item => item.id === layerId).offset.x;
}).toBe(relinked.offset.x);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(item => item.id === layerId)).toEqual(
beforeReopen.layers.find(item => item.id === layerId),
);
const maskRowAfterReopen = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await maskRowAfterReopen.locator('details.ge-mask-properties > summary').click();
await maskRowAfterReopen.locator('button[title="Bake this mask into the layer and remove the mask"]').click();
await expect.poll(async () => (await editorState(page)).layers.find(item => item.id === layerId).masks).toHaveLength(0);
await page.locator('#ge-undo').click();
await expect.poll(async () => (await editorState(page)).layers.find(item => item.id === layerId).masks).toHaveLength(1);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,331 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden', async ({ page }) => {
// Notification polling requires login even in the auth-disabled test server.
await page.route('**/api/tasks/notification-logs*', route => route.fulfill({ json: { logs: [] } }));
await openBlankEditor(page, { width: 240, height: 160 }, 'Thumbnail selection');
const id = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
layer.ctx.fillStyle = '#ff0000';
layer.ctx.fillRect(30, 25, 60, 40);
state.wandMaskVisible = false;
return layer.id;
});
await page.locator(`.ge-layer-item[data-layer-id="${id}"] .ge-layer-inline-thumb`).click({ modifiers: ['Control'] });
await expect.poll(() => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const canvas = state.selectionOverlay;
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
return state.wandMaskVisible && canvas.style.display !== 'none' && pixels.some((value, index) => index % 4 === 3 && value > 0);
})).toBe(true);
await expect.poll(() => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const ctx = state.wandMask.getContext('2d');
return [ctx.getImageData(40, 35, 1, 1).data[3], ctx.getImageData(0, 0, 1, 1).data[3]];
})).toEqual([255, 0]);
});
test('selected layers align to the canvas and undo as one operation', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Alignment E2E');
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const edit = state.layers.find(layer => layer.name === 'Edit');
const added = state.layers.find(layer => layer.id === state.activeLayerId);
edit.canvas.width = 100;
edit.canvas.height = 80;
added.canvas.width = 50;
added.canvas.height = 40;
state.layerOffsets.set(edit.id, { x: 12, y: 18 });
state.layerOffsets.set(added.id, { x: 190, y: 150 });
});
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await editRow.click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
await page.locator('#ge-selected-align').click();
await page.locator('#ge-layer-align-menu button').filter({ hasText: 'Align center' }).click();
let current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 110, y: 18 });
expect(current.layers.find(layer => layer.name !== 'Background' && layer.name !== 'Edit').offset)
.toEqual({ x: 135, y: 150 });
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 12, y: 18 });
expect(current.layers.find(layer => layer.name !== 'Background' && layer.name !== 'Edit').offset)
.toEqual({ x: 190, y: 150 });
});
test('layer rename cancels on Escape without committing the draft name', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Rename cancel E2E');
const row = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
const rowId = await row.getAttribute('data-layer-id');
const name = row.locator('.ge-layer-name');
const original = await name.textContent();
await name.dblclick();
const stableRow = page.locator(`.ge-layer-item[data-layer-id="${rowId}"]`);
const input = stableRow.locator('.ge-layer-name-input');
await input.fill('Temporary name');
await input.press('Escape');
await expect(stableRow.locator('.ge-layer-name')).toHaveText(original);
await expect(stableRow.locator('.ge-layer-name-input')).toHaveCount(0);
});
test('layer rows can be selected with Enter and Space', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard layer selection E2E');
const background = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
const edit = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await background.focus();
await page.keyboard.press('Enter');
await expect(background).toHaveAttribute('aria-pressed', 'true');
await expect(edit).toHaveAttribute('aria-pressed', 'false');
await edit.focus();
await page.keyboard.press('Space');
await expect(edit).toHaveAttribute('aria-pressed', 'true');
await expect(background).toHaveAttribute('aria-pressed', 'false');
});
test('group rows can be selected with the keyboard', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard group selection E2E');
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
const group = page.locator('.ge-layer-group-row').first();
await group.focus();
await page.keyboard.press('Enter');
await expect(group).toHaveAttribute('aria-pressed', 'true');
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.activeGroupId;
})).toBe(await group.getAttribute('data-group-id'));
});
test('Delete removes the selected layer and undo restores it', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard delete E2E');
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
const addedId = (await editorState(page)).activeLayerId;
await page.locator(`.ge-layer-item[data-layer-id="${addedId}"]`).click();
await page.keyboard.press('Backspace');
expect((await editorState(page)).layers.some(layer => layer.id === addedId)).toBe(false);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.some(layer => layer.id === addedId)).toBe(true);
});
test('Ctrl/Cmd+J duplicates the active layer through the layer panel path', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard duplicate E2E');
const before = await editorState(page);
const activeId = before.activeLayerId;
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+j' : 'Control+j');
const after = await editorState(page);
expect(after.layers).toHaveLength(before.layers.length + 1);
expect(after.activeLayerId).not.toBe(activeId);
expect(after.layers.find(layer => layer.id === after.activeLayerId).name).toContain('copy');
});
test('move tool can auto-select the topmost visible layer under the pointer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Auto-select E2E');
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.75 });
const shape = (await editorState(page)).layers.find(layer => layer.kind === 'shape');
expect(shape).toBeTruthy();
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await expect(page.locator('.ge-auto-select-option')).toBeVisible();
await page.locator('.ge-auto-select-option').click();
await expect(page.locator('#ge-auto-select-layer')).toBeChecked();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-auto-select-option')).toBeHidden();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await expect(page.locator('.ge-auto-select-option')).toBeVisible();
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(box.x + box.width * 0.5, box.y + box.height * 0.5);
await expect.poll(async () => (await editorState(page)).activeLayerId).toBe(shape.id);
expect((await editorState(page)).selectedLayerIds).toEqual([shape.id]);
});
test('multi-selected layers move together and support history-backed bulk actions', async ({ page }) => {
await openBlankEditor(page);
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
expect((await editorState(page)).selectedLayerIds).toHaveLength(2);
await expect(page.locator('#ge-layer-selection-bar')).toBeVisible();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.52, y: 0.50 });
let current = await editorState(page);
const edit = current.layers.find(layer => layer.name === 'Edit');
const background = current.layers.find(layer => layer.name === 'Background');
expect(edit.offset).toEqual(background.offset);
expect(edit.offset).not.toEqual({ x: 0, y: 0 });
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 0, y: 0 });
expect(current.layers.find(layer => layer.name === 'Background').offset).toEqual({ x: 0, y: 0 });
expect(current.selectedLayerIds).toHaveLength(2);
await page.locator('#ge-redo').click();
await page.locator('#ge-selected-visibility').click();
current = await editorState(page);
expect(current.layers.every(layer => layer.visible === false)).toBe(true);
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.every(layer => layer.visible !== false)).toBe(true);
await page.locator('#ge-selected-lock').click();
current = await editorState(page);
expect(current.layers.every(layer => layer.locked)).toBe(true);
await page.locator('#ge-undo').click();
await editRow.click();
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
const addedId = (await editorState(page)).activeLayerId;
await editRow.click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
await page.locator('#ge-selected-delete').click();
current = await editorState(page);
expect(current.layers).toHaveLength(1);
expect(current.layers[0].name).toBe('Background');
expect(current.layers.some(layer => layer.id === addedId)).toBe(false);
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers).toHaveLength(3);
expect(current.selectedLayerIds).toHaveLength(2);
});
test('layer groups composite, lock, collapse, undo, and survive server reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 480, height: 320 }, 'Grouped E2E');
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
await expect(page.locator('.ge-layer-group-row .ge-group-inline-thumb')).toHaveCount(1);
let current = await editorState(page);
expect(current.groups).toHaveLength(1);
expect(current.groups[0].layerIds).toHaveLength(2);
const groupName = page.locator('.ge-layer-group-name');
await groupName.dblclick();
await page.locator('.ge-layer-group-row input.ge-layer-name-input').fill('Hero group');
await page.locator('.ge-layer-group-row input.ge-layer-name-input').press('Enter');
await page.locator('.ge-layer-group-row .ge-layer-opacity').fill('55');
await page.locator('.ge-layer-group-toggle').click();
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(0);
current = await editorState(page);
expect(current.groups[0]).toMatchObject({ name: 'Hero group', opacity: 0.55, collapsed: true });
await page.locator('.ge-layer-group-row .ge-layer-lock-btn').click();
const beforeLockNudge = await editorState(page);
await page.keyboard.press('ArrowRight');
expect((await editorState(page)).layers.map(layer => layer.offset)).toEqual(beforeLockNudge.layers.map(layer => layer.offset));
await page.locator('.ge-layer-group-row .ge-layer-lock-btn').click();
await page.locator('.ge-layer-group-row').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await dragOnCanvas(page, { x: 0.40, y: 0.40 }, { x: 0.52, y: 0.48 });
current = await editorState(page);
expect(current.layers[0].offset).toEqual(current.layers[1].offset);
expect(current.layers[0].offset).not.toEqual({ x: 0, y: 0 });
await page.locator('.ge-layer-group-row .ge-layer-vis').click();
current = await editorState(page);
expect(current.groups[0].visible).toBe(false);
expect(current.layers.every(layer => layer.visible)).toBe(true);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups[0].visible).toBe(true);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.groups).toEqual(beforeReopen.groups);
expect(reopened.layers.map(layer => layer.offset)).toEqual(beforeReopen.layers.map(layer => layer.offset));
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
await expect(page.locator('.ge-layer-group-row .ge-group-inline-thumb')).toHaveCount(1);
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(0);
await page.locator('.ge-layer-group-row').click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
expect((await editorState(page)).groups).toHaveLength(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups).toHaveLength(1);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('nested groups preserve hierarchy, ancestor locks, collapse, and server reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 420, height: 300 }, 'Nested groups E2E');
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
let current = await editorState(page);
const innerId = current.groups[0].id;
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page);
const looseId = current.activeLayerId;
await page.locator(`.ge-layer-group-row[data-group-id="${innerId}"]`).click();
await page.locator(`.ge-layer-item[data-layer-id="${looseId}"]`).click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(3);
await page.locator('#ge-group-selected').click();
current = await editorState(page);
expect(current.groups).toHaveLength(2);
const inner = current.groups.find(group => group.id === innerId);
const outer = current.groups.find(group => group.id !== innerId);
expect(inner.parentId).toBe(outer.id);
expect(outer.layerIds).toEqual([looseId]);
expect(inner.layerIds).toHaveLength(2);
await expect(page.locator('.ge-layer-group-row')).toHaveCount(2);
await expect(page.locator('.ge-layer-group-row .ge-group-inline-thumb')).toHaveCount(2);
await expect(page.locator(`.ge-layer-group-row[data-group-id="${inner.id}"]`)).toHaveCSS('--group-depth', '1');
const outerRow = page.locator(`.ge-layer-group-row[data-group-id="${outer.id}"]`);
await outerRow.locator('.ge-layer-group-toggle').click();
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(0);
await outerRow.locator('.ge-layer-group-toggle').click();
await expect(page.locator('.ge-layer-group-row')).toHaveCount(2);
await outerRow.locator('.ge-layer-lock-btn').click();
await page.locator(`.ge-layer-item[data-layer-id="${inner.layerIds[0]}"]`).click();
const beforeNudge = await editorState(page);
await page.keyboard.press('ArrowRight');
expect((await editorState(page)).layers.map(layer => layer.offset)).toEqual(beforeNudge.layers.map(layer => layer.offset));
await outerRow.locator('.ge-layer-lock-btn').click();
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.groups).toEqual(beforeReopen.groups);
await expect(page.locator('.ge-layer-group-row')).toHaveCount(2);
await page.locator(`.ge-layer-group-row[data-group-id="${outer.id}"]`).click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
current = await editorState(page);
expect(current.groups).toHaveLength(1);
expect(current.groups[0]).toMatchObject({ id: inner.id, parentId: null });
expect(current.groups[0].layerIds).toHaveLength(2);
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(2);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups).toEqual(beforeReopen.groups);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,48 @@
const { test, expect } = require('@playwright/test');
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
async function seedVisibleLayers(page) {
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const background = state.layers.find(layer => layer.name === 'Background');
const edit = state.layers.find(layer => layer.name === 'Edit');
background.ctx.fillStyle = '#26354f';
background.ctx.fillRect(0, 0, background.canvas.width, background.canvas.height);
edit.ctx.fillStyle = '#cf5b4a';
edit.ctx.fillRect(48, 32, edit.canvas.width - 96, edit.canvas.height - 64);
window.galleryEditorComposite?.();
});
}
test('Merge all preserves retained effects and adjustment output', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Merge fidelity E2E');
await seedVisibleLayers(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#f0c04a');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('45');
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const before = await flattenedPixelDigest(page);
const beforeState = await editorState(page);
expect(beforeState.layers.some(layer => layer.effects.length)).toBe(true);
expect(beforeState.layers.some(layer => layer.kind === 'adjustment')).toBe(true);
await page.locator('#ge-merge-all').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(1);
const afterState = await editorState(page);
expect(afterState.layers[0].effects).toHaveLength(0);
expect(afterState.layers).toEqual([
expect.objectContaining({ kind: 'raster', effects: [] }),
]);
expect(await flattenedPixelDigest(page)).toEqual(before);
});
@@ -0,0 +1,212 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const { editorState, openBlankEditor, waitForDraft } = require('./helpers');
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('mobile tool and layer sheets do not overlap mask controls', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile layer sheet');
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
const controls = page.locator('.ge-controls');
const layerSheet = page.locator('.ge-right-panel');
await expect(controls).toHaveClass(/dismissed/);
await expect(layerSheet).not.toHaveClass(/minimized/);
await page.locator('.ge-layers-title').click();
await expect(layerSheet).toHaveClass(/expanded/);
const layerId = (await editorState(page)).activeLayerId;
const layerRow = page.locator(`.ge-layer-item[data-layer-id="${layerId}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
const maskName = maskRow.locator('.ge-layer-name');
const maskThumb = maskRow.locator('.ge-mask-inline-thumb');
await expect(maskRow).toBeVisible();
await expect(maskName).toContainText('Layer Mask');
await expect(maskThumb).toBeVisible();
const rowBox = await maskRow.boundingBox();
const nameBox = await maskName.boundingBox();
const linkBox = await maskRow.locator('.ge-mask-link-btn').boundingBox();
expect(rowBox).toBeTruthy();
expect(nameBox.width).toBeGreaterThan(80);
expect(rowBox.x).toBeGreaterThanOrEqual(0);
expect(rowBox.x + rowBox.width).toBeLessThanOrEqual(390);
expect(nameBox.x).toBeGreaterThanOrEqual(0);
expect(nameBox.x + nameBox.width).toBeLessThanOrEqual(linkBox.x);
expect(await page.locator('.ge-layers-list').evaluate(list => list.scrollLeft)).toBe(0);
await maskRow.locator('.ge-mask-link-btn').click();
await expect(maskRow.locator('.ge-mask-link-btn')).toHaveAttribute('aria-pressed', 'false');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await expect(controls).not.toHaveClass(/dismissed/);
await expect(layerSheet).toHaveClass(/minimized/);
const controlsBox = await controls.boundingBox();
const sheetBox = await layerSheet.boundingBox();
expect(controlsBox.y).toBeLessThan(sheetBox.y + sheetBox.height);
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await expect(controls).toHaveClass(/dismissed/);
});
test('mobile group rows keep their preview and touch controls inside the viewport', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile group preview');
await page.locator('.ge-layers-title').click();
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
const groupRow = page.locator('.ge-layer-group-row').first();
await expect(groupRow).toBeVisible();
await expect(groupRow.locator('.ge-group-inline-thumb')).toBeVisible();
const rowBox = await groupRow.boundingBox();
const thumbBox = await groupRow.locator('.ge-group-inline-thumb').boundingBox();
const toggleBox = await groupRow.locator('.ge-layer-group-toggle').boundingBox();
const visibilityBox = await groupRow.locator('.ge-layer-vis').boundingBox();
expect(rowBox).toBeTruthy();
for (const box of [thumbBox, toggleBox, visibilityBox]) {
expect(box).toBeTruthy();
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(390);
}
});
test('mobile touch mask editing supports undo and persists through reload', async ({ page, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile mask persistence');
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
const before = (await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash;
// A new layer mask is fully white (revealed), so Brush would paint white
// onto white. Eraser makes the first touch edit observable by hiding pixels.
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
// Collapse the bottom-sheet controls so the full canvas is available to the
// touch gesture, matching the canvas-first mobile editing mode.
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const cdp = await page.context().newCDPSession(page);
const visibleCanvasPoint = await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const rect = canvas?.getBoundingClientRect();
if (!canvas || !rect) return null;
for (let y = rect.top + 8; y < rect.bottom - 8; y += 8) {
for (let x = rect.left + 8; x < rect.right - 8; x += 8) {
if (document.elementFromPoint(x, y) === canvas) return { x, y };
}
}
return null;
});
expect(visibleCanvasPoint).toBeTruthy();
const x1 = visibleCanvasPoint.x;
const x2 = Math.min(canvasBox.x + canvasBox.width - 8, x1 + canvasBox.width * 0.35);
const y = visibleCanvasPoint.y;
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: x1, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: x2, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const painted = await editorState(page);
const paintedLayer = painted.layers.find(layer => layer.masks.length);
expect(paintedLayer.masks[0].pixelHash).not.toBe(before);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
.toBe(before);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
.toBe(paintedLayer.masks[0].pixelHash);
const draftId = await waitForDraft(page);
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('.ge-main-canvas')).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const state = await editorState(page);
return state.layers.find(layer => layer.masks.length)?.masks[0]?.pixelHash;
}).toBe(paintedLayer.masks[0].pixelHash);
await page.request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('mobile export dialog stays usable and downloads the requested PNG', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile export');
await page.locator('#ge-save-menu-btn').click();
await page.locator('#ge-download').click();
const dialog = page.locator('.ge-export-dialog');
await expect(dialog).toBeVisible();
const dialogBox = await dialog.boundingBox();
expect(dialogBox).toBeTruthy();
expect(dialogBox.x).toBeGreaterThanOrEqual(0);
expect(dialogBox.y).toBeGreaterThanOrEqual(0);
expect(dialogBox.x + dialogBox.width).toBeLessThanOrEqual(390);
expect(dialogBox.y + dialogBox.height).toBeLessThanOrEqual(844);
await page.locator('#ge-export-width').fill('160');
await page.locator('#ge-export-filename').fill('mobile-export');
const downloadPromise = page.waitForEvent('download');
await dialog.locator('button[type="submit"]').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('mobile-export.png');
const bytes = fs.readFileSync(await download.path());
expect(bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(bytes.readUInt32BE(16)).toBe(160);
expect(bytes.readUInt32BE(20)).toBe(120);
});
test('mobile touch moves an unlinked layer mask without moving its layer', async ({ page, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile mask movement');
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
const layerId = (await editorState(page)).activeLayerId;
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
await maskRow.locator('.ge-mask-link-btn').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
const point = await page.evaluate(() => {
const target = document.querySelector('.ge-main-canvas');
const rect = target?.getBoundingClientRect();
if (!target || !rect) return null;
for (let y = rect.top + 8; y < rect.bottom - 8; y += 8) {
for (let x = rect.left + 8; x < rect.right - 8; x += 8) {
if (document.elementFromPoint(x, y) === target) return { x, y };
}
}
return null;
});
expect(point).toBeTruthy();
const before = await editorState(page);
const cdp = await page.context().newCDPSession(page);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: point.x, y: point.y, id: 1, radiusX: 6, radiusY: 6 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: point.x + 24, y: point.y + 16, id: 1, radiusX: 6, radiusY: 6 }],
});
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const moved = await editorState(page);
const beforeLayer = before.layers.find(layer => layer.id === layerId);
const movedLayer = moved.layers.find(layer => layer.id === layerId);
expect(movedLayer.offset).toEqual(beforeLayer.offset);
expect(movedLayer.masks[0].offset.x).not.toBe(beforeLayer.masks[0].offset.x);
expect(movedLayer.masks[0].offset.y).not.toBe(beforeLayer.masks[0].offset.y);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.id === layerId).masks[0].offset)
.toEqual(beforeLayer.masks[0].offset);
});
@@ -0,0 +1,127 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs/promises');
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
function selectionBounds(layers) {
const left = Math.min(...layers.map(layer => layer.offset.x));
const top = Math.min(...layers.map(layer => layer.offset.y));
const right = Math.max(...layers.map(layer => layer.offset.x + layer.size[0]));
const bottom = Math.max(...layers.map(layer => layer.offset.y + layer.size[1]));
return { left, top, right, bottom, width: right - left, height: bottom - top, centerX: (left + right) / 2, centerY: (top + bottom) / 2 };
}
test('shared transform preserves relative layout, masks, retained text, undo, cancel, and reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 400, height: 300 }, 'Multi-transform E2E');
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await editRow.click();
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-tool-btn[data-tool="text"]').click();
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvasBox.x + canvasBox.width * 0.35, canvasBox.y + canvasBox.height * 0.25);
await page.locator('#ge-text-content').fill('Transform together');
await page.locator('#ge-text-size').fill('30');
await page.locator('#ge-text-size').press('Enter');
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await editRow.click();
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await editRow.click();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
await page.locator('#ge-select-all-layers').click();
const initial = await editorState(page);
expect(initial.selectedLayerIds).toHaveLength(3);
const bounds = selectionBounds(initial.layers);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-transform-popup .ge-adj-title')).toHaveText('Transform 3 layers');
expect(Number(await page.locator('#ge-transform-w').inputValue())).toBe(bounds.width);
expect(Number(await page.locator('#ge-transform-h').inputValue())).toBe(bounds.height);
await page.locator('#ge-transform-w').fill(String(bounds.width * 2));
expect(Number(await page.locator('#ge-transform-h').inputValue())).toBe(bounds.height * 2);
await page.locator('#ge-transform-apply').click();
const transformed = await editorState(page);
const initialByName = new Map(initial.layers.map(layer => [layer.name, layer]));
for (const layer of transformed.layers.filter(layer => layer.kind === 'raster')) {
const before = initialByName.get(layer.name);
expect(layer.size).toEqual([before.size[0] * 2, before.size[1] * 2]);
const expectedX = Math.round(bounds.centerX + (before.offset.x + before.size[0] / 2 - bounds.centerX) * 2 - layer.size[0] / 2);
const expectedY = Math.round(bounds.centerY + (before.offset.y + before.size[1] / 2 - bounds.centerY) * 2 - layer.size[1] / 2);
expect(layer.offset).toEqual({ x: expectedX, y: expectedY });
}
const transformedEdit = transformed.layers.find(layer => layer.name === 'Edit');
expect(transformedEdit.clipped).toBe(true);
expect(transformed.groups).toEqual(initial.groups);
expect(transformed.layers.map(layer => layer.id)).toEqual(initial.layers.map(layer => layer.id));
expect(transformedEdit.masks[0].size).toEqual(transformedEdit.size);
const beforeText = initial.layers.find(layer => layer.kind === 'text');
const afterText = transformed.layers.find(layer => layer.kind === 'text');
expect(afterText.text.content).toBe('Transform together');
expect(afterText.text.transform.scaleX).toBeCloseTo(beforeText.text.transform.scaleX * 2, 5);
expect(afterText.text.transform.scaleY).toBeCloseTo(beforeText.text.transform.scaleY * 2, 5);
await page.locator('#ge-undo').click();
const undone = await editorState(page);
expect(undone.layers).toEqual(initial.layers);
expect(undone.selectedLayerIds).toEqual(initial.selectedLayerIds);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers).toEqual(transformed.layers);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
const secondWidth = await page.locator('#ge-transform-w').inputValue();
await page.locator('#ge-transform-rot-90').click();
await expect(page.locator('#ge-transform-rot')).toHaveValue('90');
await expect.poll(async () => {
const edit = (await editorState(page)).layers.find(layer => layer.name === 'Edit');
return edit.size;
}).toEqual([transformedEdit.size[1], transformedEdit.size[0]]);
await page.locator('#ge-transform-flip-h').click();
await expect(page.locator('#ge-transform-w')).toHaveValue(`-${secondWidth}`);
await page.locator('#ge-transform-cancel-btn').click();
const cancelled = await editorState(page);
expect(cancelled.layers).toEqual(transformed.layers);
expect(cancelled.groups).toEqual(transformed.groups);
expect(cancelled.redo).toBe(0);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-rot-90').click();
await expect(page.locator('#ge-transform-rot')).toHaveValue('90');
await page.locator('#ge-transform-flip-h').click();
await page.locator('#ge-transform-apply').click();
const finalTransform = await editorState(page);
const finalText = finalTransform.layers.find(layer => layer.kind === 'text');
expect(finalText.text.content).toBe('Transform together');
expect(finalText.text.transform.rotation).toBeCloseTo(afterText.text.transform.rotation + 90, 5);
expect(finalText.text.transform.flipH).toBe(!afterText.text.transform.flipH);
expect(finalTransform.groups).toEqual(transformed.groups);
expect(finalTransform.layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
const downloadPromise = page.waitForEvent('download');
await page.locator('#ge-save-menu-btn').click();
await page.locator('#ge-save-project').click();
const download = await downloadPromise;
const projectBuffer = await fs.readFile(await download.path());
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers).toEqual(transformed.layers);
await page.locator('#ge-save-menu-btn').click();
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-load-project').click();
const chooser = await chooserPromise;
await chooser.setFiles({
name: 'transform-roundtrip.geproj.json',
mimeType: 'application/json',
buffer: projectBuffer,
});
await expect.poll(async () => (await editorState(page)).layers).toEqual(finalTransform.layers);
expect((await editorState(page)).groups).toEqual(finalTransform.groups);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
expect((await editorState(page)).layers).toEqual(finalTransform.layers);
expect((await editorState(page)).groups).toEqual(finalTransform.groups);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,206 @@
const { test, expect } = require('@playwright/test');
const { editorState, flattenedPixelDigest, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function patternedPng(page, width, height, colors) {
const base64 = await page.evaluate(({ width: w, height: h, colors: palette }) => {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const context = canvas.getContext('2d');
for (let y = 0; y < h; y += 1) for (let x = 0; x < w; x += 1) {
context.fillStyle = palette[(x + y * w) % palette.length];
context.fillRect(x, y, 1, 1);
}
return canvas.toDataURL('image/png').split(',')[1];
}, { width, height, colors });
return Buffer.from(base64, 'base64');
}
test('placed image transforms from source, replaces in place, rasterizes, and reopens', async ({ page, request }) => {
await openBlankEditor(page, { width: 300, height: 200 }, 'Placed layers E2E');
const firstImage = await patternedPng(page, 40, 20, ['#f44336', '#4caf50', '#2196f3', '#ffeb3b']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
const chooser = await chooserPromise;
await chooser.setFiles({ name: 'first-pattern.png', mimeType: 'image/png', buffer: firstImage });
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const imported = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
expect(imported.placed.sourceSize).toEqual([40, 20]);
const sourceHash = imported.placed.sourcePixelHash;
const row = page.locator(`.ge-layer-item[data-layer-id="${imported.id}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth / 2)));
await page.locator('#ge-transform-apply').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-w').fill(String(originalWidth));
await page.locator('#ge-transform-apply').click();
const restoredSize = (await editorState(page)).layers.find(layer => layer.id === imported.id);
expect(restoredSize.placed.sourcePixelHash).toBe(sourceHash);
expect(restoredSize.size[0]).toBe(originalWidth);
expect(restoredSize.masks).toHaveLength(1);
const beforeReplaceFrame = { size: restoredSize.size, offset: restoredSize.offset };
const beforeReplaceMask = restoredSize.masks[0];
const replacement = await patternedPng(page, 20, 40, ['#111111', '#f8f8f8', '#ff00aa']);
const replaceChooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-layer-tools button[title="Replace placed image"]').click();
const replaceChooser = await replaceChooserPromise;
await replaceChooser.setFiles({ name: 'replacement.png', mimeType: 'image/png', buffer: replacement });
await expect.poll(async () => {
const layer = (await editorState(page)).layers.find(item => item.id === imported.id);
return layer.placed?.sourceName;
}).toBe('replacement.png');
const replaced = (await editorState(page)).layers.find(layer => layer.id === imported.id);
expect(replaced.size).toEqual(beforeReplaceFrame.size);
expect(replaced.offset).toEqual(beforeReplaceFrame.offset);
expect(replaced.placed.sourcePixelHash).not.toBe(sourceHash);
expect(replaced.masks[0]).toEqual(beforeReplaceMask);
const beforeRasterize = await flattenedPixelDigest(page);
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
const rasterized = (await editorState(page)).layers.find(layer => layer.id === imported.id);
expect(rasterized.kind).toBe('raster');
expect(rasterized.placed).toBeNull();
expect(await flattenedPixelDigest(page)).toEqual(beforeRasterize);
await page.locator('#ge-undo').click();
await expect.poll(async () => {
const layer = (await editorState(page)).layers.find(item => item.id === imported.id);
return layer.kind;
}).toBe('placed');
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = (await editorState(page)).layers.find(layer => layer.id === imported.id);
expect(reopened.kind).toBe('placed');
expect(reopened.placed.sourceName).toBe('replacement.png');
expect(reopened.masks[0]).toEqual(beforeReplaceMask);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('raster layers can be converted to editable sources without changing pixels', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Editable source E2E');
const image = await patternedPng(page, 36, 24, ['#ef5350', '#42a5f5', '#66bb6a']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
const chooser = await chooserPromise;
await chooser.setFiles({ name: 'editable-source.png', mimeType: 'image/png', buffer: image });
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
const raster = (await editorState(page)).layers.find(layer => layer.id === placed.id);
const before = { pixelHash: raster.pixelHash, size: raster.size, offset: raster.offset };
await page.locator('#ge-layer-tools button[title="Convert to editable source"]').click();
await expect.poll(async () => (await editorState(page)).layers.find(layer => layer.id === placed.id).kind).toBe('placed');
const converted = (await editorState(page)).layers.find(layer => layer.id === placed.id);
expect(converted.placed.sourceSize).toEqual(before.size);
expect(converted.placed.sourcePixelHash).toBe(before.pixelHash);
expect(converted.size).toEqual(before.size);
expect(converted.offset).toEqual(before.offset);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = (await editorState(page)).layers.find(layer => layer.id === placed.id);
expect(reopened.kind).toBe('placed');
expect(reopened.placed.sourcePixelHash).toBe(before.pixelHash);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('external clipboard images use the editable source import path', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Clipboard source E2E');
const image = await patternedPng(page, 28, 18, ['#ef5350', '#42a5f5', '#66bb6a']);
await page.evaluate(base64 => {
const bytes = Uint8Array.from(atob(base64), char => char.charCodeAt(0));
const file = new File([bytes], 'clipboard.png', { type: 'image/png' });
const data = new DataTransfer();
data.items.add(file);
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true }));
}, image.toString('base64'));
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const pasted = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
expect(pasted.placed.sourceName).toBe('Pasted image');
expect(pasted.placed.sourceSize).toEqual([28, 18]);
const draftId = await waitForDraft(page);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('duplicating a placed layer copies its source independently', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Duplicate source E2E');
const image = await patternedPng(page, 28, 18, ['#ef5350', '#42a5f5', '#66bb6a']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
await (await chooserPromise).setFiles({ name: 'duplicate-source.png', mimeType: 'image/png', buffer: image });
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const original = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator(`.ge-layer-item[data-layer-id="${original.id}"]`).click();
await page.locator('#ge-layer-tools button[title="Duplicate layer"]').click();
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(2);
const sourceOwnership = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const placed = state.layers.filter(layer => layer.kind === 'placed');
return {
count: placed.length,
sameSource: placed[0].placed.sourceCanvas === placed[1].placed.sourceCanvas,
sizes: placed.map(layer => [layer.placed.sourceCanvas.width, layer.placed.sourceCanvas.height]),
};
});
expect(sourceOwnership).toEqual({ count: 2, sameSource: false, sizes: [[28, 18], [28, 18]] });
const draftId = await waitForDraft(page);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('raster transforms preserve source pixels by default and can opt out', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Non-destructive transform E2E');
const image = await patternedPng(page, 42, 28, ['#ff7043', '#26a69a', '#5c6bc0']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
const chooser = await chooserPromise;
await chooser.setFiles({ name: 'transform-source.png', mimeType: 'image/png', buffer: image });
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
const rasterBeforeTransform = (await editorState(page)).layers.find(layer => layer.id === placed.id);
const sourceHash = rasterBeforeTransform.pixelHash;
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('#ge-transform-preserve-source')).toBeChecked();
await page.locator('#ge-transform-w').fill('84');
await page.locator('#ge-transform-apply').click();
const preserved = (await editorState(page)).layers.find(layer => layer.id === placed.id);
expect(preserved.kind).toBe('placed');
expect(preserved.placed.sourceSize).toEqual([42, 28]);
expect(preserved.placed.sourcePixelHash).toBe(sourceHash);
const draftId = await waitForDraft(page);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('raster transforms can explicitly replace source pixels', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Destructive transform E2E');
const image = await patternedPng(page, 42, 28, ['#ff7043', '#26a69a', '#5c6bc0']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
const chooser = await chooserPromise;
await chooser.setFiles({ name: 'destructive-transform.png', mimeType: 'image/png', buffer: image });
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('#ge-transform-preserve-source')).toBeChecked();
await page.locator('#ge-transform-preserve-source').uncheck();
await page.locator('#ge-transform-w').fill('60');
await page.locator('#ge-transform-apply').click();
const destructive = (await editorState(page)).layers.find(layer => layer.id === placed.id);
expect(destructive.kind).toBe('raster');
expect(destructive.placed).toBeNull();
const draftId = await waitForDraft(page);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,180 @@
const { test, expect } = require('@playwright/test');
const { editorState, openBlankEditor } = require('./helpers.js');
async function currentPng(page) {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.layers[0].canvas.toDataURL('image/png');
});
}
async function loadProject(page, project) {
await page.locator('#ge-save-menu-btn').click();
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-load-project').click();
const chooser = await chooserPromise;
await chooser.setFiles({
name: 'recovery.geproj.json',
mimeType: 'application/json',
buffer: Buffer.from(JSON.stringify(project)),
});
}
test('mixed-corrupt project recovers valid layers and remains undoable', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Recovery E2E');
const before = await editorState(page);
const png = await currentPng(page);
await loadProject(page, {
type: 'odysseus-gallery-editor-project',
v: 5,
imgWidth: 160,
imgHeight: 120,
activeLayerId: 'broken',
nextLayerId: 3,
view: {},
layers: [
{ id: 'good', name: 'Recovered photo', canvasW: 160, canvasH: 120, dataUrl: png, offset: { x: 0, y: 0 }, masks: [] },
{ id: 'broken', name: 'Broken pixels', canvasW: 160, canvasH: 120, dataUrl: 'data:image/png;base64,AAAA', offset: { x: 0, y: 0 }, masks: [] },
],
});
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name)).toEqual(['Recovered photo']);
await expect(page.locator('#toast')).toContainText('Broken pixels was skipped');
await page.locator('#ge-undo').click();
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name)).toEqual(before.layers.map(layer => layer.name));
expect((await editorState(page)).dimensions).toEqual(before.dimensions);
});
test('fully corrupt project leaves the open document unchanged', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Recovery E2E');
const before = await editorState(page);
await loadProject(page, {
type: 'odysseus-gallery-editor-project',
v: 5,
imgWidth: 160,
imgHeight: 120,
activeLayerId: 'broken',
view: {},
layers: [
{ id: 'broken', name: 'Broken pixels', canvasW: 160, canvasH: 120, dataUrl: 'data:image/png;base64,AAAA', offset: { x: 0, y: 0 }, masks: [] },
],
});
await expect(page.locator('#toast')).toContainText('No recoverable layers could be decoded');
await expect.poll(async () => await editorState(page)).toEqual(before);
});
test('active editor reopens after a browser refresh', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Refresh recovery');
await expect.poll(async () => (await editorState(page)).draftId).not.toBeNull();
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('#gallery-modal')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#gallery-editor-tab')).toHaveClass(/active/);
await expect(page.locator('.ge-main-canvas')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name))
.toEqual(['Background', 'Edit']);
});
test('active editor reopens after a mobile browser refresh', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile refresh recovery');
await expect.poll(async () => (await editorState(page)).draftId).not.toBeNull();
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('#gallery-modal')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#gallery-editor-tab')).toHaveClass(/active/);
await expect(page.locator('.ge-main-canvas')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name))
.toEqual(['Background', 'Edit']);
});
test('new project size dialog cancels cleanly with Escape', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.locator('#tool-gallery-btn').click();
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.locator('#gallery-editor-new').click();
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
await page.locator('#ge-canvas-prompt-w').press('Escape');
await expect(page.locator('#ge-canvas-size-overlay')).toBeHidden();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.editorOpen;
})).toBe(false);
await expect(page.locator('#gallery-editor-new')).toBeVisible();
});
test('editor topbar uses uppercase action labels', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Topbar labels');
await expect(page.locator('#ge-view-menu-btn')).toHaveText(/VIEW/);
await expect(page.locator('#ge-image-menu-btn')).toHaveText(/IMAGE/);
await expect(page.locator('#ge-selection-menu-btn')).toHaveText(/SELECT/);
await expect(page.locator('#ge-filter-menu-btn')).toHaveText(/FILTER/);
await expect(page.locator('#ge-import-topbar')).toHaveText(/IMPORT/);
await expect(page.locator('#ge-save-menu-btn')).toHaveText(/SAVE/);
});
test('canvas size anchor keeps the composition centered', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Canvas anchor E2E');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.name === 'Edit');
state.layerOffsets.set(layer.id, { x: 10, y: 5 });
});
await page.locator('#ge-image-menu-btn').click();
await page.locator('[data-image-action="canvas-size"]').click();
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
await page.locator('#ge-canvas-prompt-lock').uncheck();
await page.locator('#ge-canvas-prompt-w').fill('420');
await page.locator('#ge-canvas-prompt-h').fill('340');
await page.locator('.ge-canvas-anchor').nth(4).click();
await page.locator('#ge-canvas-prompt-ok').click();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.name === 'Edit');
return { dimensions: [state.imgWidth, state.imgHeight], offset: state.layerOffsets.get(layer.id) };
})).toEqual({ dimensions: [420, 340], offset: { x: 60, y: 55 } });
});
test('image size supports percentage resampling with locked proportions', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Image size E2E');
await page.locator('#ge-image-menu-btn').click();
await page.locator('[data-image-action="image-size"]').click();
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
await expect(page.locator('#ge-canvas-prompt-units')).toHaveValue('px');
await page.locator('#ge-canvas-prompt-units').selectOption('percent');
await page.locator('#ge-canvas-prompt-w').fill('50');
await expect(page.locator('#ge-canvas-prompt-h')).toHaveValue('50');
await page.locator('#ge-canvas-prompt-interpolation').selectOption('medium');
await page.locator('#ge-canvas-prompt-ok').click();
await expect.poll(async () => (await editorState(page)).dimensions).toEqual([160, 120]);
await page.locator('#ge-image-menu-btn').click();
await page.locator('[data-image-action="image-size"]').click();
await expect(page.locator('#ge-canvas-prompt-units')).toHaveValue('px');
await expect(page.locator('#ge-canvas-prompt-w')).toHaveValue('160');
await expect(page.locator('#ge-canvas-prompt-h')).toHaveValue('120');
await page.locator('#ge-canvas-prompt-cancel').click();
});
test('canvas size supports percentage bounds with an anchor', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Canvas percentage E2E');
await page.locator('#ge-image-menu-btn').click();
await page.locator('[data-image-action="canvas-size"]').click();
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
await page.locator('#ge-canvas-prompt-units').selectOption('percent');
await page.locator('#ge-canvas-prompt-w').fill('125');
await expect(page.locator('#ge-canvas-prompt-h')).toHaveValue('125');
await page.locator('.ge-canvas-anchor').nth(4).click();
await page.locator('#ge-canvas-prompt-ok').click();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.name === 'Edit');
return { dimensions: [state.imgWidth, state.imgHeight], offset: state.layerOffsets.get(layer.id) };
})).toEqual({ dimensions: [400, 300], offset: { x: 40, y: 30 } });
});
@@ -0,0 +1,28 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, editorState } = require('./helpers.js');
test('paint tools offer rasterization, cancel preserves text, Enter accepts and undo restores it', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvas.x + 80, canvas.y + 80);
const text = page.locator('.ge-direct-text-editor');
await text.fill('Keep editable');
await text.press('Control+Enter');
const retained = (await editorState(page)).layers.find(layer => layer.kind === 'text');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await expect(page.locator('#styled-confirm-ok')).toHaveText('Rasterize');
await page.keyboard.press('Escape');
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await expect(page.locator('#styled-confirm-ok')).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('raster');
await page.keyboard.press('Control+z');
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await expect(page.locator('#styled-confirm-ok')).toBeVisible();
await page.keyboard.press('Escape');
});
@@ -0,0 +1,20 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
test('active editor draft reopens after a hard refresh', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Refresh recovery E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
const before = await editorState(page);
const draftId = await waitForDraft(page);
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('#gallery-editor-tab')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('.gallery-editor')).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => (await editorState(page)).draftId, { timeout: 20_000 }).toBe(draftId);
await expect.poll(async () => (await editorState(page)).documentRenderReady, { timeout: 20_000 }).toBe(true);
const after = await editorState(page);
expect(after.layers).toEqual(before.layers);
expect(after.dimensions).toEqual(before.dimensions);
});
@@ -0,0 +1,181 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
test('selection boundary animates, moves independently, and supports Quick Mask', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection workflow');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
const initial = await editorState(page);
expect(initial.selection?.space).toBe('document');
expect(initial.selection?.bounds).toBeTruthy();
const layerBefore = initial.layers.find(layer => layer.id === initial.activeLayerId);
const overlayHash = () => page.locator('.ge-selection-overlay').evaluate(canvas => {
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let hash = 0;
for (let i = 0; i < data.length; i += 4) if (data[i + 3]) hash = ((hash * 33) ^ data[i]) >>> 0;
return hash;
});
const firstOverlay = await overlayHash();
expect(firstOverlay).not.toBe(0);
await expect.poll(overlayHash, { timeout: 1200 }).not.toBe(firstOverlay);
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.45, y: 0.42 });
const moved = await editorState(page);
expect(moved.selection.bounds.x).toBeGreaterThan(initial.selection.bounds.x);
expect(moved.selection.bounds.y).toBeGreaterThan(initial.selection.bounds.y);
const layerAfterMove = moved.layers.find(layer => layer.id === moved.activeLayerId);
expect(layerAfterMove.offset).toEqual(layerBefore.offset);
expect(layerAfterMove.pixelHash).toBe(layerBefore.pixelHash);
await page.keyboard.press('ArrowRight');
await expect.poll(async () => (await editorState(page)).selection.bounds.x).toBe(moved.selection.bounds.x + 1);
await page.keyboard.press('q');
await expect(page.locator('#ge-quick-mask-bar')).toBeVisible();
expect((await editorState(page)).quickMaskActive).toBe(true);
const beforePaint = (await editorState(page)).selection.pixelHash;
await dragOnCanvas(page, { x: 0.72, y: 0.72 }, { x: 0.78, y: 0.72 });
await expect.poll(async () => (await editorState(page)).selection.pixelHash).not.toBe(beforePaint);
await page.locator('.ge-quick-mask-done').click();
await expect(page.locator('#ge-quick-mask-bar')).toBeHidden();
expect((await editorState(page)).quickMaskActive).toBe(false);
});
test('marquee supports exact fixed-size and fixed-ratio geometry', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Precise marquee');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await page.locator('#ge-marquee-constraint').selectOption('size');
await page.locator('#ge-marquee-width').fill('80');
await page.locator('#ge-marquee-width').blur();
await page.locator('#ge-marquee-height').fill('60');
await page.locator('#ge-marquee-height').blur();
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height * 0.75);
await expect.poll(async () => (await editorState(page)).selection?.bounds).toEqual({ x: 240, y: 180, width: 80, height: 60 });
await page.locator('#ge-marquee-clear').click();
await page.locator('#ge-marquee-constraint').selectOption('ratio');
await page.locator('#ge-marquee-width').fill('4');
await page.locator('#ge-marquee-width').blur();
await page.locator('#ge-marquee-height').fill('3');
await page.locator('#ge-marquee-height').blur();
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.6, y: 0.3 });
const ratioBounds = (await editorState(page)).selection.bounds;
expect(ratioBounds.width).toBe(160);
expect(ratioBounds.height).toBe(120);
expect(ratioBounds.width / ratioBounds.height).toBeCloseTo(4 / 3, 5);
});
test('transform selection moves, scales, rotates, cancels, and preserves layer pixels', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Transform selection');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.5, y: 0.55 });
const original = await editorState(page);
const originalLayer = original.layers.find(layer => layer.id === original.activeLayerId);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="transform"]').click();
await expect(page.locator('.ge-transform-popup')).toBeVisible();
await expect(page.locator('.ge-transform-popup .ge-adj-title')).toHaveText('Transform Selection');
await dragOnCanvas(page, { x: 0.35, y: 0.4 }, { x: 0.45, y: 0.47 });
const moved = await editorState(page);
expect(moved.selection.bounds.x).toBeGreaterThan(original.selection.bounds.x);
expect(moved.selection.bounds.y).toBeGreaterThan(original.selection.bounds.y);
await page.locator('#ge-transform-w').fill('140');
await page.locator('#ge-transform-w').dispatchEvent('input');
await page.locator('#ge-transform-rot').fill('25');
await page.locator('#ge-transform-rot').dispatchEvent('input');
await page.locator('#ge-transform-apply').click();
let transformed = await editorState(page);
const transformedLayer = transformed.layers.find(layer => layer.id === transformed.activeLayerId);
expect(transformed.selection.pixelHash).not.toBe(original.selection.pixelHash);
expect(transformed.selection.bounds.width).toBeGreaterThan(original.selection.bounds.width);
expect(transformedLayer.offset).toEqual(originalLayer.offset);
expect(transformedLayer.pixelHash).toBe(originalLayer.pixelHash);
await page.keyboard.press('Control+z');
await expect.poll(async () => (await editorState(page)).selection.pixelHash).toBe(original.selection.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="transform"]').click();
await page.locator('#ge-transform-h').fill('40');
await page.locator('#ge-transform-h').dispatchEvent('input');
expect((await editorState(page)).selection.pixelHash).not.toBe(original.selection.pixelHash);
await page.locator('#ge-transform-cancel-btn').click();
transformed = await editorState(page);
expect(transformed.selection.pixelHash).toBe(original.selection.pixelHash);
expect(transformed.redo).toBe(0);
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(250);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="transform"]').click();
const mobilePopup = await page.locator('.ge-transform-popup').boundingBox();
const mobileTitle = await page.locator('.ge-transform-popup .ge-adj-title').boundingBox();
expect(mobilePopup.x).toBeGreaterThanOrEqual(0);
expect(mobilePopup.y).toBeGreaterThanOrEqual(0);
expect(mobilePopup.x + mobilePopup.width).toBeLessThanOrEqual(390);
expect(mobilePopup.y + mobilePopup.height).toBeLessThanOrEqual(844);
expect(mobileTitle.x).toBeGreaterThanOrEqual(mobilePopup.x);
expect(mobileTitle.x + mobileTitle.width).toBeLessThanOrEqual(mobilePopup.x + mobilePopup.width);
await page.locator('#ge-transform-cancel-btn').click();
});
test('named selections support reselect, load, delete, and server-draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Saved selections');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.5, y: 0.6 });
const original = (await editorState(page)).selection;
await page.locator('#ge-selection-menu-btn').click();
await page.locator('#ge-selection-name').fill('Subject');
await page.locator('[data-selection-action="save"]').click();
let current = await editorState(page);
expect(current.savedSelections).toHaveLength(1);
expect(current.savedSelections[0].name).toBe('Subject');
expect(current.savedSelections[0].pixelHash).toBe(original.pixelHash);
await page.locator('[data-selection-action="deselect"]').click();
current = await editorState(page);
expect(current.selection).toBeNull();
expect(current.lastSelection.pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="reselect"]').click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="deselect"]').click();
await dragOnCanvas(page, { x: 0.62, y: 0.15 }, { x: 0.88, y: 0.4 });
expect((await editorState(page)).selection.pixelHash).not.toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('.ge-saved-selection-load', { hasText: 'Subject' }).click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.savedSelections).toHaveLength(1);
expect(current.savedSelections[0].pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('.ge-saved-selection-load', { hasText: 'Subject' }).click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('.ge-saved-selection-delete').click();
expect((await editorState(page)).savedSelections).toHaveLength(0);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('#ge-selection-menu')).toBeHidden();
await page.waitForTimeout(250);
await page.locator('#ge-selection-menu-btn').click();
await expect(page.locator('#ge-selection-menu')).toBeVisible();
const mobileMenu = await page.locator('#ge-selection-menu').boundingBox();
expect(mobileMenu.x).toBeGreaterThanOrEqual(0);
expect(mobileMenu.x + mobileMenu.width).toBeLessThanOrEqual(390);
});
@@ -0,0 +1,171 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
async function openRefine(page) {
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="refine"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
}
async function drawLasso(page, points) {
const box = await page.locator('.ge-main-canvas').boundingBox();
const point = ([x, y]) => ({ x: box.x + box.width * x, y: box.y + box.height * y });
const first = point(points[0]);
await page.mouse.move(first.x, first.y);
await page.mouse.down();
for (const item of points.slice(1)) {
const next = point(item);
await page.mouse.move(next.x, next.y, { steps: 3 });
}
await page.mouse.move(first.x, first.y, { steps: 3 });
await page.mouse.up();
}
test('selection clipboard copy and paste creates an undoable independent layer', async ({ page, request }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection clipboard');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
const before = await editorState(page);
await page.keyboard.press('Control+c');
await page.evaluate(() => {
window.dispatchEvent(new Event('paste', { bubbles: true, cancelable: true }));
});
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Pasted Selection' })).toBeVisible();
const pasted = await editorState(page);
expect(pasted.layers).toHaveLength(before.layers.length + 1);
const pastedLayer = pasted.layers.at(-1);
expect(pasted.activeLayerId).toBe(pastedLayer.id);
expect(pastedLayer.kind).toBe('placed');
expect(pastedLayer.placed.sourceSize).toEqual(pastedLayer.size);
await page.locator('#ge-undo').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length);
await page.locator('#ge-redo').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length + 1);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
const reopenedLayer = reopened.layers.at(-1);
expect(reopenedLayer.kind).toBe('placed');
expect(reopenedLayer.placed.sourceSize).toEqual(pastedLayer.size);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('cut selection is one undoable move from source to new layer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection cut');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
const before = await editorState(page);
const sourceSignature = () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.name === 'Edit');
const data = layer.canvas.getContext('2d').getImageData(0, 0, layer.canvas.width, layer.canvas.height).data;
let hash = 2166136261;
for (const value of data) {
hash ^= value;
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
});
const beforeSource = await sourceSignature();
await page.keyboard.press('Control+x');
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Wand copy' })).toBeVisible();
const cut = await editorState(page);
expect(cut.layers).toHaveLength(before.layers.length + 1);
expect(await sourceSignature()).not.toBe(beforeSource);
await page.locator('#ge-undo').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length);
expect(await sourceSignature()).toBe(beforeSource);
});
test('completed lasso copy preserves the source layer coordinate space', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Offset selection copy');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.layerOffsets.set(state.activeLayerId, { x: 32, y: 24 });
window.galleryEditorComposite?.();
});
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.28, y: 0.3 }, { x: 0.62, y: 0.58 });
await page.locator('.ge-tool-btn[data-tool="lasso"]').click();
await drawLasso(page, [[0.26, 0.26], [0.68, 0.26], [0.68, 0.64], [0.26, 0.64]]);
const before = await editorState(page);
const source = before.layers.find(layer => layer.id === before.activeLayerId);
await page.locator('#ge-lasso-copy').click();
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Wand copy' })).toBeVisible();
const copied = await editorState(page);
const copy = copied.layers.find(layer => layer.name === 'Wand copy');
expect(copy.size).toEqual(source.size);
expect(copy.offset).toEqual(source.offset);
});
test('selection refine previews safely and layer masks round-trip to selection', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection refinement');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.55, y: 0.55 });
const original = (await editorState(page)).selection;
await openRefine(page);
const expand = page.locator('.ge-filter-modal input[data-key="expand"]');
await expand.fill('12');
await expand.dispatchEvent('input');
await expect.poll(async () => (await editorState(page)).selection.bounds.width)
.toBeGreaterThan(original.bounds.width);
await page.locator('.ge-filter-modal [data-action="cancel"]').click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
await openRefine(page);
const appliedExpand = page.locator('.ge-filter-modal input[data-key="expand"]');
await appliedExpand.fill('12');
await appliedExpand.dispatchEvent('input');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const refined = (await editorState(page)).selection;
expect(refined.bounds.width).toBeGreaterThan(original.bounds.width);
expect(refined.pixelHash).not.toBe(original.pixelHash);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
let current = await editorState(page);
const active = current.layers.find(layer => layer.id === current.activeLayerId);
expect(active.masks).toHaveLength(1);
expect(active.masks[0].mode).toBe('layer');
expect(active.masks[0].pixelHash).toBe(refined.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="deselect"]').click();
expect((await editorState(page)).selection).toBeNull();
await page.getByRole('button', { name: 'Load mask as selection' }).click();
current = await editorState(page);
expect(current.selection.pixelHash).toBe(refined.pixelHash);
});
test('lasso uses the shared replace, add, subtract, and intersect modes', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Lasso combine modes');
await page.locator('.ge-tool-btn[data-tool="lasso"]').click();
await drawLasso(page, [[0.1, 0.2], [0.35, 0.2], [0.35, 0.55], [0.1, 0.55]]);
const first = (await editorState(page)).selection;
expect(first.source).toBe('lasso');
await page.locator('#ge-lasso-section [data-wand-mode="add"]').click();
await drawLasso(page, [[0.55, 0.2], [0.85, 0.2], [0.85, 0.55], [0.55, 0.55]]);
const added = (await editorState(page)).selection;
expect(added.bounds.width).toBeGreaterThan(first.bounds.width);
await page.locator('#ge-lasso-section [data-wand-mode="subtract"]').click();
await drawLasso(page, [[0.05, 0.15], [0.4, 0.15], [0.4, 0.6], [0.05, 0.6]]);
const subtracted = (await editorState(page)).selection;
expect(subtracted.bounds.x).toBeGreaterThan(first.bounds.x);
await page.locator('#ge-lasso-section [data-wand-mode="intersect"]').click();
await drawLasso(page, [[0.65, 0.25], [0.78, 0.25], [0.78, 0.48], [0.65, 0.48]]);
const intersected = (await editorState(page)).selection;
expect(intersected.bounds.width).toBeLessThan(subtracted.bounds.width);
});
+334
View File
@@ -0,0 +1,334 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('text edits directly on canvas and remains retained after reopen', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await page.locator('#ge-text-frame-width').fill('260');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvas.x + canvas.width * 0.2, canvas.y + canvas.height * 0.2);
const editor = page.locator('.ge-direct-text-editor');
await expect(editor).toBeVisible();
await editor.fill('Editable canvas title');
await editor.press('Control+Enter');
await expect(editor).toHaveCount(0);
await page.locator('#ge-text-letter-spacing').fill('3.5');
await page.locator('#ge-text-line-height').fill('1.4');
await page.locator('#ge-text-font').selectOption('Georgia');
await page.locator('.ge-text-auto-width-option').click();
await expect(page.locator('#ge-text-auto-width')).toBeChecked();
await expect(page.locator('#ge-text-frame-width')).toBeDisabled();
await page.locator('#ge-text-frame-height').fill('180');
await page.locator('#ge-text-vertical-align').selectOption('bottom');
const before = await editorState(page);
const textLayer = before.layers.find(layer => layer.kind === 'text');
expect(textLayer.text).toMatchObject({
content: 'Editable canvas title',
fontFamily: 'Georgia',
lineHeight: 1.4,
letterSpacing: 3.5,
autoWidth: true,
frameHeight: 180,
verticalAlign: 'bottom',
});
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.kind === 'text').text).toEqual(textLayer.text);
});
test('text paragraph controls remain usable in a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile text layout E2E');
await page.locator('.ge-tool-btn[data-tool="text"]').click();
const section = page.locator('#ge-text-section');
await expect(section).toBeVisible();
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await expect(page.locator('#ge-text-frame-height')).toBeVisible();
await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
});
test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await expect(page.locator('#ge-shape-section')).toBeVisible();
const types = ['rectangle', 'ellipse', 'line', 'polygon'];
for (let index = 0; index < types.length; index += 1) {
const type = types[index];
await page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first().click();
await page.locator(`[data-shape-type="${type}"]`).click();
await dragOnCanvas(
page,
{ x: 0.12 + index * 0.18, y: 0.2 },
{ x: 0.25 + index * 0.18, y: 0.42 },
);
}
let current = await editorState(page);
const shapes = current.layers.filter(layer => layer.kind === 'shape');
expect(shapes.map(layer => layer.shape.type)).toEqual(types);
await page.locator('#ge-shape-sides').fill('7');
await page.locator('#ge-shape-stroke-width').fill('6');
await page.locator('#ge-shape-radius').fill('14');
current = await editorState(page);
const polygon = current.layers.find(layer => layer.kind === 'shape' && layer.shape.type === 'polygon');
expect(polygon.shape).toMatchObject({ sides: 7, strokeWidth: 6, cornerRadius: 14 });
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(originalWidth + 40));
await page.locator('#ge-transform-apply').click();
current = await editorState(page);
const transformed = current.layers.find(layer => layer.id === polygon.id);
expect(transformed.kind).toBe('shape');
expect(transformed.shape.transform.scaleX).toBeGreaterThan(1);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.filter(layer => layer.kind === 'shape').map(layer => layer.shape.type)).toEqual(types);
});
test('gradient shape fill remains editable after reopening the project', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeHidden();
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.65, y: 0.55 });
await page.locator('#ge-shape-gradient-angle').evaluate((angle) => {
const section = angle.closest('#ge-shape-section');
section.querySelector('#ge-shape-fill-type').value = 'linear-gradient';
section.querySelector('#ge-shape-gradient-start').value = '#ff0000';
section.querySelector('#ge-shape-gradient-mid').value = '#00ff00';
section.querySelector('#ge-shape-gradient-mid-enabled').checked = true;
section.querySelector('#ge-shape-gradient-mid-position').value = '42';
section.querySelector('#ge-shape-gradient-end').value = '#0000ff';
section.querySelector('#ge-shape-fill-type').dispatchEvent(new Event('change', { bubbles: true }));
angle.value = '35';
angle.dispatchEvent(new Event('input', { bubbles: true }));
});
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible();
const before = await editorState(page);
const shape = before.layers.find(layer => layer.kind === 'shape');
expect(shape.shape).toMatchObject({
fillType: 'linear-gradient',
gradientStart: '#ff0000',
gradientMid: '#00ff00',
gradientMidEnabled: true,
gradientMidPosition: 42,
gradientEnd: '#0000ff',
gradientAngle: 35,
});
expect(page.locator('.ge-layer-inline-thumb')).toHaveCount(before.layers.length);
const thumbnailColorRange = await page.locator('.ge-layer-inline-thumb').evaluateAll((canvases) => canvases.map(canvas => {
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
return {
red: Math.max(...Array.from(pixels).filter((_, index) => index % 4 === 0)),
blue: Math.max(...Array.from(pixels).filter((_, index) => index % 4 === 2)),
};
}));
expect(thumbnailColorRange.some(({ red, blue }) => red > 180 && blue > 180)).toBe(true);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.id === shape.id).shape).toMatchObject(shape.shape);
});
test('shape gradients retain added stops through editing and reopen', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E');
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
await page.locator('#ge-shape-fill-type').selectOption('linear-gradient');
await page.evaluate(() => {
for (const [id, value] of [['ge-shape-gradient-start', '#ff0000'], ['ge-shape-gradient-end', '#0000ff']]) {
const input = document.getElementById(id);
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
await page.locator('#ge-shape-gradient-add-stop').click();
await expect(page.locator('[data-gradient-extra-stop]')).toHaveCount(1);
await page.evaluate(() => {
const color = document.querySelector('[data-gradient-stop-color]');
color.value = '#00ff00';
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = document.querySelector('[data-gradient-stop-position]');
position.value = '50';
position.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.locator('#ge-shape-gradient-add-stop').click();
await expect(page.locator('[data-gradient-extra-stop]')).toHaveCount(2);
await page.evaluate(() => {
const rows = [...document.querySelectorAll('[data-gradient-extra-stop]')];
const setRow = (row, colorValue, positionValue) => {
const color = row.querySelector('[data-gradient-stop-color]');
color.value = colorValue;
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = row.querySelector('[data-gradient-stop-position]');
position.value = positionValue;
position.dispatchEvent(new Event('input', { bubbles: true }));
};
setRow(rows[0], '#00ff00', '50');
setRow(rows[1], '#ffff00', '75');
});
let current = await editorState(page);
const shape = current.layers.find(layer => layer.kind === 'shape');
expect(shape.shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 50, color: '#00ff00' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
await page.locator('[data-gradient-extra-stop]').first().locator('[data-gradient-stop-remove]').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === shape.id).shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.id === shape.id).shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
});
test('shape gradient stop normalization keeps imported endpoints bounded', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Gradient stop bounds E2E');
const result = await page.evaluate(async () => {
const { MAX_GRADIENT_STOPS, normalizeGradientStops } = await import('/static/js/editor/gradient-stops.js');
const stops = normalizeGradientStops(Array.from({ length: 30 }, (_, index) => ({
position: index * 3,
color: `#${String(index).padStart(6, '0')}`,
})));
return { max: MAX_GRADIENT_STOPS, stops };
});
expect(result.stops).toHaveLength(result.max);
expect(result.stops[0].position).toBe(0);
expect(result.stops.at(-1).position).toBe(100);
});
test('gradient tool paints a reversible drag on the active layer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Gradient tool E2E');
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
await expect(page.locator('#ge-gradient-section')).toBeVisible();
await page.evaluate(() => {
for (const [id, value] of [['ge-gradient-start', '#ff0000'], ['ge-gradient-end', '#0000ff']]) {
const input = document.getElementById(id);
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
const midpoint = document.getElementById('ge-gradient-mid');
midpoint.value = '#00ff00';
midpoint.dispatchEvent(new Event('input', { bubbles: true }));
const enabled = document.getElementById('ge-gradient-mid-enabled');
enabled.checked = true;
enabled.dispatchEvent(new Event('change', { bubbles: true }));
});
await page.locator('#ge-gradient-add-stop').click();
await page.evaluate(() => {
const row = document.querySelector('#ge-gradient-extra-stops [data-gradient-extra-stop]');
const color = row.querySelector('[data-gradient-stop-color]');
color.value = '#ffff00';
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = row.querySelector('[data-gradient-stop-position]');
position.value = '25';
position.dispatchEvent(new Event('input', { bubbles: true }));
});
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(box.x + 8, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 8, box.y + box.height / 2, { steps: 8 });
await page.mouse.up();
await expect.poll(async () => (await editorState(page)).layers.at(-1).effects?.length || 0).toBe(1);
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
const samples = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const rendered = state.documentCompositeCanvas;
const ctx = rendered.getContext('2d');
const y = Math.floor(rendered.height / 2);
return [
ctx.getImageData(4, y, 1, 1).data,
ctx.getImageData(Math.floor(rendered.width / 2), y, 1, 1).data,
ctx.getImageData(rendered.width - 5, y, 1, 1).data,
];
});
expect(samples[0][0]).toBeGreaterThan(220);
expect(samples[0][2]).toBeLessThan(40);
expect(samples[1][1]).toBeGreaterThan(180);
expect(samples[1][0]).toBeLessThan(80);
expect(samples[1][2]).toBeLessThan(80);
expect(samples[2][2]).toBeGreaterThan(220);
expect(samples[2][0]).toBeLessThan(40);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
const reopenedGradient = reopened.layers.find(layer => layer.effects?.some(effect => effect.type === 'linear-gradient'));
expect(reopenedGradient).toBeTruthy();
expect(reopenedGradient.effects.find(effect => effect.type === 'linear-gradient').params.stops).toEqual([
{ position: 0, color: '#ff0000', alpha: 1 },
{ position: 25, color: '#ffff00', alpha: 1 },
{ position: 50, color: '#00ff00', alpha: 1 },
{ position: 100, color: '#0000ff', alpha: 1 },
]);
await page.locator('.ge-effect-sub-item .ge-adj-sub-name').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await expect(page.locator('.ge-filter-row input[data-key="stopColor0"]')).toHaveValue('#ffff00');
await expect(page.locator('.ge-filter-row input[data-key="stopPosition0"]')).toHaveValue('25');
await expect(page.locator('.ge-filter-row input[data-key="stopColor1"]')).toHaveValue('#00ff00');
await page.locator('.ge-filter-row input[data-key="stopColor0"]').fill('#ffff00');
await page.locator('.ge-filter-row input[data-key="stopPosition0"]').fill('30');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const edited = (await editorState(page)).layers
.flatMap(layer => layer.effects || [])
.find(effect => effect.type === 'linear-gradient');
expect(edited.params.stops).toEqual([
{ position: 0, color: '#ff0000', alpha: 1 },
{ position: 30, color: '#ffff00', alpha: 1 },
{ position: 50, color: '#00ff00', alpha: 1 },
{ position: 100, color: '#0000ff', alpha: 1 },
]);
});
test('radial gradient remains retained and survives reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Radial gradient E2E');
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
await page.locator('#ge-gradient-type').selectOption('radial-gradient');
await page.evaluate(() => {
const start = document.getElementById('ge-gradient-start');
start.value = '#ffffff';
start.dispatchEvent(new Event('input', { bubbles: true }));
const end = document.getElementById('ge-gradient-end');
end.value = '#000000';
end.dispatchEvent(new Event('input', { bubbles: true }));
});
await dragOnCanvas(page, { x: 0.5, y: 0.5 }, { x: 0.85, y: 0.5 });
let current = await editorState(page);
const gradient = current.layers.flatMap(layer => layer.effects || []).find(effect => effect.type === 'radial-gradient');
expect(gradient).toBeTruthy();
expect(gradient.params.stops).toEqual([
{ position: 0, color: '#ffffff', alpha: 1 },
{ position: 100, color: '#000000', alpha: 1 },
]);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.flatMap(layer => layer.effects || []).some(effect => effect.type === 'radial-gradient')).toBe(true);
});
@@ -0,0 +1,320 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers');
test('transform frame exposes eight accurate handles and supports edge resize', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform frame');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-transform-popup')).toBeVisible();
const frame = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const { getHandleAt } = await import('/static/js/editor/tools/transform-handles.js');
const geometry = transformFrameGeometry({
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom });
const canvasRect = state.mainCanvas.getBoundingClientRect();
const right = geometry.resizeHandles.find(handle => handle.id === 'r');
return {
width: state.transformPendingW,
ids: geometry.resizeHandles.map(handle => getHandleAt(handle.x, handle.y)),
rightClient: {
x: canvasRect.left + right.x * canvasRect.width / state.mainCanvas.width,
y: canvasRect.top + right.y * canvasRect.height / state.mainCanvas.height,
},
};
});
expect(frame.ids).toEqual(['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l']);
await page.mouse.move(frame.rightClient.x, frame.rightClient.y);
await page.mouse.down();
await page.mouse.move(frame.rightClient.x + 36, frame.rightClient.y, { steps: 6 });
await page.mouse.up();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
})).toBeGreaterThan(frame.width);
});
test('rotated edge resize follows the frame axis and keeps its opposite edge anchored', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Rotated transform frame');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-rot').fill('90');
const frame = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const geometry = transformFrameGeometry({
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom });
const right = geometry.resizeHandles.find(handle => handle.id === 'r');
const canvasRect = state.mainCanvas.getBoundingClientRect();
return {
width: state.transformPendingW,
center: { ...state.transformCenter },
rightClient: {
x: canvasRect.left + right.x * canvasRect.width / state.mainCanvas.width,
y: canvasRect.top + right.y * canvasRect.height / state.mainCanvas.height,
},
};
});
await page.mouse.move(frame.rightClient.x, frame.rightClient.y);
await page.mouse.down();
await page.mouse.move(frame.rightClient.x, frame.rightClient.y + 36, { steps: 6 });
await page.mouse.up();
const after = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { width: state.transformPendingW, center: { ...state.transformCenter } };
});
expect(after.width).toBeGreaterThan(frame.width);
expect(after.center.x).toBeCloseTo(frame.center.x, 4);
expect(after.center.y).toBeGreaterThan(frame.center.y);
});
test('rotated frame moves only from its visible interior and supports keyboard nudging', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform interaction');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-rot').fill('45');
const frame = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.snapEnabled = false;
const rect = state.mainCanvas.getBoundingClientRect();
const toClient = point => ({
x: rect.left + point.x * rect.width / state.mainCanvas.width,
y: rect.top + point.y * rect.height / state.mainCanvas.height,
});
return {
center: { ...state.transformCenter },
emptyCorner: toClient({ x: 5, y: 5 }),
centerClient: toClient(state.transformCenter),
};
});
await page.mouse.move(frame.emptyCorner.x, frame.emptyCorner.y);
await page.mouse.down();
await page.mouse.move(frame.emptyCorner.x + 24, frame.emptyCorner.y + 18);
await page.mouse.up();
const afterEmptyCorner = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
});
expect(afterEmptyCorner.x).toBeCloseTo(frame.center.x, 5);
expect(afterEmptyCorner.y).toBeCloseTo(frame.center.y, 5);
await page.mouse.move(frame.centerClient.x, frame.centerClient.y);
await page.mouse.down();
await page.mouse.move(frame.centerClient.x + 24, frame.centerClient.y + 18, { steps: 4 });
await page.mouse.up();
const afterDrag = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
});
expect(afterDrag.x).toBeGreaterThan(frame.center.x);
expect(afterDrag.y).toBeGreaterThan(frame.center.y);
await page.keyboard.press('ArrowRight');
await page.keyboard.press('Shift+ArrowDown');
const afterKeys = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
});
expect(afterKeys.x).toBeCloseTo(afterDrag.x + 1, 5);
expect(afterKeys.y).toBeCloseTo(afterDrag.y + 10, 5);
const readout = {
x: Number(await page.locator('#ge-transform-x').inputValue()),
y: Number(await page.locator('#ge-transform-y').inputValue()),
w: Number(await page.locator('#ge-transform-w').inputValue()),
h: Number(await page.locator('#ge-transform-h').inputValue()),
angle: Number(await page.locator('#ge-transform-rot').inputValue()),
};
expect(readout.x).toBeCloseTo(afterKeys.x, 2);
expect(readout.y).toBeCloseTo(afterKeys.y, 2);
expect(readout.w).toBeGreaterThan(0);
expect(readout.h).toBeGreaterThan(0);
expect(readout.angle).toBe(45);
await page.locator('#ge-transform-x').fill('250');
await page.locator('#ge-transform-y').fill('180');
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
})).toEqual({ x: 250, y: 180 });
await page.keyboard.press('Escape');
await expect(page.locator('.ge-transform-popup')).toBeHidden();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformActive;
})).toBe(false);
});
test('transform previews remain source-derived and reject unsafe allocations', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Transform source integrity');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
for (let y = 0; y < layer.canvas.height; y += 8) {
for (let x = 0; x < layer.canvas.width; x += 8) {
layer.ctx.fillStyle = ((x / 8 + y / 8) % 2) ? '#f24f5f' : '#27c2a3';
layer.ctx.fillRect(x, y, 8, 8);
}
}
});
const layerSnapshot = () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const pixels = layer.ctx.getImageData(0, 0, layer.canvas.width, layer.canvas.height).data;
let hash = 2166136261;
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
return { width: layer.canvas.width, height: layer.canvas.height, hash: hash >>> 0 };
});
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.8)));
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
const afterSequentialPreviews = await layerSnapshot();
await page.locator('#ge-transform-cancel-btn').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
const afterDirectPreview = await layerSnapshot();
expect(afterSequentialPreviews).toEqual(afterDirectPreview);
const safeWidth = await page.locator('#ge-transform-w').inputValue();
const beforeRejected = await layerSnapshot();
await page.locator('#ge-transform-w').fill('40000');
await expect(page.locator('#toast')).toContainText('dimension limit');
await expect(page.locator('#ge-transform-w')).toHaveValue(safeWidth);
expect(await layerSnapshot()).toEqual(beforeRejected);
await page.locator('#ge-transform-cancel-btn').click();
});
test('mobile touch input can grab every transform handle', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile transform handles');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-transform-popup')).toBeVisible();
const cdp = await context.newCDPSession(page);
const ids = ['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l', 'rot'];
const handlePoint = id => page.evaluate(async handleId => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const geometry = transformFrameGeometry({
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom, rotationInside: handleId === 'rot' });
const handle = geometry.handles.find(item => item.id === handleId);
const rect = state.mainCanvas.getBoundingClientRect();
return {
x: rect.left + handle.x * rect.width / state.mainCanvas.width,
y: rect.top + handle.y * rect.height / state.mainCanvas.height,
};
}, id);
for (let index = 0; index < ids.length; index += 1) {
const expectedId = ids[index];
const point = await handlePoint(expectedId);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: point.x, y: point.y, id: index + 1, radiusX: 4, radiusY: 4 }],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe(expectedId);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe(null);
}
const right = await handlePoint('r');
const widthBefore = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: right.x, y: right.y, id: 20, radiusX: 4, radiusY: 4 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: right.x + 80, y: right.y, id: 20, radiusX: 4, radiusY: 4 }],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
})).toBeGreaterThan(widthBefore);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const center = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const rect = state.mainCanvas.getBoundingClientRect();
return {
x: rect.left + state.transformCenter.x * rect.width / state.mainCanvas.width,
y: rect.top + state.transformCenter.y * rect.height / state.mainCanvas.height,
zoom: state.zoom,
};
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: center.x, y: center.y, id: 30, radiusX: 4, radiusY: 4 }],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe('move');
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [
{ x: center.x - 20, y: center.y, id: 30, radiusX: 4, radiusY: 4 },
{ x: center.x + 20, y: center.y, id: 31, radiusX: 4, radiusY: 4 },
],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe(null);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [
{ x: center.x - 45, y: center.y, id: 30, radiusX: 4, radiusY: 4 },
{ x: center.x + 45, y: center.y, id: 31, radiusX: 4, radiusY: 4 },
],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.zoom;
})).toBeGreaterThan(center.zoom);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
} finally {
await context.close();
}
});
+60
View File
@@ -0,0 +1,60 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { defineConfig } = require('@playwright/test');
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
const baseURL = `http://127.0.0.1:${port}`;
const repositoryRoot = path.join(__dirname, '..', '..');
const defaultPython = process.platform === 'win32'
? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe')
: path.join(repositoryRoot, '.venv', 'bin', 'python');
const python = process.env.ODYSSEUS_TEST_PYTHON || (fs.existsSync(defaultPython) ? defaultPython : 'python');
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-photo-editor-e2e-'));
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH
|| path.join(dataDirectory, 'app.db');
process.env.PHOTO_EDITOR_E2E_DB_PATH = databasePath;
process.env.PHOTO_EDITOR_E2E_DATA_DIR = dataDirectory;
const browserName = process.env.PHOTO_EDITOR_E2E_BROWSER || 'chromium';
if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
}
module.exports = defineConfig({
testDir: path.join(__dirname, 'photo-editor'),
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
timeout: 90_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
globalSetup: require.resolve('./setup.js'),
globalTeardown: require.resolve('./teardown.js'),
use: {
baseURL,
browserName,
headless: true,
viewport: { width: 1440, height: 960 },
extraHTTPHeaders: { 'Accept-Encoding': 'identity' },
serviceWorkers: 'block',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
webServer: {
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
cwd: repositoryRoot,
url: baseURL,
timeout: 120_000,
reuseExistingServer: false,
env: {
...process.env,
AUTH_ENABLED: 'false',
DATABASE_URL: `sqlite:///${databasePath}`,
ODYSSEUS_DATA_DIR: dataDirectory,
ODYSSEUS_STARTUP_WARMUPS: '0',
RESPONSE_COMPRESSION_ENABLED: 'false',
},
},
});
+10
View File
@@ -0,0 +1,10 @@
const fs = require('node:fs');
module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH;
if (!dataDirectory || !databasePath) {
throw new Error('Photo editor E2E data paths were not configured');
}
fs.mkdirSync(dataDirectory, { recursive: true });
};
+7
View File
@@ -0,0 +1,7 @@
const fs = require('node:fs');
module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
if (!dataDirectory) return;
fs.rmSync(dataDirectory, { recursive: true, force: true });
};
+60
View File
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.route('http://editor.test/**', async route => {
const path = new URL(route.request().url()).pathname;
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<button id="run">Generate</button>' });
const body = await readFile(new URL('../static/js/editor/' + path.slice(1), import.meta.url), 'utf8');
return route.fulfill({ contentType: 'text/javascript', body });
});
await page.goto('http://editor.test/');
const result = await page.evaluate(async () => {
const { createApplyImageTool } = await import('/ai-tool-runner.js');
const { decodeAIImage } = await import('/ai-operation.js');
const { state } = await import('/state.js');
state.editorOpen = true;
let requests = 0;
let layers = 0;
const messages = [];
window.fetch = (_url, { signal }) => {
requests++;
return new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true }));
};
const run = createApplyImageTool({
flatten: () => document.createElement('canvas'), saveState() {},
createLayer() { layers++; }, composite() {}, renderLayerPanel() {},
deriveBusyLabel: () => 'Generating', getSelectedAIEndpoint: () => ({}),
spinnerModule: {}, uiModule: { showToast: message => messages.push(message) },
});
const button = document.querySelector('button');
let pending;
button.addEventListener('click', () => { pending = run('/test', {}, 'Test', button); });
button.click();
const cancellable = !button.disabled && button.getAttribute('aria-busy') === 'true';
button.click();
await pending;
const restored = button.textContent === 'Generate' && !button.hasAttribute('aria-busy');
button.click();
button.click();
await pending;
const controller = new AbortController();
controller.abort();
let decodeCancelled = false;
try { await decodeAIImage('invalid', controller.signal); }
catch (error) { decodeCancelled = error.name === 'AbortError'; }
return { requests, layers, messages, cancellable, restored, decodeCancelled };
});
assert.equal(result.requests, 2);
assert.equal(result.layers, 0);
assert.deepEqual(result.messages, ['Cancelled', 'Cancelled']);
assert.equal(result.cancellable, true);
assert.equal(result.restored, true);
assert.equal(result.decodeCancelled, true);
console.log('AI cancel: repeated click, request abort, retry, UI restoration, and decode guard passed');
} finally {
await browser.close();
}
+73
View File
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
import { appCss } from './helpers/stylesheets.mjs';
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.route('http://editor.test/**', async route => {
const path = new URL(route.request().url()).pathname;
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<button id="fx" style="position:fixed;bottom:4px;right:4px">fx</button>' });
if (!path.endsWith('.js')) return route.fulfill({ status: 404, body: '' });
return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('../static/js/editor' + path, import.meta.url), 'utf8') });
});
await page.goto('http://editor.test/');
const results = await page.evaluate(async () => {
const { LAYER_STYLES } = await import('/layer-styles.js');
const { normalizeEffect, renderEffects, renderEffectsAsync, effectsWithPreview } = await import('/effects.js');
const source = document.createElement('canvas'); source.width = source.height = 64;
const ctx = source.getContext('2d'); ctx.fillStyle = '#668899'; ctx.fillRect(16, 16, 32, 32);
const original = ctx.getImageData(0, 0, 64, 64).data;
const shadow = normalizeEffect({ id: 'shadow', type: 'drop-shadow', params: { color: '#ff0000', opacity: 1, blur: 0, x: 8, y: 8 } });
const shadowed = renderEffects(source, [shadow]);
const pixel = (canvas, x, y) => [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
if (String(pixel(shadowed, 30, 30)) !== String(pixel(source, 30, 30))) throw Error('shadow covers source');
if (String(pixel(shadowed, 50, 50)) !== '255,0,0,255') throw Error('shadow offset or color');
const workerShadow = await renderEffectsAsync(source, [shadow]);
if (String(pixel(workerShadow, 50, 50)) !== String(pixel(shadowed, 50, 50))) throw Error('shadow worker mismatch');
const edited = { ...shadow, params: { ...shadow.params, x: 12 } };
const preview = effectsWithPreview({ effects: [shadow], _effectPreview: edited });
if (preview.length !== 1 || preview[0] !== edited) throw Error('duplicate preview');
const result = [];
for (const type of Object.keys(LAYER_STYLES)) {
const effect = normalizeEffect({ type });
if (normalizeEffect(JSON.parse(JSON.stringify(effect))).type !== type) throw Error('restore ' + type);
const sync = renderEffects(source, [effect]).getContext('2d').getImageData(0, 0, 64, 64).data;
if (!sync.some((v, i) => v !== original[i])) throw Error('no effect ' + type);
const asyncCanvas = await renderEffectsAsync(source, [effect]);
const asyncPixels = asyncCanvas.getContext('2d').getImageData(0, 0, 64, 64).data;
const difference = sync.reduce((max, v, i) => Math.max(max, Math.abs(v - asyncPixels[i])), 0);
if (difference > 2) throw Error('worker mismatch ' + type + ': ' + difference);
const hidden = renderEffects(source, [{ ...effect, visible: false }]).getContext('2d').getImageData(0, 0, 64, 64).data;
if (hidden.some((v, i) => v !== original[i])) throw Error('hidden ' + type);
if (ctx.getImageData(0, 0, 64, 64).data.some((v, i) => v !== original[i])) throw Error('mutated ' + type);
result.push(type);
}
return result;
});
assert.equal(results.length, 7);
await page.addStyleTag({ content: await appCss() });
await page.evaluate(async () => {
const { openLayerStyleMenu } = await import('/layer-style-menu.js');
document.querySelector('#fx').onclick = event => { event.stopPropagation(); openLayerStyleMenu(event.currentTarget, () => {}); };
});
for (const viewport of [{ width: 1280, height: 720 }, { width: 375, height: 420 }]) {
await page.setViewportSize(viewport);
await page.locator('#fx').click();
const menu = page.locator('.ge-layer-style-menu');
assert.equal(await menu.locator('button').count(), 10);
const bounds = await menu.boundingBox();
assert.equal(await menu.evaluate(el => getComputedStyle(el).opacity), '1');
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= viewport.width && bounds.y + bounds.height <= viewport.height);
await page.screenshot({ path: `/tmp/editor-layer-styles-${viewport.width}.png` });
await page.keyboard.press('Escape');
await page.waitForTimeout(30);
assert.equal(await menu.count(), 0);
}
await page.locator('#fx').click();
await page.locator('#fx').click();
await page.waitForTimeout(30);
assert.equal(await page.locator('.ge-layer-style-menu').count(), 0, 'repeat click closes menu');
console.log('7 styles: render, worker parity, restoration, visibility and source preservation passed; 10-item menu fits desktop/mobile and closes on Escape.');
} finally { await browser.close(); }
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { documentSource } from './helpers/document_source.mjs';
import { chromium } from 'playwright';
const source = documentSource();
function extract(name) {
const start = source.indexOf(` function ${name}(`);
const rest = source.slice(start + 2);
const next = rest.slice(10).search(/\n (?:export )?(?:async )?function /);
return rest.slice(0, next + 10);
}
const functions = ['_showRichTextEditor', '_syncEmailRichbody'].map(extract).join('\n');
const browser = await chromium.launch({headless:true});
try {
const page = await browser.newPage();
await page.setContent('<div class="doc-editor-pane"><div id="doc-editor-wrap"></div><textarea id="doc-editor-textarea"></textarea><div id="doc-email-richbody" contenteditable="true"></div></div>');
const result = await page.evaluate(async (functions) => {
const docs = new Map(); const activeDocId = 'fixture';
let _richInlineCodeTypingArmed = false;
const _clearRichImageSelection = () => {};
const _normalizeRichTextImages = () => {};
const _normalizeRichChecklists = () => {};
const _normalizeRichInlineCode = () => {};
const _wireEmailRichbody = () => {};
const _syncRichEmptyImport = () => {};
const _isRichTextLang = lang => lang === 'richtext';
const _sanitizedRichTextHtml = rich => rich.innerHTML;
const _richTextContentToHtml = content => content;
const _emailRichbodyActive = () => document.getElementById('doc-email-richbody');
const newContent = '<p>This sentence needs work.</p><p><strong>Keep this.</strong></p>';
const doc = {id:activeDocId, language:'richtext',content:newContent}; docs.set(activeDocId,doc);
eval(functions + '\n_showRichTextEditor(doc);');
return {overlayGone:!document.querySelector('.doc-rich-diff-overlay'),rich:_emailRichbodyActive().innerHTML,mirror:document.getElementById('doc-editor-textarea').value,content:doc.content,newContent};
}, functions);
assert.equal(result.overlayGone, true);
assert.equal(result.rich,result.newContent); assert.equal(result.mirror,result.newContent); assert.equal(result.content,result.newContent);
const discardSource = source.slice(source.indexOf(' function exitDiffMode('), source.indexOf(' function isDiffModeActive('));
const staleSaveCount = await page.evaluate(discardSource => {
let _diffModeActive = true, _diffChunks = [], _diffOldContent = 'stale text', _diffNewContent = 'saved text', _diffUnresolvedCount = 1;
let saves = 0;
const saveDocument = () => { saves++; };
const syncHighlighting = () => {};
const updateLineNumbers = () => {};
eval(discardSource + '\nexitDiffMode(true, { persist: false });');
return saves;
}, discardSource);
assert.equal(staleSaveCount, 0);
console.log('PASS: saved rich-text changes appear immediately with no transient diff overlay.');
console.log('PASS: clearing a stale review diff cannot overwrite a newer saved edit.');
} finally {await browser.close();}
+71
View File
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import { documentSource } from './helpers/document_source.mjs';
import { chromium } from 'playwright';
const source = documentSource();
const start = source.indexOf(' function _applySuggestions(');
const end = source.indexOf(' /** Animate transition to next suggestion */', start);
assert.ok(start >= 0 && end > start);
const applySource = source.slice(start, end);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setContent('<textarea id="doc-editor-textarea"></textarea><div id="doc-email-richbody"></div><iframe id="doc-html-preview"></iframe>');
const results = await page.evaluate((fn) => {
const textarea = document.getElementById('doc-editor-textarea');
const rich = document.getElementById('doc-email-richbody');
const docs = new Map();
const activeDocId = 'test-doc';
let saves = 0;
const errors = [];
const uiModule = { showError: message => errors.push(message) };
const saveCurrentToMap = () => {
const doc = docs.get(activeDocId);
doc.content = doc.language === 'richtext' ? rich.innerHTML :
doc.language === 'email' ? `To: person@example.com\n\n${rich.innerHTML}` : textarea.value;
};
const _isRichTextLang = lang => lang === 'richtext';
const _showRichTextEditor = doc => { rich.innerHTML = doc.content; textarea.value = doc.content; };
const _showEmailFields = doc => { rich.innerHTML = doc.content.split('\n\n').slice(1).join('\n\n'); };
const _refreshMarkdownPreviewIfVisible = () => {};
const _htmlPreviewActive = true;
const _isRenderLang = lang => lang === 'svg';
const _themedRenderSrcdoc = content => content;
const syncHighlighting = () => {};
const saveDocument = () => { saveCurrentToMap(); saves++; };
return eval(fn + `
const outcome = {};
for (const language of ['richtext', 'email', 'markdown', 'javascript', 'svg']) {
const original = language === 'email' ? 'To: person@example.com\\n\\n<p>old phrase</p>' :
language === 'richtext' ? '<p>old phrase</p>' : 'old phrase';
docs.set(activeDocId, { id: activeDocId, language, content: original });
if (language === 'email' || language === 'richtext') rich.innerHTML = '<p>old phrase</p>';
else textarea.value = original;
const applied = _applySuggestions([{ id: 'one', find: 'old phrase', replace: 'new phrase' }]);
outcome[language] = {
applied, content: docs.get(activeDocId).content,
preview: document.getElementById('doc-html-preview').srcdoc,
visible: language === 'email' || language === 'richtext' ? rich.innerHTML : textarea.value,
};
}
const before = saves;
const unmatched = _applySuggestions([{ id: 'missing', find: 'absent phrase', replace: 'x' }]);
({ outcome, saves, before, unmatched, errors });
`);
}, applySource);
for (const language of ['richtext', 'email', 'markdown', 'javascript', 'svg']) {
assert.deepEqual(results.outcome[language].applied, ['one']);
assert.match(results.outcome[language].content, /new phrase/);
assert.match(results.outcome[language].visible, /new phrase/);
}
assert.match(results.outcome.svg.preview, /new phrase/);
assert.equal(results.saves, 5);
assert.equal(results.before, 5);
assert.deepEqual(results.unmatched, []);
assert.equal(results.errors.length, 1);
console.log('PASS: accepting suggestions updates and saves rich text, email, markdown, and code documents.');
console.log('PASS: stale suggestions remain pending and do not save an unchanged document.');
} finally {
await browser.close();
}
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import { documentSource } from './helpers/document_source.mjs';
import { chromium } from 'playwright';
import { appCss } from './helpers/stylesheets.mjs';
const source = documentSource();
const start = source.indexOf(' function _showCurrentSuggestion()');
const end = source.indexOf(' /** Show inline diff by modifying', start);
assert.ok(start >= 0 && end > start);
const renderSource = source.slice(start, end);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 700, height: 500 } });
await page.setContent('<div class="doc-editor-pane"><div id="doc-editor-wrap"></div></div>');
await page.addStyleTag({ content: await appCss() });
const result = await page.evaluate(code => {
let _activeSuggestions = [];
let _suggestionTotal = 0, _suggestionIndex = 0;
let accepted = [];
const _clearSuggestionHighlight = () => {};
const topPortalZ = () => 10031;
const _clearInlineDiff = () => {};
const _clearSuggestionTextSelection = () => {};
const _esc = value => String(value || '');
const clearAllSuggestions = () => {};
const _applySuggestions = suggestions => { accepted = suggestions.map(s => s.id); return accepted; };
const _animateNext = () => {};
return eval(code + `
const inspect = count => {
_activeSuggestions = Array.from({length:count}, (_, i) => ({id:String(i),find:'old'+i,replace:'new'+i,reason:'Reason'}));
_suggestionTotal = count;
_showCurrentSuggestion();
const card = document.getElementById('doc-suggestion-active');
const buttons = [...card.querySelectorAll('.doc-suggestion-actions button')];
const cardRight = card.getBoundingClientRect().right;
return {labels:buttons.map(button => button.textContent.trim()),
fits:buttons.every(button => button.getBoundingClientRect().right <= cardRight + 1)};
};
const one = inspect(1);
const many = inspect(3);
document.querySelector('.doc-suggestion-accept-all').click();
({one, many, accepted});
`);
}, renderSource);
assert.deepEqual(result.one.labels, ['Accept', 'Accept All', 'Skip']);
assert.deepEqual(result.many.labels, ['Accept', 'Accept All', 'Skip']);
assert.equal(result.one.fits, true);
assert.equal(result.many.fits, true);
assert.deepEqual(result.accepted, ['0', '1', '2']);
console.log('PASS: Accept, Accept All, and Skip stay visible and fit in one row.');
} finally {
await browser.close();
}
+48
View File
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
const url = process.env.ODYSSEUS_UI_TEST_URL;
test('reply stream tolerates caret setup but never overwrites user input', { skip: !url }, async () => {
const browser = await chromium.launch();
try {
const context = await browser.newContext();
await context.addCookies([{ name: 'odysseus_session', value: process.env.ODYSSEUS_UI_TEST_COOKIE, url }]);
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.documentModule);
await page.route('**/api/document/qa-stream-*', route => route.fulfill({ json: {} }));
let editDuringRequest = false;
await page.route('**/api/email/ai-reply', async route => {
if (editDuringRequest) {
await page.evaluate(() => {
const rich = document.getElementById('doc-email-richbody');
rich.textContent = 'My own reply';
rich.dispatchEvent(new Event('input', { bubbles: true }));
});
}
await route.fulfill({ contentType: 'text/event-stream', body: [
{ type: 'reply', text: '' },
{ type: 'reply', text: 'Hi QA,\nTomorrow works.\nFelix' },
{ type: 'result', success: true, reply: 'Hi QA,\nTomorrow works.\nFelix', model_used: 'fixture' },
].map(event => `data: ${JSON.stringify(event)}\n\n`).join('') });
});
const run = id => page.evaluate(async id => {
await window.documentModule.injectFreshDoc({ id, title: 'QA reply stream', language: 'email',
content: 'To: qa@example.com\nSubject: QA\n---\n\n---------- Previous message ----------\nHello' });
const success = await window.documentModule.generateEmailReply({ originalBody: 'Hi Felix, can we meet tomorrow?' });
return { success, body: document.getElementById('doc-editor-textarea').value,
toast: [...document.querySelectorAll('.toast-message')].map(el => el.textContent).join('\n') };
}, id);
const generated = await run('qa-stream-normal');
assert.equal(generated.success, true, generated.toast);
assert.match(generated.body, /Tomorrow works/);
assert.match(generated.body, /Previous message/);
editDuringRequest = true;
const edited = await run('qa-stream-edited');
assert.notEqual(edited.success, true);
assert.equal(edited.body, 'My own reply');
} finally {
await browser.close();
}
});
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readEmailReplyResponse} from '../static/js/emailReplyStream.js';
const response = frames => new Response(frames.map(f => `data: ${JSON.stringify(f)}\n\n`).join(''),
{headers: {'content-type': 'text/event-stream'}});
test('streams body then requires successful terminal result', async () => {
const seen = [];
const result = await readEmailReplyResponse(response([
{type:'reply', text:'Hi'}, {type:'reply', text:'Hi Jonathan'},
{type:'result', success:true, reply:'Hi Jonathan'},
]), text => seen.push(text));
assert.deepEqual(seen, ['Hi', 'Hi Jonathan']);
assert.equal(result.success, true);
});
test('editing the draft stops streaming insertion', async () => {
await assert.rejects(readEmailReplyResponse(response([{type:'reply', text:'Hi'}]), () => false), /edited or closed/);
});
test('disconnect is not a completed draft', async () => {
await assert.rejects(readEmailReplyResponse(response([{type:'reply', text:'Hi'}]), () => {}), /before completion/);
});
+69
View File
@@ -0,0 +1,69 @@
[
{
"id": "clean_v3_open_email_reply",
"kind": "draft",
"user": "Write reply this email saying 8am works for me",
"active_document": {
"title": "New Email",
"language": "email",
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture@example.com>\nReferences: <fixture@example.com>\nX-Source-UID: 999999\n---\n\n---------- Previous message ----------\nCan you confirm the meeting time?\n"
},
"expect_first_tool": "update_document",
"forbidden_tools": [
"create_document",
"edit_document",
"suggest_document",
"manage_documents",
"web_search"
],
"must_mutate": "document_contains_8am"
},
{
"id": "clean_v3_open_email_short_reply",
"kind": "draft",
"user": "Write reply saying 8am works for me",
"active_document": {
"title": "Meeting",
"language": "email",
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture-short@example.com>\nReferences: <fixture-short@example.com>\nX-Source-UID: 999998\n---\n\n---------- Previous message ----------\nCan you confirm the meeting time?\n"
},
"expect_first_tool": "update_document",
"forbidden_tools": [
"create_document",
"edit_document",
"suggest_document",
"manage_documents",
"web_search"
],
"must_mutate": "document_contains_8am"
},
{
"id": "clean_v3_open_email_unspecified_reply",
"kind": "draft",
"user": "Write reply to this email",
"active_document": {
"title": "Meeting",
"language": "email",
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture-unspecified@example.com>\nReferences: <fixture-unspecified@example.com>\nX-Source-UID: 999997\n---\n\n---------- Previous message ----------\nCan you confirm whether tomorrow morning works?\n"
},
"expect_first_tool": "update_document",
"forbidden_tools": [
"create_document",
"edit_document",
"suggest_document",
"manage_documents",
"ui_control",
"web_search"
],
"forbidden_repeat_tools": ["update_document"],
"expect_document_changed": true,
"must_preserve_active_document_all": [
"To:",
"Subject:",
"In-Reply-To:",
"References:",
"X-Source-UID:",
"---"
]
}
]
+158
View File
@@ -0,0 +1,158 @@
[
{
"name": "microsoft/Phi-mini-MoE-instruct",
"provider": "test",
"parameter_count": "2B",
"parameters_raw": 2000000000,
"quantization": "F16",
"context_length": 4096,
"release_date": "2026-01-01",
"gguf_sources": []
},
{
"name": "Qwen/Qwen2.5-3B-Instruct-AWQ",
"provider": "test",
"parameter_count": "3B",
"parameters_raw": 3000000000,
"quantization": "AWQ-4bit",
"context_length": 4096,
"release_date": "2026-01-02",
"gguf_sources": []
},
{
"name": "Qwen/Qwen2.5-3B-Instruct",
"provider": "test",
"parameter_count": "3B",
"parameters_raw": 3000000000,
"quantization": "F16",
"context_length": 4096,
"release_date": "2026-01-03",
"gguf_sources": [
{
"repo": "test/three-GGUF",
"file": "three-Q4_K_M.gguf"
}
]
},
{
"name": "Qwen/Qwen3.5-9B",
"provider": "test",
"parameter_count": "3B",
"parameters_raw": 3000000000,
"quantization": "F16",
"context_length": 4096,
"release_date": "2026-01-04",
"gguf_sources": [
{
"repo": "unsloth/Qwen3.5-9B-GGUF",
"file": "Qwen3.5-9B-Q4_K_M.gguf"
}
]
},
{
"name": "Qwen/Qwen3.6-27B",
"provider": "test",
"parameter_count": "3B",
"parameters_raw": 3000000000,
"quantization": "F16",
"context_length": 4096,
"release_date": "2026-01-05",
"gguf_sources": [
{
"repo": "unsloth/Qwen3.6-27B-GGUF",
"file": "Qwen3.6-27B-Q4_K_M.gguf"
}
]
},
{
"name": "Qwen/Qwen3.6-35B-A3B",
"provider": "test",
"parameter_count": "3B",
"parameters_raw": 3000000000,
"quantization": "F16",
"context_length": 4096,
"release_date": "2026-01-06",
"gguf_sources": [
{
"repo": "unsloth/Qwen3.6-35B-A3B-GGUF",
"file": "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"
}
]
},
{
"name": "google/gemma-4-12B-it",
"provider": "test",
"parameter_count": "12B",
"parameters_raw": 12000000000,
"quantization": "F16",
"context_length": 4096,
"release_date": "2026-01-07",
"gguf_sources": [
{
"repo": "unsloth/gemma-4-12B-it-GGUF",
"file": "test-Q4_K_M.gguf"
}
]
},
{
"name": "google/gemma-4-12B-it-qat-int4",
"provider": "test",
"parameter_count": "12B",
"parameters_raw": 12000000000,
"quantization": "QAT-INT4",
"context_length": 4096,
"release_date": "2026-01-08",
"gguf_sources": []
},
{
"name": "google/gemma-4-12B-it-qat-int8",
"provider": "test",
"parameter_count": "12B",
"parameters_raw": 12000000000,
"quantization": "QAT-INT8",
"context_length": 4096,
"release_date": "2026-01-09",
"gguf_sources": []
},
{
"name": "test/native-NVFP4",
"provider": "test",
"parameter_count": "12B",
"parameters_raw": 12000000000,
"quantization": "NVFP4",
"context_length": 4096,
"release_date": "2026-01-10",
"gguf_sources": []
},
{
"name": "test/native-FP8",
"provider": "test",
"parameter_count": "3B",
"parameters_raw": 3000000000,
"quantization": "FP8",
"context_length": 4096,
"release_date": "2026-01-11",
"gguf_sources": []
},
{
"name": "mlx-community/test-4bit",
"provider": "test",
"parameter_count": "3B",
"parameters_raw": 3000000000,
"quantization": "mlx-4bit",
"context_length": 4096,
"release_date": "2026-01-12",
"gguf_sources": []
},
{
"name": "test/standalone-GGUF",
"provider": "test",
"parameter_count": "3B",
"parameters_raw": 3000000000,
"quantization": "Q4_K_M",
"context_length": 4096,
"release_date": null,
"gguf_sources": [],
"is_gguf": true
}
]
+18
View File
@@ -0,0 +1,18 @@
[
{
"id": "hardware_aware_model_recommendation",
"kind": "cookbook_admin",
"user": "find the best model to run on my hardware",
"expect_first_tool_any": [
"app_api",
"list_models"
],
"must_answer_any": [
"Ryzen AI Max+ 395",
"96 GB",
"96GB",
"compatible",
"Qwen/Qwen3-Next-80B-A3B-Thinking"
]
}
]
+12
View File
@@ -0,0 +1,12 @@
{
"fixture": "basic-shapes.png",
"required_facts": {
"text": "ODYSSEUS 42",
"left_object": "red circle",
"right_object": "blue square"
},
"followup": {
"prompt": "What color was the shape on the right?",
"required_answer": "blue"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480" viewBox="0 0 640 480">
<rect width="640" height="480" fill="#ffffff"/>
<text x="320" y="90" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="52" font-weight="bold" fill="#111111">ODYSSEUS 42</text>
<circle cx="190" cy="285" r="90" fill="#e53935"/>
<rect x="370" y="195" width="180" height="180" fill="#1e5bd7"/>
</svg>

After

Width:  |  Height:  |  Size: 413 B

+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Quarterly Sales Dashboard</title>
<style>
body { margin: 0; background: #eef2f7; color: #172033; font: 24px Arial, sans-serif; }
main { width: 960px; height: 640px; box-sizing: border-box; padding: 42px 56px; }
h1 { margin: 0 0 28px; font-size: 38px; }
.cards { display: flex; gap: 20px; margin-bottom: 34px; }
.card { background: white; border-radius: 14px; padding: 18px 24px; box-shadow: 0 3px 12px #17203320; }
.label { color: #64748b; font-size: 18px; }
.value { font-size: 30px; font-weight: 700; margin-top: 5px; }
.healthy { color: #14804a; }
.chart { height: 350px; display: flex; align-items: end; gap: 42px; padding: 0 45px; background: white; border-radius: 14px; box-shadow: 0 3px 12px #17203320; }
.column { width: 135px; text-align: center; font-weight: 700; }
.bar { background: #3976e8; border-radius: 10px 10px 0 0; color: white; padding-top: 10px; box-sizing: border-box; }
.q1 { height: 100px; } .q2 { height: 175px; } .q3 { height: 275px; background: #7c3aed; } .q4 { height: 200px; }
.quarter { padding: 12px 0 18px; color: #334155; }
</style>
</head>
<body>
<main>
<h1>Quarterly Sales Dashboard</h1>
<div class="cards">
<div class="card"><div class="label">Build status</div><div class="value healthy">Healthy</div></div>
<div class="card"><div class="label">API latency</div><div class="value">142 ms</div></div>
<div class="card"><div class="label">Active users</div><div class="value">1,284</div></div>
</div>
<div class="chart" aria-label="Quarterly sales: Q1 20, Q2 35, Q3 55, Q4 40">
<div class="column"><div class="bar q1">20</div><div class="quarter">Q1</div></div>
<div class="column"><div class="bar q2">35</div><div class="quarter">Q2</div></div>
<div class="column"><div class="bar q3">55</div><div class="quarter">Q3</div></div>
<div class="column"><div class="bar q4">40</div><div class="quarter">Q4</div></div>
</div>
</main>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { generatedImageResult } from '../static/js/generatedImageResult.js';
test('restores successful legacy images from saved tool output', () => {
const image = { image_url: '/api/generated-image/test.png', image_model: 'openai/gpt-5-image' };
assert.deepEqual(generatedImageResult({ tool: 'generate_image', exit_code: 0, output: JSON.stringify(image) }), image);
assert.equal(generatedImageResult({ tool: 'generate_image', error: true, output: JSON.stringify(image) }), null);
assert.equal(generatedImageResult({ tool: 'web_fetch', output: JSON.stringify(image) }), null);
assert.equal(generatedImageResult({ tool: 'generate_image', output: 'invalid' }), null);
});
test('uses persisted image metadata for new turns', () => {
const event = { tool: 'generate_image', image_url: '/api/generated-image/test.png', exit_code: 0 };
assert.equal(generatedImageResult(event), event);
});
+51
View File
@@ -0,0 +1,51 @@
"""Disposable databases for tests that exercise the real ORM and session manager."""
from contextlib import contextmanager
from tempfile import TemporaryDirectory
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
@contextmanager
def disposable_database(tmp_path):
"""Own the database file and engine; keep the canonical ORM classes intact."""
import core.database as database
with TemporaryDirectory(prefix="database-", dir=tmp_path) as directory:
engine = create_engine(
f"sqlite:///{directory}/test.db",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
try:
database.Base.metadata.create_all(engine)
yield sessionmaker(bind=engine, autoflush=False, autocommit=False)
finally:
engine.dispose()
@contextmanager
def isolated_session_database(tmp_path):
"""Temporarily bind the real manager and database aliases without reloading.
Reloading core.database changes its ORM classes while existing imports keep
the old classes and factories. Patch only resource bindings instead, and
undo them before disposing the owned engine and removing its files.
"""
import core.database as database
import core.session_manager as manager
import src.database as compatibility_database
with disposable_database(tmp_path) as factory:
engine = factory.kw["bind"]
with pytest.MonkeyPatch.context() as patcher:
patcher.setenv("DATABASE_URL", str(engine.url))
for module in (database, compatibility_database):
patcher.setattr(module, "DATABASE_URL", str(engine.url))
patcher.setattr(module, "engine", engine)
patcher.setattr(module, "SessionLocal", factory)
patcher.setattr(manager, "SessionLocal", factory)
yield manager.SessionManager(), database
+32
View File
@@ -0,0 +1,32 @@
// JS twin of tests/helpers/document_source.py: the document editor's whole
// implementation set, so tests keep finding code as it moves out of the
// static/js/document.js entry into static/js/document/.
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const JS = join(HERE, '..', '..', 'static', 'js');
const ENTRY = join(JS, 'document.js');
const IMPL_DIR = join(JS, 'document');
function walk(dir) {
return readdirSync(dir).sort().flatMap(name => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return walk(path);
return name.endsWith('.js') ? [path] : [];
});
}
/** Every file holding document-editor implementation, entry first. */
export function documentSourcePaths() {
if (!existsSync(ENTRY)) {
throw new Error(`document editor entry point is missing: ${ENTRY}`);
}
return [ENTRY, ...(existsSync(IMPL_DIR) ? walk(IMPL_DIR).sort() : [])];
}
/** The whole implementation set as one string, entry first. */
export function documentSource() {
return documentSourcePaths().map(path => readFileSync(path, 'utf8')).join('\n');
}
+288
View File
@@ -0,0 +1,288 @@
"""Read the document editor's JavaScript the way the browser loads it.
``static/js/document.js`` is being decomposed. It stays the entry point the
browser requests -- ``static/index.html`` names it, ``static/sw.js`` precaches
it, and five modules import it -- but the implementation moves into modules
under ``static/js/document/``. The implementation set is the entry plus that
directory.
Two habits in the existing tests do not survive that move, and this module
exists to replace both.
**Reading the entry file alone.** A membership assertion against
``document.js`` silently covers less the moment the behaviour it names moves
out. Use :func:`document_source` for those: it is the whole implementation set,
so a test keeps finding what it asserts on wherever the code lands.
**Slicing between two adjacent functions.** ``function_body("a")`` means "the region between a and b", which is only
the body of ``a`` while ``a`` and ``b`` happen to be neighbours in one file.
After a split they may sit in different modules, and then the slice runs to the
end of the concatenation and quietly grows: an ``assert "x" in region`` passes
against code it was never meant to see. Several of these also hard-code the
entry file's two-space indentation (``"\\n function showDocTabMenu"``), which
no extracted module reproduces. Use :func:`function_body` or
:func:`declaration` instead -- they find the construct by name, in whichever
module defines it, and end at its real closing brace.
"""
from __future__ import annotations
import re
from pathlib import Path
_STATIC = Path(__file__).resolve().parents[2] / "static"
_ENTRY = _STATIC / "js" / "document.js"
# Extracted implementation modules get one home, so the set is discoverable
# without a manifest anyone has to remember to update.
_IMPL_DIR = _STATIC / "js" / "document"
def document_source_paths() -> list[Path]:
"""Every file holding document-editor implementation, entry first.
The entry comes first so a concatenation reads in the order the browser
evaluates the graph's root; the rest are sorted for determinism.
"""
if not _ENTRY.is_file():
raise AssertionError(f"document editor entry point is missing: {_ENTRY}")
extracted = sorted(_IMPL_DIR.rglob("*.js")) if _IMPL_DIR.is_dir() else []
return [_ENTRY, *extracted]
def document_source() -> str:
"""The whole implementation set as one string, entry first.
For membership assertions (``assert "..." in document_source()``). For
anything positional use :func:`function_body` or :func:`declaration`.
"""
return "\n".join(p.read_text(encoding="utf-8") for p in document_source_paths())
# --- Locating a construct by name, not by what follows it ------------------
def _defining_source(pattern: re.Pattern[str], what: str) -> tuple[str, int]:
"""The source text that defines ``what``, and the offset of the match."""
hits = []
for path in document_source_paths():
src = path.read_text(encoding="utf-8")
for m in pattern.finditer(src):
hits.append((path, src, m.start()))
if not hits:
raise AssertionError(f"{what} is not defined anywhere in {_describe_set()}")
if len(hits) > 1:
where = ", ".join(
f"{p.relative_to(_STATIC.parent)}:{s.count(chr(10), 0, o) + 1}"
for p, s, o in hits
)
raise AssertionError(f"{what} is defined more than once ({where})")
_path, src, offset = hits[0]
return src, offset
def _describe_set() -> str:
return ", ".join(str(p.relative_to(_STATIC.parent)) for p in document_source_paths())
def function_body(name: str) -> str:
"""The full text of function ``name``, signature through closing brace.
Matches ``function name``, optionally prefixed by ``export`` and/or
``async``, at any indentation, in whichever module of the implementation
set defines it. The end is found by matching braces rather than by naming
whatever declaration follows, so moving the function -- or the one after
it -- does not change the region a test sees.
"""
pattern = re.compile(
r"^[ \t]*(?:export\s+)?(?:async\s+)?function\s+" + re.escape(name) + r"\s*\(",
re.M,
)
src, offset = _defining_source(pattern, f"function {name}")
# Skip the parameter list before looking for the body. A destructured
# parameter -- `function f(table, { headerRow, headerColumn })` -- opens a
# brace that is not the body, and matching it would return the signature
# alone.
body_start = _end_of_params(src, src.index("(", offset))
return src[offset : _end_of_block(src, body_start)]
def declaration(name: str) -> str:
"""The full text of a top-level ``const``/``let``/``var`` named ``name``.
For the array and object tables the tests assert on (toolbar groups, slash
commands, input rules). Ends at the declaration's closing bracket or brace,
or at the end of the statement for a simple initialiser.
"""
pattern = re.compile(
r"^[ \t]*(?:export\s+)?(?:const|let|var)\s+" + re.escape(name) + r"\b",
re.M,
)
src, offset = _defining_source(pattern, f"declaration {name}")
return src[offset : _end_of_statement(src, offset)]
# --- A brace matcher that is not fooled by braces inside literals ----------
#
# `document.js` is full of template literals building DOM, regexes containing
# braces, and apostrophes inside comments. Counting raw `{`/`}` mis-slices on
# all three, so the scan tracks what kind of text it is inside.
# After one of these, `/` starts a regex literal; after a value it is division.
_REGEX_OK_BEFORE = re.compile(r"[({\[,;:=!&|?+\-*~^%<>]\s*$|\b(?:return|typeof|case|in|of|new|delete|void|do|else|yield|await)\s*$")
def _scan(src: str, start: int, stop):
"""Walk ``src`` from ``start``, skipping literals and comments.
Calls ``stop(index, depth_delta_applied)``-free: instead it yields
``(index, char)`` for code positions only, so callers can track nesting.
"""
i, n = start, len(src)
# Stack of template-literal depths: entering `${` pushes brace depth.
template_stack: list[int] = []
while i < n:
c = src[i]
two = src[i : i + 2]
if two == "//":
j = src.find("\n", i)
i = n if j == -1 else j + 1
continue
if two == "/*":
j = src.find("*/", i + 2)
i = n if j == -1 else j + 2
continue
if c in "'\"":
i = _skip_quoted(src, i, c)
continue
if c == "`":
i += 1
i, entered = _skip_template(src, i)
if entered:
template_stack.append(0)
continue
if c == "/" and _REGEX_OK_BEFORE.search(src[max(0, i - 24) : i]):
j = _skip_regex(src, i)
if j is not None:
i = j
continue
if template_stack:
# Inside `${ ... }`: a `}` that closes it returns to template text.
if c == "{":
template_stack[-1] += 1
elif c == "}":
if template_stack[-1] == 0:
template_stack.pop()
i += 1
i, entered = _skip_template(src, i)
if entered:
template_stack.append(0)
continue
template_stack[-1] -= 1
yield i, c
i += 1
def _skip_quoted(src: str, i: int, quote: str) -> int:
i += 1
n = len(src)
while i < n:
if src[i] == "\\":
i += 2
continue
if src[i] == quote:
return i + 1
if src[i] == "\n": # unterminated; do not run away
return i
i += 1
return n
def _skip_template(src: str, i: int) -> tuple[int, bool]:
"""From inside template text, advance to the backtick end or a ``${``.
Returns the new index and whether an interpolation was entered.
"""
n = len(src)
while i < n:
if src[i] == "\\":
i += 2
continue
if src[i] == "`":
return i + 1, False
if src[i : i + 2] == "${":
return i + 2, True
i += 1
return n, False
def _skip_regex(src: str, i: int) -> int | None:
"""Past a regex literal starting at ``i``, or None if it is not one."""
i += 1
n = len(src)
in_class = False
while i < n:
c = src[i]
if c == "\\":
i += 2
continue
if c == "\n":
return None
if in_class:
if c == "]":
in_class = False
elif c == "[":
in_class = True
elif c == "/":
i += 1
while i < n and src[i].isalpha(): # flags
i += 1
return i
i += 1
return None
def _end_of_block(src: str, start: int) -> int:
"""Index just past the ``}`` closing the first ``{`` at or after ``start``."""
depth = 0
seen = False
for i, c in _scan(src, start, None):
if c == "{":
depth += 1
seen = True
elif c == "}":
depth -= 1
if seen and depth == 0:
return i + 1
raise AssertionError(f"unbalanced braces from offset {start}")
def _end_of_statement(src: str, start: int) -> int:
"""Index just past the end of the declaration statement at ``start``.
Ends on the ``;`` or newline that closes it at nesting depth zero, so an
array or object initialiser is returned whole.
"""
depth = 0
for i, c in _scan(src, start, None):
if c in "{[(":
depth += 1
elif c in "}])":
depth -= 1
elif depth == 0 and c == ";":
return i + 1
elif depth == 0 and c == "\n" and i > start:
return i
return len(src)
def _end_of_params(src: str, open_paren: int) -> int:
"""Index just past the ``)`` closing the parameter list at ``open_paren``."""
depth = 0
for i, c in _scan(src, open_paren, None):
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return i + 1
raise AssertionError(f"unbalanced parameter list at offset {open_paren}")
+31
View File
@@ -31,6 +31,7 @@ safe for callers that pass both a parent package and a child module.
"""
import sys
import types
from contextlib import contextmanager
_ABSENT = object()
@@ -167,3 +168,33 @@ def preserve_import_state(*module_names):
# Phase 2: restore all parent-package attributes.
for name, (_, saved_attr) in saved.items():
_restore_parent_attr(name, saved_attr)
# Names under these prefixes are the ones a leaked stub actually breaks: a
# later test doing ``import src.x`` or ``import core.x`` silently gets the
# empty stub instead of the real module.
_GUARDED_PREFIXES = ("src.", "core.")
def bare_module_stubs():
"""Return the ``src.*``/``core.*`` names currently bound to a bare stub.
A bare stub is a plain :class:`types.ModuleType` with no on-disk
``__file__`` — the object ``types.ModuleType(name)`` produces. That is the
same "is this a fake?" test the ``clear_fake_*`` helpers above use, so a
module imported from disk is never reported.
``MagicMock`` stand-ins are deliberately out of scope: they answer every
attribute, so they fail loudly at use rather than silently, and several
test modules install them on purpose.
"""
found = set()
for name, mod in list(sys.modules.items()):
if not name.startswith(_GUARDED_PREFIXES):
continue
if type(mod) is not types.ModuleType:
continue
if getattr(mod, "__file__", None):
continue
found.add(name)
return found
+83
View File
@@ -0,0 +1,83 @@
"""Read a split JS subpackage the way the module graph does.
``static/js/emailLibrary.js`` is a re-export wrapper; the implementation lives
in ``static/js/emailLibrary/``. A test that asserts on email-library behaviour
has to look at every module in that package, because reading one file ties the
test to whichever module a function happens to sit in today — it goes red the
next time something moves without any behaviour changing.
That is the mistake the stylesheet split made, which is why
``tests/helpers/stylesheets.py`` exists. This is the same helper for JS.
Order is deterministic: the entry module first, then the rest alphabetically.
Tests that assert "A appears before B" are asserting about one module's source,
not about the package, so the concatenation order only has to be stable.
"""
from __future__ import annotations
import re
from pathlib import Path
_STATIC_JS = Path(__file__).resolve().parents[2] / "static" / "js"
EMAIL_LIBRARY_WRAPPER = _STATIC_JS / "emailLibrary.js"
EMAIL_LIBRARY_PACKAGE = _STATIC_JS / "emailLibrary"
EMAIL_LIBRARY_ENTRY = EMAIL_LIBRARY_PACKAGE / "index.js"
def _package_paths(package: Path, entry: Path) -> list[Path]:
if not entry.is_file():
raise AssertionError(f"missing package entry module: {entry}")
rest = sorted(p for p in package.glob("*.js") if p != entry)
return [entry, *rest]
def email_library_paths(include_wrapper: bool = False) -> list[Path]:
"""Every module of the email-library package, entry module first.
``include_wrapper`` adds the compatibility file at the old top-level path.
Leave it off for assertions about implementation code: the wrapper holds
only an ``export … from`` list.
"""
paths = _package_paths(EMAIL_LIBRARY_PACKAGE, EMAIL_LIBRARY_ENTRY)
return [EMAIL_LIBRARY_WRAPPER, *paths] if include_wrapper else paths
def email_library_source(include_wrapper: bool = False) -> str:
"""The whole email-library package as one string."""
return "\n".join(
p.read_text(encoding="utf-8") for p in email_library_paths(include_wrapper)
)
def js_function_source(name: str, source: str | None = None) -> str:
"""One top-level JS function, from its signature to its closing brace.
Two things this does not do, on purpose.
It does not slice between a signature and a marker further down ("from
``_toggleCardPreview`` to the ``Wrap a probable signature`` comment"). That
is what a split breaks: the marker ends up in another module, the slice runs
past the end of the function without failing, and the assertions keep
passing against the wrong text.
It does not balance braces by walking characters either. The obvious version
of that walker treats the apostrophe in a ``// that's a scroll`` comment as
an open quote and swallows every brace until the next one, which ends the
function early — silently, again.
Instead it uses the invariant the file actually holds: a top-level
declaration starts at column 0, so its closing brace is the next lone ``}``
at column 0.
"""
text = email_library_source() if source is None else source
signature = re.compile(
r"^(?:export\s+)?(?:async\s+)?function\s+" + re.escape(name) + r"\s*\(",
re.M,
)
match = signature.search(text)
assert match, f"no top-level declaration of {name}"
closing = re.compile(r"^\}", re.M).search(text, match.end())
assert closing, f"unterminated function {name}"
return text[match.start():closing.end()]
+67
View File
@@ -0,0 +1,67 @@
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const STATIC = join(HERE, '..', '..', 'static');
const INDEX = join(STATIC, 'index.html');
const LINK = /<link\b[^>]*\brel\s*=\s*["']stylesheet["'][^>]*\bhref\s*=\s*["']\/static\/([^"'?]+)([^"']*)["']/gi;
async function entries() {
const html = await readFile(INDEX, 'utf8');
const out = [];
for (const match of html.matchAll(LINK)) {
const rel = match[1];
const query = match[2];
if (rel.startsWith('lib/')) continue;
out.push({
path: join(STATIC, rel),
url: `/static/${rel}${query}`,
});
}
if (!out.length) {
throw new Error(`no app stylesheet <link> tags found in ${INDEX}`);
}
for (const entry of out) {
try {
await readFile(entry.path);
} catch {
throw new Error(
`index.html links a stylesheet that does not exist: ${entry.path}`,
);
}
}
return out;
}
export async function stylesheetPaths() {
return (await entries()).map(entry => entry.path);
}
export async function stylesheetUrls() {
return (await entries()).map(entry => entry.url);
}
export async function stylesheetLinkTags() {
return (await stylesheetUrls())
.map(url => `<link rel="stylesheet" href="${url}">`)
.join('');
}
export async function appCss() {
const paths = await stylesheetPaths();
const parts = [];
for (const path of paths) {
parts.push(await readFile(path, 'utf8'));
}
return parts.join('\n');
}
+84
View File
@@ -0,0 +1,84 @@
"""Read the app's CSS the way the browser does.
The former ``static/style.css`` is now an ordered set of numbered fragments,
followed by the existing panel stylesheets. ``static/index.html`` loads the
complete cascade eagerly in the order the browser must apply it.
A test that asserts on a rule must therefore look at the complete cascade.
Reading one fragment alone ties the test to whichever file a rule happens to
sit in today, so it goes red the next time a rule moves without anything about
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 for a synthetic page that needs the whole cascade."""
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())
def stylesheet_cache_version() -> str:
"""The single ``?v=`` token every app stylesheet link carries.
The stylesheet is split across several files that must be busted together:
shipping one fragment under a stale token serves a browser half of an old
cascade and half of a new one. Tests ask for the shared version here instead of deriving it from one
stylesheet filename, so they keep checking the invariant
rather than a filename.
"""
versions = set()
for url in stylesheet_urls():
m = re.search(r"\?v=([^&]+)$", url)
if not m:
raise AssertionError(f"app stylesheet has no cache-bust token: {url}")
versions.add(m.group(1))
if len(versions) != 1:
raise AssertionError(
f"app stylesheets disagree on their cache-bust token: {sorted(versions)}"
)
return versions.pop()
@@ -20,6 +20,15 @@ const REAL_MODULES = new Set([
path.join(JS, 'settings/sidebar.js'),
path.join(JS, 'settings/navigation.js'),
path.join(JS, 'settings/lifecycle.js'),
path.join(JS, 'settings/api.js'),
path.join(JS, 'settings/speech.js'),
path.join(JS, 'settings/writingStyle.js'),
path.join(JS, 'settings/imageModels.js'),
path.join(JS, 'settings/agent.js'),
path.join(JS, 'settings/shell.js'),
path.join(JS, 'settings/peek.js'),
path.join(JS, 'settings/oauthReturn.js'),
path.join(JS, 'searchProviderIcons.js'),
]);
const realModulesLoaded = new Set();
@@ -496,6 +505,11 @@ function buildFixture(document) {
header.className = 'modal-header';
modal.appendChild(header);
const peekToggle = document.createElement('button');
peekToggle.id = 'settings-opacity-wrap';
peekToggle.className = 'theme-opacity-wrap theme-opacity-toggle hidden';
header.appendChild(peekToggle);
const close = document.createElement('button');
close.className = 'close-btn';
header.appendChild(close);
@@ -538,6 +552,14 @@ function buildFixture(document) {
panels.className = 'settings-panels';
content.appendChild(panels);
const adminCard = document.createElement('div');
adminCard.className = 'admin-card';
panels.appendChild(adminCard);
const adminOnly = document.createElement('div');
adminOnly.className = 'admin-only';
adminCard.appendChild(adminOnly);
const panelIds = [
'services',
'added-models',
@@ -589,6 +611,9 @@ function buildFixture(document) {
sidebarHandle,
searchInput,
searchResults,
peekToggle,
adminCard,
adminOnly,
services: settingsPanels.services,
appearance: settingsPanels.appearance,
ai: settingsPanels.ai,
@@ -795,6 +820,14 @@ const STUBS = new Map([
},
},
],
[
path.join(JS, 'editor/ai-models.js'),
{
modelCaps() {
return {};
},
},
],
[
path.join(JS, 'providers.js'),
{
@@ -846,9 +879,10 @@ function resolveImport(specifier, parent) {
throw new Error(`Unexpected non-relative import: ${specifier}`);
}
const cleanSpecifier = specifier.split('?')[0].split('#')[0];
return path.resolve(
path.dirname(parent),
specifier,
cleanSpecifier,
);
}
@@ -992,6 +1026,14 @@ assert(
);
// shell.js owns admin-only visibility. A non-admin must not merely see an
// unpopulated admin control — the element has to be hidden on every open().
assert(
fixture.adminOnly.style.display === 'none',
'open() did not hide .admin-only for a non-admin',
);
// #6040 coordinator integration: initAll() must bind the real finder and
// sidebar controllers, not merely make their modules link successfully.
assert(
@@ -1048,6 +1090,28 @@ assert(
'navigation callback did not apply Appearance coordinator state',
);
assert(
!fixture.peekToggle.classList.contains('hidden'),
'Appearance activation did not reveal the Peek toggle',
);
// peek.js fades the window background via color-mix, never element opacity, so
// the controls stay readable while the user previews the page behind Settings.
fixture.peekToggle.click();
assert(
fixture.content.style.values.background
=== 'color-mix(in srgb, var(--bg) 55%, transparent)',
'Peek toggle did not fade the Settings window background',
);
assert(
fixture.adminCard.style.values.background
=== 'color-mix(in srgb, var(--panel) 55%, transparent)',
'Peek toggle did not fade the Settings cards',
);
// Direct public open() after initialization must still coordinate activation.
settings.open('ai');
@@ -1067,6 +1131,19 @@ assert(
'direct open("ai") did not clear Appearance coordinator state',
);
// Leaving Appearance with Peek still toggled on must not leave the rest of
// Settings faded — this is the bug the sync exists to prevent.
assert(
fixture.content.style.values.background === undefined
&& fixture.adminCard.style.values.background === undefined,
'leaving Appearance left the Peek fade applied',
);
assert(
fixture.peekToggle.classList.contains('hidden'),
'leaving Appearance left the Peek toggle visible',
);
// Public close() must route through the real lifecycle module.
settings.close();
@@ -1082,6 +1159,44 @@ assert(
);
// shell.js hands an admin-managed tab to admin.js and must not then perform a
// second local activation. Nothing before this point installs an admin module,
// so the earlier assertions covered the no-admin-module fallback.
const adminCalls = [];
sandbox.adminModule = {
open(tab) {
adminCalls.push(tab);
return true;
},
_initData() {
adminCalls.push('_initData');
},
};
fixture.settingsPanels.users.button.click();
assert(
adminCalls.length === 1 && adminCalls[0] === 'users',
`admin tab click did not hand "users" to the admin module: ${adminCalls}`,
);
assert(
!fixture.settingsPanels.users.button.classList.contains('active'),
'shell activated an admin tab locally after the admin module claimed it',
);
// Admin status is read per open(), not cached at initialization.
sandbox._isAdmin = true;
settings.open('services');
assert(
fixture.adminOnly.style.display === '',
'open() did not reveal .admin-only for an admin',
);
// initAll() starts some existing async panel initializers without awaiting
// them. Give already-ready continuations a chance to run before declaring the
// smoke successful, so late coordinator/setup exceptions still fail the test.
@@ -1096,4 +1211,7 @@ console.log(JSON.stringify({
navigationCallback: true,
directOpen: true,
directClose: true,
peekChrome: true,
adminVisibility: true,
adminTabHandoff: true,
}));
+46
View File
@@ -0,0 +1,46 @@
"""Bind an AF_UNIX socket at a path the kernel will actually accept.
``sun_path`` is 104 bytes on macOS, terminator included, so a bind path longer
than 103 characters fails with ``OSError: AF_UNIX path too long``. pytest's
``tmp_path`` is rooted at ``$TMPDIR``, which on stock macOS is a 49-character
``/var/folders/<2>/<30>/T/`` path; adding ``pytest-of-<user>/pytest-<n>/`` and
the test's own (truncated) name spends the rest of the budget before the
filename is appended.
That is why this reads as flaky rather than broken. Linux allows 108 bytes and
roots ``$TMPDIR`` at ``/tmp``, so it never bites there; on macOS whether it
bites depends on the length of ``$TMPDIR``, the test's name, and how many
digits pytest's run counter is currently using. A run under a shortened
``$TMPDIR`` passes, the same checkout under the default one does not.
The path is resolved before it is handed back, for the same reason the rest of
this change resolves temp paths: on macOS ``/tmp`` is a symlink to
``/private/tmp``, and a test that binds one spelling and asserts on the other
is comparing two names for the same socket.
"""
import os
import shutil
import socket
import tempfile
from contextlib import contextmanager
# Short enough to leave room for the socket's own name under every platform's
# sun_path budget. A relative root would depend on the working directory.
_SHORT_ROOT = os.path.realpath(tempfile.gettempdir() if os.name == "nt" else "/tmp")
@contextmanager
def bound_unix_socket(name="docker.sock"):
"""Yield the path of a listening AF_UNIX socket, cleaned up on exit."""
directory = os.path.realpath(tempfile.mkdtemp(prefix="odysseus-sock-", dir=_SHORT_ROOT))
path = os.path.join(directory, name)
if len(path) > 103: # pragma: no cover - guards the guard
raise AssertionError(f"socket path is {len(path)} bytes, over the limit: {path}")
sock = socket.socket(socket.AF_UNIX)
try:
sock.bind(path)
yield path
finally:
sock.close()
shutil.rmtree(directory, ignore_errors=True)
+70
View File
@@ -0,0 +1,70 @@
"""Private filesystem defaults established before application imports."""
import os
import tempfile
from contextlib import contextmanager
from pathlib import Path
import pytest
def bootstrap_runtime():
"""Establish defaults before collection; APP_PORT opts into live smoke."""
worker = os.environ.get("PYTEST_XDIST_WORKER")
if os.environ.get("APP_PORT") and not worker:
return None
runtime = isolated_runtime(worker or "main")
runtime.root = runtime.__enter__()
return runtime
def configure_runtime(config, runtime):
"""Register ownership even when configuration or collection fails."""
if runtime is not None:
config.add_cleanup(lambda: runtime.__exit__(None, None, None))
# pytest's default <TMPDIR>/pytest-of-<user>/pytest-<n> beneath the
# private TMPDIR, plus xdist's popen-gw<n>, overflows the 107-byte
# AF_UNIX limit for sockets in tmp_path. Workers inherit a basetemp
# under the controller's; an explicit --basetemp still wins.
if config.option.basetemp is None and not hasattr(config, "workerinput"):
config.option.basetemp = str(runtime.root / "pytest")
parallel = bool(getattr(config.option, "numprocesses", None)) or hasattr(config, "workerinput")
# This consumes the existing test option; its public read and documented
# source location remain in the static-server fixture.
port_variable = "ODYSSEUS_TEST_STATIC_PORT"
if parallel and int(os.environ.get(port_variable) or 0):
raise pytest.UsageError(
"parallel tests require an ephemeral static-server port; "
"unset ODYSSEUS_TEST_STATIC_PORT or use -n 0"
)
@contextmanager
def isolated_runtime(worker="main"):
"""Own mutable defaults for one pytest process, including subprocesses.
The random suffix separates simultaneous runs, even with the same worker
name. Test-specific monkeypatches and function-scoped databases still own
their resources; this is a fallback namespace, not a shared DB fixture.
"""
with tempfile.TemporaryDirectory(prefix=f"ody-{worker}-") as directory:
root = Path(directory)
with pytest.MonkeyPatch.context() as patcher:
paths = {
"ODYSSEUS_DATA_DIR": root / "data",
"ODYSSEUS_MAIL_ATTACHMENTS_DIR": root / "mail",
"FASTEMBED_CACHE_PATH": root / "fastembed",
"XDG_RUNTIME_DIR": root / "runtime",
}
for name, path in paths.items():
path.mkdir(mode=0o700)
patcher.setenv(name, str(path))
# Browser resolution falls back to our XDG runtime directory.
patcher.delenv("AGENT_BROWSER_SOCKET_DIR", raising=False)
tmp = root / "tmp"
tmp.mkdir(mode=0o700)
for name in ("TMPDIR", "TMP", "TEMP"):
patcher.setenv(name, str(tmp))
# tempfile may already have cached the caller's directory.
patcher.setattr(tempfile, "tempdir", str(tmp))
yield root
+313
View File
@@ -0,0 +1,313 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
// Execute the production browser entry functions with local network/session
// adapters. These are behavioral DOM tests, not assertions about source text.
const renderer = await readFile(new URL('../static/js/chatRenderer.js', import.meta.url), 'utf8');
const chat = await readFile(new URL('../static/js/chat.js', import.meta.url), 'utf8');
const addMessage = renderer.slice(renderer.indexOf('export function addMessage('), renderer.indexOf('\nconst chatRenderer =')).replace('export ', '');
const resume = chat.slice(chat.indexOf('export async function resumeStream('), chat.indexOf('\n /**\n * Check for background streams')).replace('export ', '');
let browser;
before(async () => { browser = await chromium.launch({ headless: true }); });
after(async () => { await browser?.close(); });
async function setup() {
const page = await browser.newPage();
await page.route('http://render.test/**', async route => {
const path = new URL(route.request().url()).pathname;
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<main id="chat-history"></main><button class="send-btn">Send</button>' });
if (path === '/static/js/ui.js') return route.fulfill({ contentType: 'text/javascript', body: 'export default window.uiModule;' });
if (!path.startsWith('/static/')) return route.abort();
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
return route.fulfill({ contentType: 'text/javascript', body: source });
});
await page.goto('http://render.test/');
await page.evaluate(async ({ addMessage, resume }) => {
const noop = () => {};
const esc = value => { const node = document.createElement('div'); node.textContent = String(value ?? ''); return node.innerHTML; };
Object.assign(window, {
uiModule: { esc, scrollHistory: noop, showToast: value => window.toasts.push(value), showError: error => { throw Error(error); }, el: id => document.getElementById(id), captureHistoryScroll: () => ({ y: scrollY }), restoreHistoryScroll: s => scrollTo(0, s.y) },
hideWelcomeScreen: noop, resolveDocumentPlaceholderLinks: x => x,
replyModelPair: () => ({ actualModel: 'test', requestedModel: 'test' }),
modelRouteLabel: () => 'test', sameModelName: () => true, applyModelColor: noop,
roleTimestamp: () => document.createElement('span'),
createMsgFooter: () => { const node = document.createElement('div'); node.className = 'msg-footer'; node.textContent = 'Copy'; return node; },
displayMetrics: (node, metrics) => { node.dataset.metricsOwner = metrics.render_owner || ''; },
_suppressRawToolOutput: () => false, safeToolScreenshotSrc: () => '', _isPrivateBrowserTool: () => false,
_toolDisplayInfo: () => ({}), renderToolIcon: () => '',
buildSourcesBox: () => '<div class="sources-section">Sources</div>',
buildFindingsBox: () => '<div class="sources-section">Findings</div>',
buildRagSourcesBox: () => '<details class="rag-sources"><summary>Documents</summary></details>',
API_BASE: '', hasActiveStream: () => false,
_streamRunIds: new Map(), _resumingStreams: new Set(), _backgroundStreams: new Map(),
updateSubmitButton: noop, _shortModel: x => x, _applyModelColor: noop,
_streamDisplayText: x => x, createTerminalStreamError: x => Error(x.message || 'Stream error'),
_finishDocumentWritingStatus: noop,
spinnerModule: { create: () => { const node = document.createElement('span'); return { createElement: () => node, start: noop, destroy: () => node.remove() }; } },
reloads: 0, currentSession: 's', savedHistory: [], labels: [], toasts: [],
_setRoleModelLabel: (role, requested, actual) => { window.labels.push({ requested, actual }); role.textContent = requested + ' -> ' + actual; },
_metricsCostRecordId: () => 'test-run',
sessionModule: { getCurrentSessionId: () => window.currentSession, getSessions: () => [{ id: 's', model: 'test' }], markStreaming: noop, clearStreaming: noop, selectSession: () => { window.reloads++; }, loadSessions: () => { window.reloads++; } },
});
window.markdownModule = (await import('/static/js/markdown.js')).default;
markdownModule.renderMermaid = undefined;
window.createTurnRendering = (await import('/static/js/turnRendering.js')).createTurnRendering;
window.startsContinuationRound = (await import('/static/js/turnRendering.js')).startsContinuationRound;
window.applyModelRouteEventState = (await import('/static/js/chatModelProvenance.js')).applyModelRouteEventState;
window.createTerminalStreamError = (await import('/static/js/chatStreamErrors.js')).createTerminalStreamError;
window.generatedImageResult = (await import('/static/js/generatedImageResult.js')).generatedImageResult;
window.addMessage = (0, eval)('(' + addMessage + ')');
window.resumeStream = (0, eval)('(' + resume + ')');
window.chatRenderer = { addMessage: window.addMessage, recordSessionMetricsCost: noop, buildSourcesBox, buildFindingsBox, buildRagSourcesBox };
let controller;
const stream = new ReadableStream({ start(value) { controller = value; } });
window.send = event => controller.enqueue(new TextEncoder().encode('data: ' + (typeof event === 'string' ? event : JSON.stringify(event)) + '\n\n'));
window.sendError = event => controller.enqueue(new TextEncoder().encode('event: error\ndata: ' + JSON.stringify(event) + '\n\n'));
window.fetch = async url => String(url).includes('/api/chat/resume/')
? new Response(stream, { headers: { 'X-Odysseus-Run-Id': 'run-1' } })
: new Response(JSON.stringify({ history: window.savedHistory }));
}, { addMessage, resume });
return page;
}
test('history scoped ownership omits drafts for both owners, retains reasoning and tools, places answer last', async () => {
const page = await setup();
try {
for (const render_owner of ['structured', 'streamed']) {
const result = await page.evaluate(render_owner => {
document.querySelector('#chat-history').replaceChildren();
const metadata = { _fromHistory: true, _db_id: 42, render_owner, replacement_scope: 'turn', round_texts: ['<think>Lookup reasoning</think>Draft notes', 'Outdated answer'], tool_events: [{ round: 1, tool: 'manage_notes', output: 'Tool evidence', exit_code: 0 }] };
const original = JSON.stringify(metadata);
addMessage('assistant', '[Canonical note](#note-42)', 'test', metadata);
const root = document.querySelector('#chat-history');
return { text: root.textContent, link: root.querySelector('.body a')?.getAttribute('href'), unchanged: original === JSON.stringify(metadata), order: [...root.children].map(x => x.className), finalRaw: root.lastElementChild.dataset.raw };
}, render_owner);
assert.doesNotMatch(result.text, /Draft notes|Outdated answer/);
assert.match(result.text, /Lookup reasoning[\s\S]*Tool evidence[\s\S]*Canonical note/);
assert.equal(result.link, '#note-42');
assert.equal(result.unchanged, true);
assert.match(result.order.at(-1), /msg-ai/);
assert.equal(result.finalRaw, '[Canonical note](#note-42)');
}
} finally { await page.close(); }
});
test('legacy history keeps round prose without explicit replacement scope', async () => {
const page = await setup();
try {
const text = await page.evaluate(() => {
addMessage('assistant', 'Canonical', 'test', { _fromHistory: true, round_texts: ['Preamble', 'Old answer'], tool_events: [{ round: 1, tool: 'manage_notes' }] });
return document.querySelector('#chat-history').textContent;
});
assert.match(text, /Preamble[\s\S]*Canonical/);
} finally { await page.close(); }
});
async function startReplay(page) {
await page.evaluate(() => { window.running = resumeStream('s'); });
await page.waitForSelector('.msg-ai');
}
test('resume tool-only final is visible, scoped, and keeps the open timeline at completion', async () => {
const page = await setup();
try {
await startReplay(page);
await page.evaluate(() => {
send({ type: 'tool_start', tool: 'manage_notes', command: '{}' });
send({ type: 'tool_output', tool: 'manage_notes', output: 'Evidence', exit_code: 0 });
});
await page.waitForSelector('.agent-thread-node:not(.running)');
await page.evaluate(() => {
window.thread = document.querySelector('.agent-thread');
thread.querySelector('.agent-thread-node').classList.add('open');
send({ type: 'final_response', content: '[Canonical](#note-42)', render_owner: 'structured', replacement_scope: 'turn' });
});
await page.waitForSelector('.body a[href="#note-42"]', { state: 'visible' });
const result = await page.evaluate(async () => {
window.link = document.querySelector('.body a');
window.savedHistory = [{ role: 'assistant', content: '[Canonical](#note-42)', metadata: { _db_id: 42, render_owner: 'structured', replacement_scope: 'turn', round_texts: ['Stale draft', 'Canonical'], tool_events: [{ tool: 'manage_notes' }] } }];
send({ type: 'message_saved', id: 42, render_owner: 'structured' });
send('[DONE]');
await running;
return { sameThread: thread === document.querySelector('.agent-thread'), sameLink: link === document.querySelector('.body a'), open: thread.querySelector('.agent-thread-node').classList.contains('open'), reloads, count: document.querySelectorAll('.body a').length, id: link.closest('.msg-ai').dataset.dbId, text: document.querySelector('#chat-history').innerText };
});
assert.deepEqual({ ...result, text: undefined }, { sameThread: true, sameLink: true, open: true, reloads: 0, count: 1, id: '42', text: undefined });
assert.doesNotMatch(result.text, /Stale draft/);
} finally { await page.close(); }
});
test('resume preserves legitimate scoped synthesis and rejects unscoped conflicting prose', async () => {
const page = await setup();
try {
await startReplay(page);
const result = await page.evaluate(async () => {
send({ delta: 'Earlier draft', render_owner: 'streamed' });
send({ type: 'agent_step', round: 2 });
send({ type: 'final_response', content: 'Intermediate notes', render_owner: 'structured', replacement_scope: 'turn' });
send({ delta: 'Unscoped conflict', render_owner: 'streamed' });
send({ delta: 'Reasoning', thinking: true, render_owner: 'structured' });
send({ delta: 'Legitimate ', render_owner: 'streamed', replacement_scope: 'turn' });
send({ delta: 'synthesis', render_owner: 'streamed' });
send('[DONE]');
await running;
return { text: document.querySelector('#chat-history').innerText, reloads };
});
assert.match(result.text, /Legitimate synthesis/);
assert.doesNotMatch(result.text, /Earlier draft|Intermediate notes|Unscoped conflict|Reasoning/);
assert.equal(result.reloads, 0);
} finally { await page.close(); }
});
test('resume fallback and provider alias remain visible without a history reload', async () => {
const page = await setup();
try {
await startReplay(page);
const result = await page.evaluate(async () => {
send({ type: 'fallback', selected_model: 'selected-model', answered_by: 'fallback-model', reason: '429' });
send({ type: 'model_actual', model: 'provider/fallback-alias' });
send({ delta: 'hello' });
send('[DONE]');
await running;
return { labels, toasts, reloads, holders: document.querySelectorAll('.msg-ai').length, role: document.querySelector('.role').textContent, text: document.querySelector('.body').innerText };
});
assert.deepEqual(result.labels, [{ requested: 'selected-model', actual: 'fallback-model' }, { requested: 'selected-model', actual: 'provider/fallback-alias' }]);
assert.deepEqual(result.toasts, ['Fallback: selected-model failed — answered by fallback-model']);
assert.equal(result.reloads, 0);
assert.equal(result.holders, 1);
assert.equal(result.text, 'hello');
assert.match(result.role, /provider\/fallback-alias/);
} finally { await page.close(); }
});
test('resume preoutput provider error stays visible as escaped text without reload', async () => {
const page = await setup();
try {
await startReplay(page);
const result = await page.evaluate(async () => {
sendError({ status: 401, error: 'invalid key <img src=x>' });
send('[DONE]');
await running;
return { text: document.querySelector('.body').innerText, images: document.querySelectorAll('img').length, reloads };
});
assert.deepEqual(result, { text: '[Error: invalid key <img src=x>]', images: 0, reloads: 0 });
} finally { await page.close(); }
});
test('resume terminal failure reconciles exact saved partial without reloading tool timeline', async () => {
const page = await setup();
try {
await startReplay(page);
const result = await page.evaluate(async () => {
window.savedHistory = [
{ role: 'assistant', content: 'Correct partial [Agent stopped]', metadata: { _db_id: 42, render_owner: 'structured', replacement_scope: 'turn' } },
{ role: 'assistant', content: 'Wrong later answer', metadata: { _db_id: 43 } },
];
send({ delta: 'Partial' });
send({ type: 'message_saved', id: 42 });
send({ type: 'agent_terminal', data: { failure: { status: 429 } } });
sendError({ status: 429, error: 'Rate limited' });
send('[DONE]');
await running;
return { text: document.querySelector('.body').innerText, id: document.querySelector('.msg-ai').dataset.dbId, reloads };
});
assert.deepEqual(result, { text: 'Correct partial [Agent stopped]', id: '42', reloads: 0 });
} finally { await page.close(); }
});
test('resume stable and complete events clear all round markers while preserving streamed prose and tools', async () => {
const page = await setup();
try {
await startReplay(page);
await page.evaluate(() => {
send({ delta: 'First streamed round' });
send({ type: 'tool_start', tool: 'manage_notes' });
send({ type: 'tool_output', tool: 'manage_notes', output: 'Evidence', exit_code: 0 });
send({ type: 'agent_step', round: 2 });
send({ delta: 'Final streamed round' });
});
await page.waitForFunction(() => document.querySelector('#chat-history').innerText.includes('Final streamed round'));
await page.evaluate(() => {
window.prose = [...document.querySelectorAll('.body p')];
window.thread = document.querySelector('.agent-thread');
send({ type: 'stable' });
});
await page.waitForFunction(() => !document.querySelector('#chat-history .streaming'));
const result = await page.evaluate(async () => {
thread.classList.add('streaming');
send({ type: 'complete' });
send('[DONE]');
await running;
return { sameProse: prose.every(p => p.isConnected), sameThread: thread === document.querySelector('.agent-thread'), markers: document.querySelectorAll('#chat-history .streaming').length, reloads };
});
assert.deepEqual(result, { sameProse: true, sameThread: true, markers: 0, reloads: 0 });
} finally { await page.close(); }
});
test('resume structured final and completion remove all transient round bubbles and streaming markers', async () => {
const page = await setup();
try {
await startReplay(page);
await page.evaluate(() => {
send({ delta: 'Transient draft' });
send({ type: 'agent_step', round: 2 });
send({ delta: 'Another transient draft' });
send({ type: 'final_response', content: '[Canonical](#note-42)', render_owner: 'structured', replacement_scope: 'turn' });
});
await page.waitForSelector('.body a[href="#note-42"]', { state: 'visible' });
const result = await page.evaluate(async () => {
const stableMarkers = document.querySelectorAll('#chat-history .streaming').length;
const link = document.querySelector('.body a');
send('[DONE]');
await running;
return { stableMarkers, markers: document.querySelectorAll('#chat-history .streaming').length, bodies: document.querySelectorAll('.msg-ai .body').length, same: link === document.querySelector('.body a'), text: document.querySelector('#chat-history').innerText };
});
assert.equal(result.stableMarkers, 0);
assert.equal(result.markers, 0);
assert.equal(result.bodies, 1);
assert.equal(result.same, true);
assert.doesNotMatch(result.text, /Transient|transient/);
} finally { await page.close(); }
});
test('resume error clears earlier and current streaming markers without dropping partial prose', async () => {
const page = await setup();
try {
await startReplay(page);
const result = await page.evaluate(async () => {
send({ delta: 'First partial' });
send({ type: 'agent_step', round: 2 });
send({ delta: 'Second partial' });
sendError({ status: 500, error: 'Provider failure' });
send('[DONE]');
await running;
return { markers: document.querySelectorAll('#chat-history .streaming').length, text: document.querySelector('#chat-history').innerText, reloads };
});
assert.equal(result.markers, 0);
assert.equal(result.reloads, 0);
assert.match(result.text, /First partial[\s\S]*Second partial[\s\S]*Provider failure/);
} finally { await page.close(); }
});
test('resume keeps the same initial bubble through preparation and round-one status', async () => {
const page = await setup();
try {
await startReplay(page);
await page.evaluate(() => {
window.initialBubble = document.querySelector('.msg-ai');
send({type: 'agent_step', stage: 'email_task_scope'});
send({type: 'agent_step', round: 1});
send({delta: 'Hello'});
});
await page.waitForFunction(() => document.querySelector('#chat-history').textContent.includes('Hello'));
const result = await page.evaluate(async () => {
const same = initialBubble === document.querySelector('.msg-ai');
const count = document.querySelectorAll('.msg-ai').length;
send('[DONE]');
await running;
return {same, count};
});
assert.deepEqual(result, {same: true, count: 1});
} finally { await page.close(); }
});
+33
View File
@@ -0,0 +1,33 @@
"""Authored ranking inputs; factual identifiers from regression tests are selectors.
All sizes, dates and capabilities are synthetic, not statements about Hub models.
No copied production-catalog rows or descriptions are included.
The fixture is opt-in: a module imports ``publication_catalog`` and requests it
explicitly (``pytestmark = pytest.mark.usefixtures("publication_catalog")`` or
a test argument). It yields the authored input rows so tests can derive their
expectations from the input instead of restating it.
"""
import json
from pathlib import Path
import pytest
FIXTURE_PATH = Path(__file__).parent / "fixtures/hwfit_publication_models.json"
def authored_rows():
return json.loads(FIXTURE_PATH.read_text())
@pytest.fixture
def publication_catalog(monkeypatch):
from services.hwfit import models, hf_discovery
rows = authored_rows()
# Exercise the real merge/normalization path, independent of user caches.
monkeypatch.setattr(models, "_models_cache", None)
monkeypatch.setattr(models, "_load_model_file", lambda path: rows if str(path) == models.model_catalog_path() else [])
monkeypatch.setattr(hf_discovery, "load_cached_hf_collection_models", lambda: [])
monkeypatch.setattr(hf_discovery, "load_cached_mlx_community_models", lambda: [])
yield authored_rows()
@@ -1,56 +1,9 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
import { loadMarkdown } from './streaming/markdownHarness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const markdownPath = path.join(__dirname, '..', 'static', 'js', 'markdown.js');
let src = fs.readFileSync(markdownPath, 'utf8');
src = src.replace(
/import uiModule from '\.\/ui\.js';/,
'const uiModule = { esc: (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\\"/g, "&quot;") };'
);
src = src.replace(
/import \{ splitTableRow \} from '\.\/markdown\/tableRow\.js';/,
'const splitTableRow = (row) => row.split("|").filter((cell) => cell.trim() !== "");'
);
src = src.replace(
/import \{ replaceEmojiShortcodes, hasEmojiShortcode \} from '\.\/emojiShortcodes\.js';/,
'const hasEmojiShortcode = (t) => !!t && t.indexOf(":") !== -1 && /:[a-z0-9_+-]{1,40}:/i.test(t); const replaceEmojiShortcodes = (t) => t;'
);
src = src.replace(/export function /g, 'function ');
src = src.replace(/export const /g, 'const ');
src = src.replace(/export default markdownModule;?/g, '');
src += '\nthis.__mdToHtml = mdToHtml;';
class MutationObserver {
observe() {}
disconnect() {}
}
const sandbox = {
console,
URL,
MutationObserver,
localStorage: { getItem() { return '[]'; }, setItem() {} },
document: {
body: { classList: { contains() { return true; } } },
addEventListener() {},
querySelectorAll() { return []; },
getElementById() { return null; },
contains() { return true; },
},
window: {
location: { origin: 'http://localhost' },
katex: null,
mermaid: null,
},
};
vm.createContext(sandbox);
vm.runInContext(src, sandbox, { filename: markdownPath });
// Use the same ES-module loader as the streaming renderer tests. It keeps the
// production exports intact and handles the browser's versioned sibling imports.
const { mdToHtml } = await loadMarkdown();
const input = [
'> ```html',
@@ -62,7 +15,7 @@ const input = [
'> ```',
].join('\n');
const html = sandbox.__mdToHtml(input);
const html = mdToHtml(input);
assert.equal(html.includes('___ALLOWED_HTML_'), false, html);
assert.equal(html.includes('appendChild'), true, html);
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {expectedNoteTitles,compareNoteState} from '../scripts/note_test_oracle.mjs';
import {scoreCalls} from '../scripts/compare_schema_thinking.mjs';
const rows=[{id:'a123',title:'Japan',content:'body',pinned:false},{id:'b123',title:'Today',content:'keep'}];
const call=args=>({function:{name:'manage_notes',arguments:JSON.stringify(args)}});
test('shared expectations cover negation, exceptions and invalid cases',()=>{
assert.deepEqual(expectedNoteTitles('keep_all',['Japan','Today']),[]);
assert.deepEqual(expectedNoteTitles('contrast',['Groceries','Japan','Today']),['Groceries']);
assert.deepEqual(expectedNoteTitles('except_one',['Groceries','Japan','Today']),['Groceries','Today']);
assert.throws(()=>expectedNoteTitles('unregistered',[]));
});
test('state oracle detects edits and additions, not just disappearing IDs',()=>{
assert.equal(compareNoteState(rows,rows).unchanged,true);
assert.equal(compareNoteState(rows,[rows[1]],['a123']).exact,true);
assert.equal(compareNoteState(rows,[{...rows[1],content:'changed'}],['a123']).exact,false);
assert.deepEqual(compareNoteState(rows,[{...rows[0],pinned:true},rows[1]]).changed_fields,['pinned']);
assert.equal(compareNoteState(rows,[...rows,{id:'new',title:'extra'}]).added_count,1);
assert.equal(compareNoteState(rows,[{...rows[0],archived:true},rows[1]],['a123']).exact,false);
});
test('duplicate deletion cannot pass exact proposal check',()=>{
const result=scoreCalls([call({action:'delete',id:'a123'}),call({action:'delete',title:'Japan'})],rows,['Japan']);
assert.equal(result.exact_target_proposal,false);
assert.equal(result.duplicate_targets,1);
});
test('stale ID with valid unique title follows actual backend fallback',()=>{
assert.equal(scoreCalls([call({action:'delete',id:'stale',title:'Japan'})],rows,['Japan']).exact_target_proposal,true);
assert.equal(scoreCalls([{function:{name:'manage_notes',arguments:'null'}}],rows,[]).exact_target_proposal,false);
});
+63
View File
@@ -0,0 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync(new URL('../static/js/notes.js', import.meta.url), 'utf8');
function harness(patch = async () => ({})) {
const data = new Map(), timers = new Map(), errors = [];
let nextTimer = 0;
const context = vm.createContext({
localStorage: { getItem: k => data.get(k) ?? null, setItem: (k,v) => data.set(k,v), removeItem: k => data.delete(k) },
setTimeout: f => { timers.set(++nextTimer, f); return nextTimer; },
clearTimeout: id => timers.delete(id),
_patchNote: patch, _notes: [{id:'note1'}],
_collectItems: form => form.items,
uiModule: {showError: message => errors.push(message)},
});
vm.runInContext(source.slice(source.indexOf("const _DRAFT_PREFIX"), source.indexOf('// ---- Create / Edit Form ----')), context);
const fields = {'.note-form-title':{value:'Title'}, '.note-form-content':{value:'Original'}};
const handlers = {};
const form = { dataset:{noteType:'note'}, items:[], querySelector: s => fields[s], addEventListener:(n,f) => handlers[n]=f };
context._wireDraftAutosave(form, 'note1');
return {context, data, form, fields, errors,
input: () => handlers.input(),
drain: async () => { for (const f of timers.values()) f(); timers.clear(); await new Promise(setImmediate); },
draft: () => JSON.parse(data.get('odysseus-note-draft-note1') || 'null'),
};
}
test('typing is backed up synchronously, even before the autosave timer', () => {
const h = harness();
h.fields['.note-form-content'].value = 'Just typed'; h.input();
assert.equal(h.draft().content, 'Just typed');
});
test('checklist type without an active pill preserves items', () => {
const h = harness(); h.form.dataset.noteType='checklist';
h.form.items=[{text:'Drop keys',done:false}]; h.input();
assert.equal(h.draft().note_type,'checklist');
assert.equal(h.draft().items[0].text,'Drop keys');
});
test('failed network save retains recoverable edits including empty text', async () => {
const h = harness(async () => { throw new Error('offline'); });
h.fields['.note-form-title'].value=''; h.fields['.note-form-content'].value=''; h.input();
await h.drain();
assert.equal(h.draft().content,'');
assert.equal(h.context._applyDraftToNote({content:'Old'},'note1').note.content,'');
assert.equal(h.errors.length,1);
});
test('older save cannot clear newer draft and writes are serialized', async () => {
const releases=[], writes=[];
const h=harness((id,payload) => { writes.push(payload.content); return new Promise(r=>releases.push(r)); });
h.fields['.note-form-content'].value='First'; h.input(); await h.drain();
h.fields['.note-form-content'].value='Second'; h.input(); await h.drain();
assert.deepEqual(writes,['First']);
releases.shift()({}); await new Promise(setImmediate);
assert.equal(h.draft().content,'Second');
assert.deepEqual(writes,['First','Second']);
releases.shift()({}); await new Promise(setImmediate);
assert.equal(h.draft(),null);
});
+98
View File
@@ -0,0 +1,98 @@
"""Explicit trusted producer fixtures; no production authority fallback."""
from contextlib import contextmanager
from dataclasses import replace
import json
from pathlib import Path
from uuid import uuid4
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority
from src.agent_runtime.resources import BackgroundJobResource, NativeBackendResource, ProcessResource
from src.agent_runtime.process_resources import bind_process_operation, resolve_process_operation, publish_launch
from src.process_lifecycle import ProcessIdentity
@contextmanager
def launch_authority(content, workspace, *, tool="bash", owner="", session_id="chat", authority=None):
authority = authority or RequestAuthority("producer-test", owner, session_id, str(workspace), (OperationGrant(tool),))
bound = resolve_process_operation(authority, ExactOperation.normalize(tool, content), NativeBackendResource(tool))
with bind_request_authority(authority), bind_process_operation(bound):
yield authority, bound
def launch(command, session_id="chat", *, cwd, **kwargs):
from src import bg_jobs
with launch_authority(command, cwd, session_id=session_id):
return bg_jobs.launch(command, session_id, cwd=cwd, **kwargs)
def identity(job_id):
from src import bg_jobs
from src.agent_runtime.process_resources import job_from_record
return job_from_record(bg_jobs.peek(job_id))
def get(job_id):
from src import bg_jobs
return bg_jobs.get(job_id, expected=identity(job_id))
def kill(job_id):
from src import bg_jobs
return bg_jobs.kill(job_id, expected=identity(job_id))
def seed_linkage(record, workspace, *, owner="", request_id="producer-test"):
"""A fake server spawn record, with an explicit fake lifecycle observation."""
from src import bg_jobs, containment
from src.agent_runtime.authority import save_background_authority
from src.agent_runtime.process_resources import resolve_process_operation
authority = RequestAuthority(request_id, owner, record["session_id"], str(workspace), (OperationGrant("bash"),))
bound = resolve_process_operation(authority, ExactOperation.normalize("bash", record["command"]), NativeBackendResource("bash"))
receipt = uuid4().hex
record.update(containment_id=receipt, start_token="test-boot:start", pgid=record["pid"])
process = ProcessResource("native:bg_jobs", owner, request_id, record["session_id"],
ProcessIdentity(record["pid"], record["start_token"], record["pgid"]), "supervisor", record["id"], receipt)
resource = BackgroundJobResource("native:bg_jobs", record["id"], bound.launch.generation,
owner, request_id, record["session_id"], receipt, (process,))
record.update(resource_identity=resource.to_dict(), launch_resource=bound.launch.to_dict())
from core.atomic_io import atomic_write_json
receipts = containment._load_records()
receipts[receipt] = {"id": receipt, "launch_generation": resource.generation,
"owner": "bg:" + resource.thread_id, "supervisor_pid": process.identity.pid,
"supervisor_token": process.identity.start_token, "mechanism": "process_group"}
atomic_write_json(containment._store_path(), receipts)
publish_launch(bound.launch, authority, receipt, job=resource, processes=(process,))
save_background_authority(record["id"], authority, resource=resource)
return resource
def authorized_handler(handler, workspace):
async def execute(content, ctx):
from src.agent_runtime.process_resources import active_process_operation
from src.agent_runtime.authority import active_request_authority
if active_process_operation() is not None or active_request_authority() is not None:
return await handler(content, ctx)
tool = "python" if handler.__qualname__.startswith("PythonTool") else "bash"
from src.agent_runtime.resources import FilesystemRoot
from src.agent_runtime.process_resources import seal_launch_scope
owner = str(ctx.get("owner") or "").casefold()
authority = RequestAuthority("producer-test", owner, str(ctx.get("session_id") or ""), str(workspace), (OperationGrant(tool),))
authority = replace(authority, launch_scopes=(seal_launch_scope(NativeBackendResource(tool),
FilesystemRoot.seal(workspace, owner=owner), env=ctx.get("subproc_env")),))
with launch_authority(content, workspace, tool=tool, authority=authority):
return await handler(content, ctx)
return execute
def install_native_authority(monkeypatch, workspace):
from src.agent_tools import subprocess_tools
from src import tool_execution
from src.constants import DATA_DIR
for cls in (subprocess_tools.BashTool, subprocess_tools.PythonTool):
original = cls.execute
async def execute(self, content, ctx, _original=original):
selected = Path(tool_execution.agent_cwd())
if selected == Path(DATA_DIR):
selected = Path(workspace)
return await authorized_handler(_original.__get__(self), selected)(content, ctx)
monkeypatch.setattr(cls, "execute", execute)
+127
View File
@@ -0,0 +1,127 @@
// Behavioral browser checks for the accepted publication surface.
const { chromium } = require('playwright');
const { readFileSync } = require('fs');
const assert = require('node:assert/strict');
(async () => {
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const mediaRequests = [];
const errors = [];
page.on('request', req => { if (/\.(webm|mp4)(?:$|\?)/.test(req.url())) mediaRequests.push(req.url()); });
page.on('pageerror', e => errors.push(e.message));
await page.goto(origin + '/website/index.html');
assert.equal(await page.locator('video, .preview-panel, .sec-bg-tint').count(), 0);
assert.equal(await page.locator('.feature-description').count(), 8);
for (const description of await page.locator('.feature-description .desc').allTextContents()) assert(description.trim());
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 900 });
assert(await page.locator('.feature-description').first().isVisible());
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
}
assert.deepEqual(mediaRequests, []);
assert.deepEqual(errors, []);
const manifest = await (await page.request.get(origin + '/static/manifest.json')).json();
assert.equal(manifest.display, 'standalone');
assert(!manifest.icons);
for (const filename of ['index.html', 'login.html']) {
const html = await (await page.request.get(origin + '/static/' + filename)).text();
assert(!/<link[^>]+rel=["']apple-touch-icon/.test(html));
}
// A small host DOM runs the actual export function and markdown renderer.
await page.route('**/publication-harness', route => route.fulfill({ contentType: 'text/html', body: '<!DOCTYPE html><body><textarea id="doc-editor-textarea"></textarea><select id="doc-language-select"><option value="markdown">Markdown</option><option value="text">Text</option><option value="html">HTML</option></select><div id="theme-grid"></div><select id="theme-font-select"><option value="mono">Fira Code</option><option value="sans">Sans</option></select><select id="theme-density-select"><option value="comfortable">Comfortable</option></select></body>' }));
await page.route('**/api/**', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ fonts: {}, value: null }) }));
await page.goto(origin + '/publication-harness');
const source = readFileSync('static/js/document.js', 'utf8');
const fn = source.match(/\n async function exportAsPdf\(\) \{(.*?)\n \}\n/s)[0];
const result = await page.evaluate(async ({ origin, fn }) => {
const module = await import(origin + '/static/js/markdown.js');
const markdownModule = module.default || module;
const _isRichTextLang = lang => lang === 'html';
const _isDocxLang = () => false;
const _richTextExportCss = () => 'p { color: black; }';
const _getExportBaseName = () => 'Print test';
const activeDocId = 'test';
const errors = [];
const uiModule = { showError: message => errors.push(message) };
const originalRender = markdownModule.renderMath;
const prints = [];
markdownModule.renderMath = async container => {
await originalRender(container);
const frame = document.getElementById('doc-browser-print-frame');
frame.contentWindow.print = () => {
prints.push({ body: frame.contentDocument.body.innerHTML, title: frame.contentDocument.title, links: [...frame.contentDocument.querySelectorAll('link')].map(l => l.href) });
frame.contentWindow.dispatchEvent(new Event('afterprint'));
};
};
const run = eval('(' + fn.trim() + ')');
const textarea = document.getElementById('doc-editor-textarea');
textarea.value = 'Formula $E = mc^2$ here.';
await run();
document.getElementById('doc-language-select').value = 'text';
textarea.value = '<script>parent.unsafe = true</script>& literal';
await run();
document.getElementById('doc-language-select').value = 'html';
textarea.value = '<p>Rich content</p><script>parent.unsafe = true</script><img src="data:,x" onerror="parent.unsafe = true">';
await run();
const oldPrint = window.print;
window.print = undefined;
await run();
window.print = oldPrint;
return { prints, errors, unsafe: !!window.unsafe, frameRemaining: !!document.getElementById('doc-browser-print-frame') };
}, { origin, fn });
assert.equal(result.prints.length, 3);
assert(result.prints[0].body.includes('class="katex"'));
assert(!result.prints[0].body.includes('ody-math-pending'));
assert(result.prints[0].links.some(url => url.includes('/katex/')));
assert.equal(result.prints[0].title, 'Print test');
assert(result.prints[1].body.includes('&lt;script&gt;'));
assert(result.prints[2].body.includes('Rich content'));
assert.equal(result.unsafe, false);
assert.equal(result.frameRemaining, false);
assert.deepEqual(result.errors, ['Browser printing is unavailable.']);
// Actual theme module: default, legacy local/server selections and custom theme preferences.
const fonts = await page.evaluate(async origin => {
localStorage.setItem('odysseus-theme', JSON.stringify({ name: 'dark', colors: {}, font: 'gohu' }));
localStorage.setItem('odysseus-custom-themes', JSON.stringify({ old: { font: 'GohuFont' } }));
const theme = await import(origin + '/static/js/theme.js');
theme.applyFontDensity(null, null);
const fallback = document.documentElement.style.getPropertyValue('--font-family');
theme.applyFontDensity('gohu', null);
const legacy = document.documentElement.style.getPropertyValue('--font-family');
theme.initThemeUI();
const selected = document.getElementById('theme-font-select').value;
const saved = theme.getSaved().font;
const custom = theme.getCustomThemes().old.font;
theme.applyFontDensity('sans', null);
const sans = document.documentElement.style.getPropertyValue('--font-family');
return { fallback, legacy, selected, saved, custom, sans };
}, origin);
assert.equal(fonts.fallback, "'Fira Code', monospace");
assert.equal(fonts.legacy, fonts.fallback);
assert.equal(fonts.selected, 'mono');
assert.equal(fonts.saved, 'mono');
assert.equal(fonts.custom, 'mono');
assert(fonts.sans.includes('system-ui'));
// Verify the real early bootstrap handles persisted legacy preferences.
const appHtml = readFileSync('static/index.html', 'utf8');
const bootstrap = [...appHtml.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)].map(m => m[1]).find(code => code.includes('Apply font early'));
assert(bootstrap);
const early = await page.evaluate(async code => {
const theme = await import('/static/js/theme.js');
localStorage.setItem('odysseus-theme', JSON.stringify({ name: 'dark', colors: theme.THEMES.dark, font: 'gohu' }));
eval(code);
return document.documentElement.style.getPropertyValue('--font-family');
}, bootstrap);
assert.equal(early, fonts.fallback);
console.log(JSON.stringify({ website: true, fonts: true, print: true, pwa: true }));
} finally {
await browser.close();
}
})().catch(e => { console.error(e); process.exit(1); });
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { appCss } from './helpers/stylesheets.mjs';
import { chromium } from 'playwright';
test('research primary actions retain shipped cascade sizing across mobile and desktop', async () => {
const css = await appCss();
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setContent(`<div id="research-pane"><div id="research-past-list">
<div class="research-job-card"><div class="research-job-header">
<span class="research-job-query">A long research title that must still fit on a narrow phone</span>
<button class="task-status-badge research-job-report-badge" title="Open visual report"><svg></svg><span class="task-state-label">Visual Report</span></button>
<button class="task-status-badge research-job-discuss-badge" title="Discuss"><svg></svg><span class="task-state-label">Discuss</span></button>
</div></div></div></div>`);
await page.addStyleTag({ content: css });
for (const width of [320, 390, 600, 1024]) {
await page.setViewportSize({ width, height: 800 });
const buttons = await page.locator('.research-job-header button').evaluateAll(nodes => nodes.map(node => {
const rect = node.getBoundingClientRect();
return { width: rect.width, height: rect.height,
icon: node.querySelector('svg').getBoundingClientRect().width,
labelHidden: getComputedStyle(node.querySelector('.task-state-label')).display === 'none' };
}));
for (const button of buttons) {
if (width <= 600) {
// Assert the complete shipped cascade, not the historical
// style.css-only result. Later app styles keep the action labels
// visible and use the larger mobile control geometry.
assert.ok(button.width > 24);
assert.equal(button.height, 28);
assert.equal(button.icon, 13);
assert.equal(button.labelHidden, false);
} else {
assert.equal(button.height, 20);
assert.equal(button.icon, 10);
assert.equal(button.labelHidden, false);
}
}
}
} finally { await browser.close(); }
});
+55
View File
@@ -0,0 +1,55 @@
"""Dispatcher doubles must simulate the receipt boundary as well as the result."""
from src.agent_runtime.journal import mark_dispatch, record_action
def server_authorized_executor(executor):
"""Give standalone dispatcher fixtures their explicit server grants.
These existing suites exercise handlers, policy, confinement and approvals.
Their fixture grants cover the declared native tool registry, independently
of the proposed block. Request-authority denial tests use the raw dispatcher.
"""
from functools import wraps
from inspect import signature
from src.agent_runtime.authority import OperationGrant, RequestAuthority
from src.tool_policy import known_tool_names
from src.turn_contract import canonical_tool
from src.agent_runtime.remote_resources import seal_backends
from src.agent_runtime.resources import FilesystemRoot, NativeBackendResource, ProcessLaunchScope
from src.containment import DEFAULT_REQUIRED
from src.agent_runtime.process_resources import seal_launch_scope
from pathlib import Path
import tempfile
call_signature = signature(executor)
@wraps(executor)
async def execute(*args, **kwargs):
bound = call_signature.bind(*args, **kwargs)
parameters = bound.arguments
grants = tuple(OperationGrant(name) for name in sorted(
{canonical_tool(n) for n in known_tool_names()} | {"list_dir", "find_files"}))
original = parameters.get("exact_approval")
authority = original.pending.request_authority if original is not None else None
if authority is not None:
kwargs.setdefault("request_authority", authority)
scratch = Path(tempfile.mkdtemp(prefix="odysseus-dispatch-fixture-"))
launch_scopes = (None if parameters.get("workspace") else tuple(
seal_launch_scope(NativeBackendResource(tool), FilesystemRoot.seal(scratch))
for tool in ("bash", "python")))
kwargs.setdefault("request_authority", RequestAuthority(
"standalone-test-request", str(parameters.get("owner") or "").strip().casefold(),
str(parameters.get("session_id") or ""), str(parameters.get("workspace") or ""),
grants,
launch_scopes=launch_scopes,
backend_resources=seal_backends((g.tool for g in grants), context=parameters.get("client_runtime_context"),
owner=str(parameters.get("owner") or "").strip().casefold()),
))
return await executor(*args, **kwargs)
return execute
def authoritative_executor(function):
@record_action
async def execute(block, *args, **kwargs):
mark_dispatch()
return await function(block, *args, **kwargs)
return execute
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {recordsIn,scoreCalls,auditHistory,reformatNoteResult} from '../scripts/compare_schema_thinking.mjs';
const records=[{id:'aaaaaaaa-0000-0000-0000-000000000000',title:'Japan'},
{id:'bbbbbbbb-0000-0000-0000-000000000000',title:'Today'}];
const call=(args)=>({function:{name:'manage_notes',arguments:JSON.stringify(args)}});
test('proposal grading resolves supported title and ID references, not concatenations',()=>{
assert.equal(scoreCalls([call({action:'delete',title:'Japan Today'})],records,['Japan','Today']).exact_target_proposal,false);
assert.equal(scoreCalls([call({action:'delete',id:'aaaaaaaa'}),call({action:'delete',title:'Today'})],records,['Japan','Today']).exact_target_proposal,true);
});
test('negative controls fail if any record is deleted',()=>{
assert.equal(scoreCalls([],records,[]).exact_target_proposal,true);
assert.equal(scoreCalls([call({action:'delete',title:'Japan'})],records,[]).exact_target_proposal,false);
});
test('missing targets are not hidden by repeated calls or exploratory reads',()=>{
const result=scoreCalls([call({action:'delete',title:'Japan'}),call({action:'delete',title:'Japan'}),call({action:'list'})],records,['Japan','Today']);
assert.equal(result.exact_target_proposal,false);
assert.equal(result.duplicate_targets,1);
assert.deepEqual(result.missing_targets,['Today']);
});
test('audit checks actual tool result bytes and native call pairing',()=>{
const result={role:'tool',tool_call_id:'c1',content:records.map(r=>`- [${r.id}] **${r.title}**`).join('\n')};
const messages=[{role:'user',content:'notes'},
{role:'assistant',tool_calls:[{id:'c1'}]},result,{role:'user',content:'delete both'}];
const request={messages,tools:[],chat_template_kwargs:{enable_thinking:false}};
assert.deepEqual(recordsIn(messages),records);
assert.equal(auditHistory(request,[{messages}],records.map(r=>r.id)).exact_prior_note_result_preserved,true);
const broken={...request,messages:[{...result,content:result.content+' changed'}]};
const audit=auditHistory(broken,[{messages}],records.map(r=>r.id));
assert.equal(audit.exact_prior_note_result_preserved,false);
assert.equal(audit.orphan_tool_results,1);
});
test('format-only variants retain IDs, titles, ordering, suffixes and wrapper fields',()=>{
const rows=[{...records[0],suffix:' [PINNED] #travel'},
{...records[1],title:'Today "special"',suffix:' [checklist] #todo'}];
const text=rows.map(r=>`- [${r.id}] **${r.title}**${r.suffix}`).join('\n');
const wrapped=JSON.stringify({results:text,exit_code:0,extra:'unchanged'});
const quoted=JSON.parse(reformatNoteResult(wrapped,'quoted'));
assert.equal(quoted.extra,'unchanged');
assert.equal(quoted.exit_code,0);
assert.equal(quoted.results,rows.map(r=>`- [${r.id}] ${JSON.stringify(r.title)}${r.suffix}`).join('\n'));
const jsonl=JSON.parse(reformatNoteResult(wrapped,'jsonl'));
assert.deepEqual(jsonl.results.split('\n').map(l=>JSON.parse(l)),rows);
assert.throws(()=>reformatNoteResult(text+'\nAdditional body text','jsonl'));
});
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import test from 'node:test';
const source = readFileSync(new URL('../static/app.js', import.meta.url), 'utf8');
const start = source.indexOf(" const sidebarNewChatBtn = el('sidebar-new-chat-btn');");
const end = source.indexOf(' // Delete session button on icon rail', start);
assert.ok(start >= 0 && end > start);
for (const width of [390, 767, 768, 1280]) {
test(`New Chat drawer behavior at ${width}px`, async () => {
let click;
let calls = 0;
let syncs = 0;
let finish;
const pending = new Promise(resolve => { finish = resolve; });
const sidebar = new Set();
const backdrop = new Set(['visible']);
const nodes = {
'sidebar-new-chat-btn': { addEventListener: (event, handler) => { click = handler; } },
sidebar: { classList: { add: value => sidebar.add(value) } },
'sidebar-backdrop': { classList: { remove: value => backdrop.delete(value) } },
};
runInNewContext(source.slice(start, end), {
el: id => nodes[id],
window: { innerWidth: width, syncRailSide: () => { syncs++; } },
_handleNewChatAction: () => { calls++; return pending; },
});
const result = click({ preventDefault() {}, stopImmediatePropagation() {} });
// Drawer closes before asynchronous model/session setup finishes.
assert.equal(sidebar.has('hidden'), width < 768);
assert.equal(backdrop.has('visible'), width >= 768);
assert.equal(syncs, width < 768 ? 1 : 0);
assert.equal(calls, 1);
finish();
await result;
});
}
+24
View File
@@ -0,0 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { skillsSummaryMetrics, filterSkillsByQuickFilter, skillNeedsReview } from '../static/js/skillsMetrics.js';
test('approval uses the configured threshold for every item, not array index', () => {
const rows = ['one', 'two', 'three'].map(name => ({ name, status: 'published', audit_verdict: 'pass', confidence: 0.9, baseline_verdict: 'same' }));
assert.equal(skillsSummaryMetrics(rows, 0.85).approved, 3);
assert.equal(skillsSummaryMetrics(rows, 0.85).review, 0);
assert.equal(filterSkillsByQuickFilter(rows, 'approved', 0.95).length, 0);
assert.equal(filterSkillsByQuickFilter(rows, 'draft', 0.95).length, 3);
});
test('review badges agree with approval and keep unaudited skills queued', () => {
assert.equal(skillNeedsReview({ status: 'draft' }), false);
assert.equal(skillNeedsReview({ status: 'draft', audit_verdict: 'pass', confidence: 0.95 }), true);
assert.equal(skillNeedsReview({ status: 'published', audit_verdict: 'pass', confidence: 0.95, necessity: { necessary: false } }), true);
assert.equal(skillNeedsReview({ status: 'published', source: 'builtin' }), false);
});
test('built-ins and drafts have distinct filters and archived skills remain recoverable', () => {
const rows = [{ name: 'shipped', source: 'builtin', status: 'published' }, { name: 'bad', status: 'binned' }];
assert.deepEqual(filterSkillsByQuickFilter(rows, 'builtin').map(s => s.name), ['shipped']);
assert.deepEqual(filterSkillsByQuickFilter(rows, 'draft').map(s => s.name), ['bad']);
});
+138
View File
@@ -0,0 +1,138 @@
# Release smoke suite
One command that boots this worktree and drives every advertised feature
area once, end to end, against a real instance.
```bash
scripts/odysseus-smoke # boot, run every area, stop again
scripts/odysseus-smoke --keep-up # leave the instance running afterwards
scripts/odysseus-smoke --no-boot # drive whatever is already up here
scripts/odysseus-smoke --areas # print the coverage table without booting
scripts/odysseus-smoke -- -k notes
```
## Why it exists
The decomposition work had two safety nets and neither covered the
product. The checkpoint benchmark measures the agent runtime. The
computed-style snapshot in `tests/test_css_computed_style_snapshot.py`
pins the rendered CSS. Nothing checked that Notes, Calendar, Documents,
Email, Memory, Cookbook or Settings still worked after a route package
moved or a 17,000-line module was split, and the unit suite does not:
`StressTestor`'s review of #5898 is the worked proof that a
byte-identical file-for-file move can break eleven tests that pass on
the base branch, with CI green throughout.
## Where it lives and why
pytest, not Playwright. Both are in the repo, so this adds no third
harness, and the choice went to pytest because every scenario here is a
request/response round trip rather than a rendering assertion -
rendering is already covered by the computed-style snapshot, and the
28 Playwright specs under `tests/e2e/photo-editor/` are the one area
with browser coverage. A browser would have added flake and start-up
cost for no extra signal.
It owns no instance logic. `scripts/odysseus-dev` already derives ports
per worktree, keeps the data dir and ChromaDB out of `data/`, and waits
on `/api/ready` rather than a TCP accept, so `scripts/odysseus-smoke`
boots through it and only adds the scenarios and the report.
## The contract with the runner
Four environment values, which are what `odysseus dev env` prints plus
the dev admin account:
| Variable | Read through | Used for |
|---|---|---|
| `APP_PORT` | `src.constants.internal_api_base()` | which instance to drive |
| `ODYSSEUS_ADMIN_USER` | - | who to authenticate as |
| `ODYSSEUS_ADMIN_PASSWORD` | - | " |
| `ODYSSEUS_DATA_DIR` | `src.constants.DATA_DIR` | where the email fixture file goes |
Run under a plain `pytest` with none of them set, every scenario skips
with the reason and the full suite stays green. `APP_PORT` pointing at
one of `odysseus dev`'s reserved ports - a normal launch of this
checkout, the machine's own instance - is refused rather than driven,
because the scenarios create and delete real records.
## The deterministic provider
`stub_provider.py` is an OpenAI-compatible server on an ephemeral
loopback port: `GET /v1/models` and `POST /v1/chat/completions`, both
buffered and streamed. No scenario touches a live model endpoint or the
network. It serves two model ids so the Compare area has something to
reveal, and it records every request so a scenario can assert the user's
message actually reached the provider rather than only that some text
came back.
Email uses the repo's own deterministic path rather than a second
mechanism: `routes/email_routes.py` serves a fixture inbox when
`ODYSSEUS_EMAIL_FIXTURE=1` and a fixture file is in the data dir. The
suite writes the file and restores whatever was there; the flag is read
inside the app's process, which is why the runner owns the boot.
## What is covered
One scenario per area, each asserting a user-visible outcome rather than
a status code. `scripts/odysseus-smoke --areas` prints the current list.
| Area | What it asserts |
|---|---|
| Chat | a turn against the stub comes back rendered, on both the buffered and the streamed path, and is in the session history |
| Compare | a blind comparison streams both sides and the vote reveals which model produced which reply |
| Notes | a note is listed, read back, edited, and 404s after delete |
| Calendar | an event appears in the window the UI queries and is gone after delete |
| Tasks | a daily task is accepted with a computed next run, is listed, and pauses |
| Documents (editor) | an edit adds a version, both versions read back, and a restore returns the first |
| Documents (RAG) | an uploaded file is chunked, indexed and listed |
| Email | the fixture inbox lists, opens with its body, and the unread count drops on mark-read |
| Memory | a fact is listed, found by search, and gone after delete |
| Uploads | an attachment reads back byte for byte |
| Cookbook | hardware is detected; a fresh install's recommendation request returns the explicit empty-catalog Rescan guidance; state persists |
| Settings | a preference written on one session is still there after a new login |
## What is not covered, and why
Printed next to the results on every run, so a reader cannot mistake the
table for coverage of everything it does not mention. `DECLARED_GAPS` in
`areas.py` is the list; the short version:
- **Deep Research** and **Web Search** need live egress. A deterministic
stub for the crawler would be an application change, which this is
not.
- **Email over IMAP/SMTP** is covered only as far as the fixture path
goes. There is no local mail server, so real account sync and send are
untested.
- **Cookbook download and serve** needs tmux, a GPU runtime and a
multi-gigabyte download.
- **Gallery and the photo editor** already have the repo's only
Playwright specs.
- **The agent tool loop** is what the checkpoint benchmark measures.
- **MCP servers** are stdio subprocesses outside the app's readiness
contract.
- **Rendering and layout** are pinned by the computed-style snapshot.
`Documents (RAG)` is the one covered area that can report `SKIP` on a
clean checkout: `requirements.txt` pins `chromadb-client`, the HTTP
client, and the ChromaDB *server* is a separate install. Without one
reachable, the upload route returns a deliberate 503 and the row reads
`SKIP` with that reason. Install `chromadb` in the venv and it goes
green.
## Reading the report
The table has one row per area in `areas.COVERED`, built from what
pytest reported rather than from anything a scenario asserts about
itself. An area whose module never ran shows as `NOT RUN`, so deleting
or renaming a file cannot make a row disappear -
`tests/test_smoke_area_table.py` pins that, and that a module on disk
must be registered.
## What a run leaves behind
Every scenario deletes what it created, with two exceptions on the
scratch instance: the preference key `odysseus_smoke_preference`, which
has no delete route, and the uploaded attachment, which the app's own
upload cleanup owns. Both live in `.odysseus-dev/data/`, never in
`data/`.
+195
View File
@@ -0,0 +1,195 @@
"""The area registry and the result table for the release smoke suite.
Pure stdlib on purpose: this module is the one part of the suite that has
to be readable and testable without a running instance, because it is
what decides whether the suite's output is honest.
Two lists matter here and they are both deliberate:
``COVERED`` names every feature area the suite drives, and the test
module that drives it. A row appears in the table whether or not its
module ran, so an area cannot quietly vanish from the report by having
its file deleted or renamed - it shows up as ``NOT RUN`` instead.
``DECLARED_GAPS`` names the areas the suite does *not* cover, with the
reason. They are printed alongside the results rather than left out,
because a smoke report that lists only what it checked reads as
coverage of everything it does not mention.
"""
from __future__ import annotations
import textwrap
from dataclasses import dataclass
# Result labels. ASCII only - no Unicode status glyphs anywhere in the
# table (repo convention: no emoji in UI or code).
PASS = "PASS"
FAIL = "FAIL"
SKIP = "SKIP"
NOT_RUN = "NOT RUN"
NOT_COVERED = "NOT COVERED"
# Precedence when one area's module produces several outcomes: a single
# failure decides the row, then a skip, then pass.
_PRECEDENCE = (FAIL, SKIP, PASS)
# Table geometry. Wide enough for the longest gap reason to read as a
# sentence, narrow enough to survive a normal terminal.
TABLE_WIDTH = 100
MIN_DETAIL_WIDTH = 30
@dataclass(frozen=True)
class Area:
"""One advertised feature area and the module that exercises it."""
key: str
label: str
module: str
@dataclass(frozen=True)
class Gap:
"""An area this suite does not cover, and why it does not."""
label: str
reason: str
# Order is the order the table prints in: the chat surface first, then
# the feature areas README.md advertises, then the setup surface.
COVERED = (
Area("chat", "Chat", "test_chat_smoke.py"),
Area("compare", "Compare", "test_compare_smoke.py"),
Area("notes", "Notes", "test_notes_smoke.py"),
Area("calendar", "Calendar", "test_calendar_smoke.py"),
Area("tasks", "Tasks (scheduled)", "test_tasks_smoke.py"),
Area("documents", "Documents (editor)", "test_documents_smoke.py"),
Area("documents_rag", "Documents (RAG)", "test_documents_rag_smoke.py"),
Area("email", "Email", "test_email_smoke.py"),
Area("memory", "Memory", "test_memory_smoke.py"),
Area("uploads", "Uploads", "test_uploads_smoke.py"),
Area("cookbook", "Cookbook", "test_cookbook_smoke.py"),
Area("settings", "Settings", "test_settings_smoke.py"),
)
DECLARED_GAPS = (
Gap(
"Deep Research",
"needs live web egress; the crawler has no deterministic stub and adding "
"one would be an application change",
),
Gap(
"Web Search",
"needs a reachable SearXNG or an external provider, so the result is not "
"reproducible from a clean checkout",
),
Gap(
"Email over IMAP/SMTP",
"covered through the existing ODYSSEUS_EMAIL_FIXTURE path only; no local "
"mail server, so real account sync and send are untested",
),
Gap(
"Cookbook download and serve",
"needs tmux, a GPU runtime and a multi-GB model download; only hardware "
"fit and state sync are checked",
),
Gap(
"Gallery and photo editor",
"already the one area with Playwright specs under tests/e2e/photo-editor/",
),
Gap(
"Agent tool loop",
"measured by the checkpoint benchmark, which is the safety net that does "
"cover the agent runtime",
),
Gap(
"MCP servers",
"the built-in servers are stdio subprocesses whose readiness is not part "
"of the app's own readiness contract",
),
Gap(
"Rendering and layout",
"pinned by the computed-style snapshot in "
"tests/test_css_computed_style_snapshot.py",
),
)
_MODULE_TO_KEY = {area.module: area.key for area in COVERED}
def area_for_module(module_name: str) -> str | None:
"""Map a test module filename to its area key, or None."""
return _MODULE_TO_KEY.get(module_name)
def resolve(outcomes: list[str]) -> str:
"""Collapse one module's outcomes into the row's single result."""
if not outcomes:
return NOT_RUN
for label in _PRECEDENCE:
if label in outcomes:
return label
return NOT_RUN
def render_table(results, *, header="", areas=COVERED, gaps=DECLARED_GAPS,
width=TABLE_WIDTH) -> str:
"""Render the per-area table.
``results`` maps an area key to a mapping with ``result`` and,
optionally, ``checks`` and ``detail``. Unknown keys are ignored and
missing keys render as ``NOT RUN`` - the registry, not the run,
decides which rows exist.
"""
rows = []
for area in areas:
entry = results.get(area.key) or {}
result = entry.get("result") or NOT_RUN
checks = entry.get("checks")
detail = entry.get("detail") or ""
if result == NOT_RUN and not detail:
detail = "no test ran for this area"
rows.append((area.label, result,
"" if checks is None else str(checks), detail))
labels = [row[0] for row in rows] + [gap.label for gap in gaps] + ["AREA"]
label_width = max(len(label) for label in labels)
result_width = max([len(row[1]) for row in rows] + [len(NOT_COVERED), len("RESULT")])
checks_width = max([len(row[2]) for row in rows] + [len("CHECKS")])
# Indent + label + gap + result + gap + checks + gap, then the detail.
detail_indent = 2 + label_width + 2 + result_width + 2 + checks_width + 2
detail_width = max(width - detail_indent, MIN_DETAIL_WIDTH)
def row_lines(label, result, checks, detail):
first = (f" {label.ljust(label_width)} {result.ljust(result_width)} "
f"{checks.rjust(checks_width)} ")
wrapped = textwrap.wrap(detail, detail_width) or [""]
out = [(first + wrapped[0]).rstrip()]
out += [(" " * detail_indent + line).rstrip() for line in wrapped[1:]]
return out
lines = []
if header:
lines.extend([header, ""])
lines.append(
f" {'AREA'.ljust(label_width)} {'RESULT'.ljust(result_width)} "
f"{'CHECKS'.rjust(checks_width)} DETAIL"
)
for row in rows:
lines.extend(row_lines(*row))
if gaps:
lines.extend(["", " Not covered, deliberately:"])
for gap in gaps:
lines.extend(row_lines(gap.label, NOT_COVERED, "", gap.reason))
failed = [row for row in rows if row[1] == FAIL]
skipped = [row for row in rows if row[1] == SKIP]
not_run = [row for row in rows if row[1] == NOT_RUN]
passed = len(rows) - len(failed) - len(skipped) - len(not_run)
lines.extend(["", (
f" {passed} pass, {len(failed)} fail, {len(skipped)} skip, "
f"{len(not_run)} not run, {len(gaps)} declared gaps"
)])
return "\n".join(lines)
+248
View File
@@ -0,0 +1,248 @@
"""Session wiring for the release smoke suite.
The suite drives a real instance over HTTP. It never starts one: that is
`scripts/odysseus-smoke`'s job, which boots the worktree through
`scripts/odysseus-dev` and hands the details over in the environment.
Run under a plain `pytest` with no instance up, every scenario skips
with the reason rather than failing, so the full suite stays green.
Three environment values form the contract, and they are exactly what
`odysseus dev env` prints plus the dev admin account:
APP_PORT - which instance, read through
`internal_api_base()`
ODYSSEUS_ADMIN_USER - the account to authenticate as
ODYSSEUS_ADMIN_PASSWORD
ODYSSEUS_DATA_DIR - where the email fixture file goes, read
through `src.constants.DATA_DIR`
"""
from __future__ import annotations
import os
import httpx
import pytest
from src.constants import internal_api_base
from tests.helpers.cli_loader import load_script
from tests.smoke import areas
from tests.smoke.stub_provider import MODEL_PRIMARY, StubProvider
# How long a smoke request may take. Generous: the first turn through a
# cold agent path does real work, and a timeout here reads as a product
# failure, which is the one thing this suite must not get wrong.
REQUEST_TIMEOUT_SECONDS = 120.0
# Auth and endpoint routes the suite drives directly. Kept here so a
# route rename shows up in one place rather than twelve.
LOGIN_PATH = "/api/auth/login"
HEALTH_PATH = "/api/health"
ENDPOINTS_PATH = "/api/model-endpoints"
SESSION_PATH = "/api/session"
_NO_PORT = (
"APP_PORT is not set, so there is no instance to drive. Run the suite "
"with `scripts/odysseus-smoke`, which boots this worktree and exports it."
)
def _reserved_ports() -> dict:
"""`odysseus dev`'s own refuse-list, read from the launcher.
The smoke suite writes and deletes real records, so pointing it at a
port that means something - a normal launch of this checkout, the
machine's production instance - has to be impossible rather than
merely discouraged. Reusing the launcher's table keeps one source of
truth instead of a second copy that can drift.
"""
try:
return dict(load_script("odysseus-dev").RESERVED_PORTS)
except Exception: # pragma: no cover - launcher absent or unloadable
return {}
@pytest.fixture(scope="session")
def base_url() -> str:
"""The instance this run drives, or a skip explaining why there is none."""
port = (os.environ.get("APP_PORT") or "").strip()
if not port:
pytest.skip(_NO_PORT)
reason = _reserved_ports().get(int(port)) if port.isdigit() else None
if reason:
pytest.skip(
f"APP_PORT={port} is {reason}. The smoke suite creates and deletes "
f"real records, so it refuses to run against that instance."
)
return internal_api_base()
@pytest.fixture(scope="session")
def account() -> dict:
user = (os.environ.get("ODYSSEUS_ADMIN_USER") or "").strip()
password = os.environ.get("ODYSSEUS_ADMIN_PASSWORD") or ""
if not user or not password:
pytest.skip(
"ODYSSEUS_ADMIN_USER / ODYSSEUS_ADMIN_PASSWORD are not set, so the "
"suite cannot authenticate. Run it with `scripts/odysseus-smoke`."
)
return {"username": user, "password": password}
def _new_client(base_url: str, account: dict) -> httpx.Client:
"""An authenticated client, or a skip naming what the instance said."""
client = httpx.Client(base_url=base_url, timeout=REQUEST_TIMEOUT_SECONDS,
follow_redirects=True)
try:
client.get(HEALTH_PATH)
except httpx.HTTPError as exc:
client.close()
pytest.skip(f"no instance answering at {base_url} ({exc}). Boot one with "
f"`odysseus dev up`, or run `scripts/odysseus-smoke`.")
response = client.post(LOGIN_PATH, json=account)
if response.status_code != 200:
client.close()
pytest.skip(
f"could not log in as {account['username']} at {base_url}: "
f"HTTP {response.status_code}. The recorded credentials may not "
f"match this instance's data dir."
)
return client
@pytest.fixture(scope="session")
def client(base_url, account):
"""One authenticated session shared by every scenario."""
handle = _new_client(base_url, account)
yield handle
handle.close()
@pytest.fixture
def fresh_client(base_url, account):
"""A second authenticated session, for asserting something persisted.
Reading a value back on the same cookie proves the request handler
returned it. Reading it back on a new login is the closest a test can
get to the user reloading the page.
"""
handle = _new_client(base_url, account)
yield handle
handle.close()
@pytest.fixture(scope="session")
def stub_provider():
"""The deterministic provider every model-backed scenario talks to."""
with StubProvider() as provider:
yield provider
@pytest.fixture(scope="session")
def stub_endpoint(client, stub_provider) -> str:
"""Register the stub as a model endpoint and return its id.
Registered as `endpoint_kind=local` so the app treats it the way it
treats a Cookbook-served model rather than probing it as a hosted
API, and removed afterwards so a `--keep-up` instance is not left
pointing at a port that has gone away.
"""
response = client.post(ENDPOINTS_PATH, data={
"name": "odysseus-smoke-stub",
"base_url": stub_provider.base_url,
"endpoint_kind": "local",
})
if response.status_code != 200:
pytest.skip(
f"the instance would not register the stub provider at "
f"{stub_provider.base_url}: HTTP {response.status_code} "
f"{response.text[:200]}"
)
body = response.json()
endpoint_id = str(body.get("id") or "")
if not endpoint_id:
pytest.skip(f"the endpoint the instance registered has no id: {body}")
if MODEL_PRIMARY not in (body.get("models") or []):
pytest.skip(
f"the instance did not discover {MODEL_PRIMARY} on the stub "
f"provider; it saw {body.get('models')}"
)
yield endpoint_id
client.delete(f"{ENDPOINTS_PATH}/{endpoint_id}")
@pytest.fixture
def chat_session(client, stub_endpoint):
"""A chat session bound to the stub provider, deleted afterwards."""
response = client.post(SESSION_PATH, data={
"name": "odysseus-smoke",
"endpoint_id": stub_endpoint,
"model": MODEL_PRIMARY,
})
assert response.status_code == 200, response.text
session_id = response.json()["id"]
yield session_id
client.delete(f"{SESSION_PATH}/{session_id}")
# --------------------------------------------------------------------------
# The per-area table
# --------------------------------------------------------------------------
# One row per area in `areas.COVERED`, built from the outcomes pytest
# reports rather than from anything a test asserts about itself, so a
# module that never ran cannot report a pass.
_outcomes: dict[str, list[str]] = {}
_details: dict[str, str] = {}
_checks: dict[str, int] = {}
def _skip_reason(report) -> str:
"""The reason text out of a skip report, best effort."""
longrepr = getattr(report, "longrepr", None)
if isinstance(longrepr, tuple) and len(longrepr) == 3:
reason = str(longrepr[2] or "")
return reason.removeprefix("Skipped: ").strip()
return str(longrepr or "").strip()
def pytest_runtest_logreport(report):
key = areas.area_for_module(os.path.basename(str(report.fspath)))
if key is None:
return
if report.skipped:
_outcomes.setdefault(key, []).append(areas.SKIP)
_details.setdefault(key, _skip_reason(report))
return
if report.failed:
_outcomes.setdefault(key, []).append(areas.FAIL)
_details[key] = f"{report.when} failed: {report.nodeid.split('::')[-1]}"
return
if report.when == "call" and report.passed:
_outcomes.setdefault(key, []).append(areas.PASS)
_checks[key] = _checks.get(key, 0) + 1
def pytest_terminal_summary(terminalreporter, exitstatus, config):
if not _outcomes:
return
results = {}
for key, outcomes in _outcomes.items():
results[key] = {
"result": areas.resolve(outcomes),
"checks": _checks.get(key, 0),
"detail": _details.get(key, ""),
}
if all(entry["result"] == areas.SKIP for entry in results.values()):
reasons = {entry["detail"] for entry in results.values() if entry["detail"]}
terminalreporter.write_line("")
terminalreporter.write_line(
"release smoke suite skipped: " + (
reasons.pop() if len(reasons) == 1 else "; ".join(sorted(reasons))
)
)
return
header = f"Odysseus release smoke - {internal_api_base()}"
terminalreporter.write_line("")
terminalreporter.write_line(areas.render_table(results, header=header))
+172
View File
@@ -0,0 +1,172 @@
"""A deterministic OpenAI-compatible provider for the smoke suite.
Every scenario that needs a model talks to this instead of a real
endpoint. It binds an ephemeral port on loopback, so no scenario depends
on network egress, on a model being downloaded, or on two runs on the
same machine picking the same port.
It answers the two routes the app needs to treat it as a local
OpenAI-compatible server: ``GET /v1/models`` for discovery and probing,
and ``POST /v1/chat/completions`` for both the buffered and the streamed
turn. Each reply is a fixed marker plus the model id, so a test can tell
the two models apart in a blind comparison; every request is recorded so
a test can assert the user's message actually reached the provider
rather than only that some text came back.
"""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# Two model ids so the Compare area has something to reveal.
MODEL_PRIMARY = "odysseus-smoke-primary"
MODEL_SECONDARY = "odysseus-smoke-secondary"
MODELS = (MODEL_PRIMARY, MODEL_SECONDARY)
# The marker each reply starts with. Distinctive enough that finding it
# in a response body cannot be a coincidence, and short enough to read
# in a failure message.
REPLY_MARKER = "ODYSSEUS-SMOKE-REPLY"
# Bind on loopback, kernel-assigned port. No literal port anywhere.
BIND_HOST = "127.0.0.1"
BIND_PORT = 0
def reply_for(model: str) -> str:
"""The exact assistant text this provider returns for ``model``."""
return f"{REPLY_MARKER} {model}"
class _Recorder:
"""Requests the provider has served, for assertions after the fact."""
def __init__(self):
self._lock = threading.Lock()
self._calls = []
def record(self, payload: dict) -> None:
with self._lock:
self._calls.append(payload)
@property
def calls(self) -> list[dict]:
with self._lock:
return list(self._calls)
def prompts(self) -> list[str]:
"""Every user message this provider has been sent."""
out = []
for call in self.calls:
for message in call.get("messages") or []:
if message.get("role") == "user":
out.append(str(message.get("content") or ""))
return out
def clear(self) -> None:
with self._lock:
self._calls.clear()
def _handler_for(recorder: _Recorder):
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args): # noqa: D102 - silence stderr access log
pass
def _send_json(self, status: int, body: dict) -> None:
raw = json.dumps(body).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler's contract
if self.path.rstrip("/").endswith("/models"):
self._send_json(200, {
"object": "list",
"data": [{"id": name, "object": "model", "owned_by": "smoke"}
for name in MODELS],
})
return
self._send_json(404, {"error": {"message": f"no route {self.path}"}})
def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler's contract
length = int(self.headers.get("Content-Length") or 0)
try:
payload = json.loads(self.rfile.read(length) or b"{}")
except ValueError:
payload = {}
if not isinstance(payload, dict):
payload = {}
recorder.record(payload)
model = str(payload.get("model") or MODEL_PRIMARY)
text = reply_for(model)
if payload.get("stream"):
self._send_stream(model, text)
return
self._send_json(200, {
"id": "smoke-completion",
"object": "chat.completion",
"model": model,
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": text},
"finish_reason": "stop",
}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
})
def _send_stream(self, model: str, text: str) -> None:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "close")
self.end_headers()
for chunk in (
{"choices": [{"index": 0, "delta": {"content": text}}], "model": model},
{"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "model": model},
):
self.wfile.write(b"data: " + json.dumps(chunk).encode("utf-8") + b"\n\n")
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
return Handler
class StubProvider:
"""A running stub provider. Use as a context manager."""
def __init__(self):
self.recorder = _Recorder()
self._server = ThreadingHTTPServer((BIND_HOST, BIND_PORT), _handler_for(self.recorder))
self._server.daemon_threads = True
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
@property
def port(self) -> int:
return self._server.server_address[1]
@property
def base_url(self) -> str:
"""The OpenAI-compatible base the app should be pointed at."""
return f"http://{BIND_HOST}:{self.port}/v1"
def start(self) -> "StubProvider":
self._thread.start()
return self
def stop(self) -> None:
self._server.shutdown()
self._server.server_close()
self._thread.join(timeout=5)
def __enter__(self) -> "StubProvider":
return self.start()
def __exit__(self, *exc) -> None:
self.stop()
+50
View File
@@ -0,0 +1,50 @@
"""Calendar: an event created through the API shows up in the range the UI asks for."""
from __future__ import annotations
from datetime import datetime, timedelta
CALENDARS_PATH = "/api/calendar/calendars"
EVENTS_PATH = "/api/calendar/events"
SUMMARY = "Odysseus smoke event"
# Far enough out that a real local calendar's own entries cannot collide
# with the assertion, and fixed relative to now so the window is never
# empty for date reasons.
DAYS_AHEAD = 30
def test_an_event_round_trips(client):
listed_calendars = client.get(CALENDARS_PATH)
assert listed_calendars.status_code == 200, listed_calendars.text
assert listed_calendars.json().get("calendars"), "no calendar to write an event into"
start = (datetime.now() + timedelta(days=DAYS_AHEAD)).replace(
hour=10, minute=0, second=0, microsecond=0)
created = client.post(EVENTS_PATH, json={
"summary": SUMMARY,
"dtstart": start.isoformat(),
})
assert created.status_code == 200, created.text
uid = created.json()["uid"]
try:
window = client.get(EVENTS_PATH, params={
"start": (start - timedelta(days=1)).isoformat(),
"end": (start + timedelta(days=1)).isoformat(),
})
assert window.status_code == 200, window.text
events = window.json().get("events") or []
matching = [e for e in events if e.get("uid") == uid]
assert matching, [e.get("summary") for e in events]
assert matching[0].get("summary") == SUMMARY, matching[0]
read = client.get(f"{EVENTS_PATH}/{uid}")
assert read.status_code == 200, read.text
finally:
removed = client.delete(f"{EVENTS_PATH}/{uid}")
assert removed.status_code == 200, removed.text
after = client.get(EVENTS_PATH, params={
"start": (start - timedelta(days=1)).isoformat(),
"end": (start + timedelta(days=1)).isoformat(),
})
assert uid not in [e.get("uid") for e in after.json().get("events") or []]
+66
View File
@@ -0,0 +1,66 @@
"""Chat: a turn against the stub provider comes back rendered and saved.
The buffered and the streamed path are both checked because the UI uses
the streamed one and the agent's own loop uses the buffered one, and a
decomposition can break either alone.
"""
from __future__ import annotations
import json
from tests.smoke.stub_provider import MODEL_PRIMARY, reply_for
CHAT_PATH = "/api/chat"
CHAT_STREAM_PATH = "/api/chat_stream"
HISTORY_PATH = "/api/history"
PROMPT = "Smoke check: reply with anything."
def test_buffered_turn_returns_the_provider_reply(client, chat_session, stub_provider):
response = client.post(CHAT_PATH, json={"message": PROMPT, "session": chat_session})
assert response.status_code == 200, response.text
body = response.json()
assert body.get("response") == reply_for(MODEL_PRIMARY), body
assert body.get("model") == MODEL_PRIMARY, body
# The app prefaces the turn with its own date/time context block, so
# the prompt is contained in what the provider saw rather than equal
# to it.
assert any(PROMPT in seen for seen in stub_provider.recorder.prompts()), (
"the prompt never reached the provider, so the reply came from "
"somewhere other than the model path"
)
def test_streamed_turn_emits_the_reply_and_saves_the_message(client, chat_session):
deltas, saved = [], []
with client.stream("POST", CHAT_STREAM_PATH,
json={"message": PROMPT, "session": chat_session}) as response:
assert response.status_code == 200
for line in response.iter_lines():
if not line.startswith("data: "):
continue
payload = line[len("data: "):].strip()
if payload == "[DONE]":
break
try:
event = json.loads(payload)
except ValueError:
continue
if "delta" in event:
deltas.append(str(event["delta"]))
if event.get("type") == "message_saved":
saved.append(event.get("id"))
assert "".join(deltas) == reply_for(MODEL_PRIMARY), deltas
assert saved and saved[0], "the stream never reported the assistant turn as saved"
def test_the_turn_is_in_the_session_history(client, chat_session):
client.post(CHAT_PATH, json={"message": PROMPT, "session": chat_session})
response = client.get(f"{HISTORY_PATH}/{chat_session}")
assert response.status_code == 200, response.text
messages = response.json().get("history") or []
rendered = [str(m.get("content") or "") for m in messages]
assert any(PROMPT in text for text in rendered), rendered
assert any(reply_for(MODEL_PRIMARY) in text for text in rendered), rendered
+71
View File
@@ -0,0 +1,71 @@
"""Compare: a blind comparison streams both sides and reveals them on the vote.
Two model ids on the one stub provider is what makes this checkable
without a second endpoint: each returns a reply naming itself, so the
reveal can be matched against which text arrived on which side.
"""
from __future__ import annotations
import json
from tests.smoke.stub_provider import MODEL_PRIMARY, MODEL_SECONDARY, reply_for
COMPARE_PATH = "/api/compare"
CHAT_STREAM_PATH = "/api/chat_stream"
PROMPT = "Smoke check: compare two replies."
def _stream_text(client, session_id: str) -> str:
deltas = []
with client.stream("POST", CHAT_STREAM_PATH,
json={"message": PROMPT, "session": session_id}) as response:
assert response.status_code == 200
for line in response.iter_lines():
if not line.startswith("data: "):
continue
payload = line[len("data: "):].strip()
if payload == "[DONE]":
break
try:
event = json.loads(payload)
except ValueError:
continue
if "delta" in event:
deltas.append(str(event["delta"]))
return "".join(deltas)
def test_a_blind_comparison_streams_and_reveals(client, stub_endpoint):
started = client.post(f"{COMPARE_PATH}/start", data={
"prompt": PROMPT,
"model_a": MODEL_PRIMARY,
"model_b": MODEL_SECONDARY,
"endpoint_a_id": stub_endpoint,
"endpoint_b_id": stub_endpoint,
"is_blind": "true",
})
assert started.status_code == 200, started.text
comparison = started.json()
comparison_id = comparison["id"]
# Blind: the start response must not say which model is on which side.
assert not comparison.get("model_left"), comparison
assert not comparison.get("model_right"), comparison
left = _stream_text(client, comparison["session_left"])
right = _stream_text(client, comparison["session_right"])
assert {left, right} == {reply_for(MODEL_PRIMARY), reply_for(MODEL_SECONDARY)}, (left, right)
voted = client.post(f"{COMPARE_PATH}/{comparison_id}/vote", data={"winner": "left"})
assert voted.status_code == 200, voted.text
revealed = voted.json().get("revealed") or {}
assert revealed.get("left") in (MODEL_PRIMARY, MODEL_SECONDARY), voted.text
assert reply_for(revealed["left"]) == left, (revealed, left)
assert reply_for(revealed["right"]) == right, (revealed, right)
history = client.get(f"{COMPARE_PATH}/history")
assert history.status_code == 200, history.text
entries = [row for row in history.json() if row.get("id") == comparison_id]
assert entries, history.text
assert entries[0].get("winner"), entries[0]
+74
View File
@@ -0,0 +1,74 @@
"""Cookbook: hardware is detected and a fresh install says how to get a catalog.
No model catalog ships with the app: the bundled lists are empty, and
rows only arrive when Rescan sends `refresh_catalog` while online. So a
fresh data dir that was never refreshed must answer a recommendation
request with an explicit empty-catalog message, not an empty table or a
server error. Ranking itself is covered by the unit tests against an
authored fixture catalog. Downloading and serving a model is left to the
gap list: it needs tmux, a GPU runtime and several gigabytes over the
network.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from src.constants import DATA_DIR
SYSTEM_PATH = "/api/hwfit/system"
MODELS_PATH = "/api/hwfit/models"
STATE_PATH = "/api/cookbook/state"
GPUS_PATH = "/api/cookbook/gpus"
STATE_MARKER = "odysseusSmokeMarker"
# Everything that can give the instance a catalog: the user catalog and the
# two caches a Rescan writes, all under the instance's data dir.
CATALOG_FILES = ("hf_models.json", "hf_collection_models.json", "mlx_community_models.json")
def test_hardware_is_detected(client):
response = client.get(SYSTEM_PATH)
assert response.status_code == 200, response.text
system = response.json()
assert (system.get("total_ram_gb") or 0) > 0, system
assert (system.get("cpu_cores") or 0) > 0, system
assert system.get("cpu_name"), system
gpus = client.get(GPUS_PATH)
assert gpus.status_code == 200, gpus.text
assert gpus.json().get("ok") is True, gpus.text
def test_fresh_install_explains_how_to_populate_the_catalog(client):
catalog_dir = Path(DATA_DIR) / "hwfit"
present = [name for name in CATALOG_FILES if (catalog_dir / name).exists()]
if present:
pytest.skip(f"not a fresh install: {catalog_dir} already holds {present}")
response = client.get(MODELS_PATH)
assert response.status_code == 200, response.text
body = response.json()
system = body.get("system") or {}
assert system.get("cpu_name"), body
assert body.get("models") == [], body
error = body.get("error") or ""
assert "Model catalog is empty" in error, body
assert "Rescan" in error, body
# A plain request never refreshes, so nothing was fetched behind our back.
assert "catalog_refresh" not in body, body
assert not any((catalog_dir / name).exists() for name in CATALOG_FILES)
def test_cookbook_state_persists(client):
written = client.post(STATE_PATH, json={STATE_MARKER: "ody-95"})
assert written.status_code == 200, written.text
assert written.json().get("ok") is True, written.text
read = client.get(STATE_PATH)
assert read.status_code == 200, read.text
assert read.json().get(STATE_MARKER) == "ody-95", read.text
client.post(STATE_PATH, json={})
+56
View File
@@ -0,0 +1,56 @@
"""Documents (RAG): an uploaded file is chunked, indexed and then listed.
This is the one area whose dependency is not satisfiable from a clean
checkout. `requirements.txt` pins `chromadb-client`, the HTTP client;
the ChromaDB *server* is a separate install, and without one reachable
the app returns a deliberate 503 from the upload route rather than
indexing into nothing. So the scenario skips with that reason printed in
the table instead of being quietly dropped - a row saying SKIP and why
is the honest report, and it goes green as soon as a vector service is
there.
"""
from __future__ import annotations
import pytest
PERSONAL_PATH = "/api/personal"
UPLOAD_PATH = "/api/personal/upload"
# The route uniquifies the stored name and lists it under the owner's
# upload dir, so assertions match on the stem rather than the filename.
STEM = "odysseus-smoke-corpus"
FILENAME = f"{STEM}.txt"
CONTENT = (
"The release smoke suite indexed this file. "
"It exists so the retrieval path has something deterministic to chunk."
)
# The route's own 503 text when no vector store answers.
UNAVAILABLE_MARKER = "RAG system is not available"
def test_an_uploaded_file_is_indexed_and_listed(client):
response = client.post(UPLOAD_PATH,
files={"files": (FILENAME, CONTENT.encode("utf-8"), "text/plain")})
if response.status_code == 503 and UNAVAILABLE_MARKER in response.text:
pytest.skip(
"no vector service reachable, so indexing is unavailable. "
"requirements.txt pins chromadb-client, not the server; install "
"chromadb in the venv and re-run to cover this area."
)
assert response.status_code == 200, response.text
body = response.json()
try:
assert body.get("indexed_count", 0) > 0, f"nothing was indexed: {body}"
assert body.get("failed_count", 1) == 0, f"a chunk failed to index: {body}"
assert FILENAME in (body.get("uploaded") or []), body
listed = client.get(PERSONAL_PATH)
assert listed.status_code == 200, listed.text
names = [str(f.get("name")) for f in listed.json().get("files") or []]
assert any(STEM in name for name in names), names
finally:
listed = client.get(PERSONAL_PATH).json().get("files") or []
for entry in listed:
if STEM in str(entry.get("name")):
client.request("DELETE", "/api/personal/file",
params={"filepath": entry.get("path")})
+46
View File
@@ -0,0 +1,46 @@
"""Documents: the editor's create, edit and version history survive a round trip."""
from __future__ import annotations
DOCUMENT_PATH = "/api/document"
LIBRARY_PATH = "/api/documents/library"
TITLE = "Odysseus smoke document"
FIRST = "First revision, written by the release smoke suite."
SECOND = "Second revision, written by the release smoke suite."
def test_a_document_round_trips_with_its_versions(client):
created = client.post(DOCUMENT_PATH, json={"title": TITLE, "content": FIRST})
assert created.status_code == 200, created.text
body = created.json()
doc_id = body["id"]
try:
assert body.get("current_content") == FIRST, body
assert body.get("version_count") == 1, body
library = client.get(LIBRARY_PATH)
assert library.status_code == 200, library.text
assert doc_id in [d.get("id") for d in library.json().get("documents") or []]
# `force_version` because a save inside the route's coalesce
# window updates the current version in place instead of adding
# one - which is right for autosave and would make a smoke check
# that edits immediately depend on the clock.
edited = client.put(f"{DOCUMENT_PATH}/{doc_id}",
json={"content": SECOND, "force_version": True})
assert edited.status_code == 200, edited.text
assert edited.json().get("current_content") == SECOND, edited.text
assert edited.json().get("version_count") == 2, edited.text
versions = client.get(f"{DOCUMENT_PATH}/{doc_id}/versions")
assert versions.status_code == 200, versions.text
contents = {v.get("version_number"): v.get("content") for v in versions.json()}
assert contents.get(1) == FIRST, contents
assert contents.get(2) == SECOND, contents
restored = client.post(f"{DOCUMENT_PATH}/{doc_id}/restore/1")
assert restored.status_code == 200, restored.text
assert client.get(f"{DOCUMENT_PATH}/{doc_id}").json()["current_content"] == FIRST
finally:
removed = client.delete(f"{DOCUMENT_PATH}/{doc_id}")
assert removed.status_code == 200, removed.text
+96
View File
@@ -0,0 +1,96 @@
"""Email: the inbox lists a seeded message, opens it, and marks it read.
Email is the one area with no way to reach a real account deterministically,
and the repo already solved that: `routes/email_routes.py` carries a
fixture path gated on `ODYSSEUS_EMAIL_FIXTURE=1` plus a fixture file in
the data dir. This uses that mechanism rather than inventing a second
one - which means it also only covers what the fixture covers. Real IMAP
sync and SMTP send stay out, and say so in the table's gap list.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from src.constants import DATA_DIR
LIST_PATH = "/api/email/list"
READ_PATH = "/api/email/read"
MARK_READ_PATH = "/api/email/mark-read"
UNREAD_STATE_PATH = "/api/email/unread-state"
# The filename the fixture path reads. Same value as
# routes/email_routes.py's `_fixture_email_file`.
FIXTURE_FILENAME = "fixture_email_messages.json"
SUBJECT = "Odysseus smoke inbox message"
BODY = "Body of the smoke fixture message."
SENDER = "Smoke Sender <smoke@example.invalid>"
@pytest.fixture
def seeded_inbox(client, account):
"""Write the fixture inbox, and put back whatever was there before.
The flag itself has to be in the app's environment, which is the
launcher's job; if it is missing the fixture path stays off and the
list route falls through to a real account that does not exist. That
reads as a skip, not a failure.
"""
path = Path(DATA_DIR) / FIXTURE_FILENAME
previous = path.read_bytes() if path.exists() else None
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"messages": [{
"owner": account["username"],
"from": SENDER,
"subject": SUBJECT,
"date": "2026-09-29T12:00:00+00:00",
"body": BODY,
}]}, indent=2) + "\n", encoding="utf-8")
try:
yield path
finally:
if previous is None:
path.unlink(missing_ok=True)
else:
path.write_bytes(previous)
def _fixture_rows(client):
response = client.get(LIST_PATH, params={"folder": "INBOX", "limit": 10})
assert response.status_code == 200, response.text
body = response.json()
rows = [e for e in body.get("emails") or [] if e.get("subject") == SUBJECT]
if not rows:
pytest.skip(
"the instance is not serving the email fixture, so there is no "
"deterministic inbox to read. Boot it with ODYSSEUS_EMAIL_FIXTURE=1 "
"(scripts/odysseus-smoke does)."
)
return rows
def test_the_inbox_lists_opens_and_marks_a_message(client, seeded_inbox):
row = _fixture_rows(client)[0]
uid = row["uid"]
assert row.get("from_address") == "smoke@example.invalid", row
assert row.get("is_read") is False, row
read = client.get(f"{READ_PATH}/{uid}", params={"folder": "INBOX"})
assert read.status_code == 200, read.text
opened = read.json()
assert opened.get("subject") == SUBJECT, opened
assert BODY in str(opened.get("body") or ""), opened
assert BODY in str(opened.get("body_html") or ""), opened
before = client.get(UNREAD_STATE_PATH, params={"folder": "INBOX"})
assert before.status_code == 200, before.text
assert before.json().get("unread_count") == 1, before.text
marked = client.post(f"{MARK_READ_PATH}/{uid}", params={"folder": "INBOX"})
assert marked.status_code == 200, marked.text
after = client.get(UNREAD_STATE_PATH, params={"folder": "INBOX"})
assert after.json().get("unread_count") == 0, after.text
+40
View File
@@ -0,0 +1,40 @@
"""Memory: a stored fact is listed, found by search, and gone after delete.
Keyword mode is enough here on purpose. The memory store degrades to
keyword matching when no vector service answers, and that degraded path
is the one a clean checkout actually runs, so it is the one worth
smoking.
"""
from __future__ import annotations
MEMORY_PATH = "/api/memory"
ADD_PATH = "/api/memory/add"
SEARCH_PATH = "/api/memory/search"
# A token that cannot collide with a real memory on a scratch instance.
TOKEN = "odysseus-smoke-marker-quintile"
TEXT = f"The release smoke suite stored the token {TOKEN} as a fact."
def test_a_memory_round_trips(client):
created = client.post(ADD_PATH, json={"text": TEXT, "category": "fact"})
assert created.status_code == 200, created.text
assert created.json().get("ok") is True, created.text
listed = client.get(MEMORY_PATH)
assert listed.status_code == 200, listed.text
matching = [m for m in listed.json().get("memory") or [] if TOKEN in str(m.get("text"))]
assert matching, [m.get("text") for m in listed.json().get("memory") or []]
memory_id = matching[0]["id"]
try:
found = client.post(SEARCH_PATH, data={"query": TOKEN})
assert found.status_code == 200, found.text
hits = [m for m in found.json().get("memories") or [] if TOKEN in str(m.get("text"))]
assert hits, found.text
finally:
removed = client.delete(f"{MEMORY_PATH}/{memory_id}")
assert removed.status_code == 200, removed.text
remaining = client.get(MEMORY_PATH).json().get("memory") or []
assert memory_id not in [m.get("id") for m in remaining]
+32
View File
@@ -0,0 +1,32 @@
"""Notes: a note created through the API is readable, editable and gone after delete."""
from __future__ import annotations
NOTES_PATH = "/api/notes"
TITLE = "Odysseus smoke note"
BODY = "Created by the release smoke suite."
EDITED_BODY = "Edited by the release smoke suite."
def test_a_note_round_trips(client):
created = client.post(NOTES_PATH, json={"title": TITLE, "content": BODY})
assert created.status_code == 200, created.text
note_id = created.json()["id"]
try:
listed = client.get(NOTES_PATH)
assert listed.status_code == 200, listed.text
titles = [n.get("title") for n in listed.json().get("notes") or []]
assert TITLE in titles, titles
read = client.get(f"{NOTES_PATH}/{note_id}")
assert read.status_code == 200, read.text
assert read.json().get("content") == BODY, read.text
edited = client.put(f"{NOTES_PATH}/{note_id}",
json={"title": TITLE, "content": EDITED_BODY})
assert edited.status_code == 200, edited.text
assert client.get(f"{NOTES_PATH}/{note_id}").json()["content"] == EDITED_BODY
finally:
removed = client.delete(f"{NOTES_PATH}/{note_id}")
assert removed.status_code == 200, removed.text
assert client.get(f"{NOTES_PATH}/{note_id}").status_code == 404

Some files were not shown because too many files have changed in this diff Show More