mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-08 07:52:20 +02:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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 }], []), []);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(); }
|
||||
@@ -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();}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
Vendored
+12
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -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
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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()]
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\\"/g, """) };'
|
||||
);
|
||||
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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
@@ -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('<script>'));
|
||||
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); });
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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/`.
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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 []]
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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={})
|
||||
@@ -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")})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
Reference in New Issue
Block a user