mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-31 02:02:22 +01:00
Merge remote-tracking branch 'origin/dev' into fix/pr-6080-llama-recovery
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,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const origin = process.env.ODYSSEUS_NOTES_RUNTIME_ORIGIN;
|
||||
const evidence = process.env.NOTES_NATIVE_EVIDENCE_DIR;
|
||||
if (evidence) fs.mkdirSync(evidence, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const credentials = { username: 'notes-reference', password: randomUUID() + 'Aa1!' };
|
||||
try {
|
||||
for (const mobile of [false, true]) {
|
||||
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, hasTouch: mobile, isMobile: mobile });
|
||||
const page = await context.newPage();
|
||||
if (!mobile) {
|
||||
const setup = await context.request.post(`${origin}/api/auth/setup`, { data: credentials });
|
||||
assert.equal(setup.ok(), true, await setup.text());
|
||||
}
|
||||
const login = await context.request.post(`${origin}/api/auth/login`, { data: credentials });
|
||||
assert.equal(login.ok(), true, await login.text());
|
||||
const response = await context.request.post(`${origin}/api/notes`, { data: { title: mobile ? 'Mobile reference' : 'Desktop reference', content: 'Native API draft', note_type: 'note', label: 'reference' } });
|
||||
assert.equal(response.ok(), true, await response.text());
|
||||
const created = await response.json();
|
||||
assert.ok(created.id);
|
||||
assert.equal(created.owner, credentials.username);
|
||||
await page.goto(origin);
|
||||
await page.waitForFunction(() => window.notesModule?.openPanel);
|
||||
await page.evaluate(() => { localStorage.setItem('odysseus-notes-first-open-hint-v1', '1'); window.notesModule.openPanel(); });
|
||||
const card = page.locator(`.note-card[data-note-id="${created.id}"]`);
|
||||
await card.waitFor();
|
||||
if (evidence) await page.screenshot({ path: path.join(evidence, mobile ? 'native-mobile.png' : 'native-desktop.png'), fullPage: true, animations: 'disabled' });
|
||||
await page.locator('#notes-select-btn').click();
|
||||
await card.locator('.note-card-cb').check({ force: mobile });
|
||||
await page.locator('#notes-send-agent').click();
|
||||
assert.ok(await page.locator('.note-card-selectmode').count());
|
||||
await page.evaluate(async () => {
|
||||
const sessions = await import('/static/js/sessions.js');
|
||||
sessions.createDirectChat('http://127.0.0.1:1', 'reference-model', null);
|
||||
});
|
||||
await page.locator('#notes-send-agent').click();
|
||||
assert.equal(await page.locator('.note-card-selectmode').count(), 0);
|
||||
assert.equal(await page.locator('#selected-notes-context-count').textContent(), '1 note selected');
|
||||
await page.evaluate(async () => (await import('/static/js/sessions.js')).createDirectChat('http://127.0.0.1:1', 'another-reference-model', null));
|
||||
assert.equal(await page.locator('#selected-notes-context-count').textContent(), '');
|
||||
if (mobile) await card.locator('.note-card-title').tap();
|
||||
else await card.locator('.note-card-title').click();
|
||||
if (mobile) await page.locator('.note-form-content-reader').tap({ position: { x: 5, y: 5 } });
|
||||
await page.locator('.note-form-content').fill('Saved by the native Notes application');
|
||||
await page.waitForFunction(id => !localStorage.getItem('odysseus-note-draft-' + id), created.id);
|
||||
const fetched = await context.request.get(`${origin}/api/notes`);
|
||||
const notes = (await fetched.json()).notes;
|
||||
assert.equal(notes.find(note => note.id === created.id).content, 'Saved by the native Notes application');
|
||||
await page.evaluate(() => window.notesModule.closePanel());
|
||||
await page.locator('#notes-pane').waitFor({ state: 'detached' });
|
||||
await context.close();
|
||||
console.log(JSON.stringify({ native: true, mobile, noteId: created.id, crud: true, selectedContext: true }));
|
||||
}
|
||||
const smoke = spawnSync(process.env.NOTES_TEST_PYTHON, ['-m', 'pytest', '-q', 'tests/smoke/test_notes_smoke.py'], {
|
||||
env: { ...process.env, APP_PORT: new URL(origin).port, ODYSSEUS_ADMIN_USER: credentials.username, ODYSSEUS_ADMIN_PASSWORD: credentials.password },
|
||||
encoding: 'utf8', timeout: 90000,
|
||||
});
|
||||
assert.equal(smoke.status, 0, smoke.stdout + smoke.stderr);
|
||||
console.log('PASS: original Notes release smoke test against the disposable authenticated instance');
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from 'playwright';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
|
||||
const manifest = JSON.parse(fs.readFileSync(new URL('../../frontend/modules.json', import.meta.url), 'utf8'));
|
||||
const outputs = manifest.modules.map(entry => '/static/' + entry.deployment);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({ serviceWorkers: 'allow' });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${origin}/static/js/panels.js`);
|
||||
const cached = await page.evaluate(async outputs => {
|
||||
await navigator.serviceWorker.register('/static/sw.js');
|
||||
await navigator.serviceWorker.ready;
|
||||
if (!navigator.serviceWorker.controller) await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
|
||||
const missing = [];
|
||||
for (const output of outputs) if (!(await caches.match(output))?.ok) missing.push(output);
|
||||
return missing;
|
||||
}, outputs);
|
||||
assert.deepEqual(cached, []);
|
||||
assert.equal(await page.evaluate(async () => {
|
||||
const shared = await import('/static/js/shared/ui/confetti.js');
|
||||
const compare = await import('/static/js/compare/vote.js?v=20260828resendcaldrag1');
|
||||
return shared.spawnConfetti === compare.spawnConfetti;
|
||||
}), true);
|
||||
await context.setOffline(true);
|
||||
const offline = await context.newPage();
|
||||
offline.on('requestfailed', request => console.log('offline failed:', request.url(), request.failure()));
|
||||
offline.on('console', message => { if (message.type() === 'error') console.log('browser:', message.text()); });
|
||||
await offline.goto(`${origin}/static/js/panels.js`);
|
||||
const result = await offline.evaluate(async () => {
|
||||
const facade = await import('/static/js/notes.js?v=20260911notesselectioncancel1');
|
||||
const feature = await import('/static/js/features/notes/index.js');
|
||||
return { identity: facade.default === feature.default && window.notesModule === feature.default, closed: facade.isPanelOpen() === false, exports: Object.keys(facade).sort() };
|
||||
});
|
||||
assert.equal(result.identity && result.closed, true);
|
||||
console.log(JSON.stringify({ ...result, maintainedPrecache: outputs.length }));
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
|
||||
const evidence = process.env.NOTES_EVIDENCE_DIR;
|
||||
if (evidence) fs.mkdirSync(evidence, { recursive: true });
|
||||
const baseline = process.env.NOTES_BASELINE === '1';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const device of ['desktop', 'mobile', 'tablet']) {
|
||||
const mobile = device === 'mobile';
|
||||
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : device === 'tablet' ? { width: 1024, height: 800 } : { width: 1440, height: 1000 }, hasTouch: device !== 'desktop', isMobile: mobile });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.stack));
|
||||
if (baseline) {
|
||||
const body = execFileSync('git', ['show', '1cc56cb9:static/js/notes.js'], { encoding: 'utf8' });
|
||||
await page.route('**/static/js/notes.js*', route => route.fulfill({ contentType: 'text/javascript', body }));
|
||||
}
|
||||
const requests = [];
|
||||
const notes = [
|
||||
{ id: 'plain', title: 'Reference note', note_type: 'note', content: 'Visit https://example.org and keep this draft.', label: 'work', sort_order: 0 },
|
||||
{ id: 'todo', title: 'Reference checklist', note_type: 'todo', items: [{ text: 'First task', done: false }, { text: 'Second task', done: true }], sort_order: 1 },
|
||||
{ id: 'goal', title: 'Reference goal', note_type: 'goal', content: 'A bounded goal', items: [{ text: 'Next step', done: false }], sort_order: 2 },
|
||||
];
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request();
|
||||
requests.push({ url: new URL(request.url()).pathname, method: request.method(), body: request.postData() });
|
||||
const id = new URL(request.url()).pathname.split('/').at(-1);
|
||||
if (request.method() === 'PUT') Object.assign(notes.find(n => n.id === id) || {}, request.postDataJSON());
|
||||
await route.fulfill({ json: request.method() === 'GET' ? { notes } : notes.find(n => n.id === id) || {} });
|
||||
});
|
||||
await page.goto(`${origin}/static/js/panels.js`);
|
||||
const shell = fs.readFileSync(new URL('../../static/index.html', import.meta.url), 'utf8');
|
||||
const styles = [...shell.matchAll(/<link[^>]+rel="stylesheet"[^>]*>/g)].map(m => m[0]).join('\n');
|
||||
await page.setContent(`<html><head><meta name="viewport" content="width=device-width,initial-scale=1">${styles}</head><body><div id="chat-container"></div><div id="toast"></div><button id="tool-notes-btn"><span id="notes-new-note-btn"></span></button><button id="rail-notes"></button></body></html>`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('odysseus-notes-first-open-hint-v1', '1');
|
||||
window.referenceIntervals = [];
|
||||
window.referenceMouseups = new Set();
|
||||
const add = window.addEventListener.bind(window), remove = window.removeEventListener.bind(window);
|
||||
window.addEventListener = (type, fn, options) => { if (type === 'mouseup' && fn.name === '_end') window.referenceMouseups.add(fn); add(type, fn, options); };
|
||||
window.removeEventListener = (type, fn, options) => { if (type === 'mouseup' && fn.name === '_end') window.referenceMouseups.delete(fn); remove(type, fn, options); };
|
||||
const interval = window.setInterval.bind(window);
|
||||
window.setInterval = (fn, ms, ...args) => { const id = interval(fn, ms, ...args); window.referenceIntervals.push({ id, ms }); return id; };
|
||||
});
|
||||
const publicAPI = await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/notes.js?v=20260911notesselectioncancel1');
|
||||
const again = await import('/static/js/notes.js?v=20260911notesselectioncancel1');
|
||||
window.referenceNotes = mod;
|
||||
mod.openPanel();
|
||||
return { names: Object.keys(mod).sort(), defaults: Object.keys(mod.default).sort(), arities: Object.fromEntries(Object.keys(mod).filter(k => k !== 'default').map(k => [k, mod[k].length])), asyncMethods: Object.keys(mod).filter(k => mod[k]?.constructor?.name === 'AsyncFunction').sort(), singleton: again === mod, bridge: window.notesModule === mod.default, aliases: mod.openNotes === mod.openPanel && mod.closeNotes === mod.closePanel && mod.isNotesOpen === mod.isPanelOpen && mod.default.openNotes === mod.openPanel };
|
||||
});
|
||||
assert.equal(publicAPI.singleton && publicAPI.bridge && publicAPI.aliases, true);
|
||||
assert.deepEqual(publicAPI.names, ['closeNotes', 'closePanel', 'default', 'dismissFiredReminderDot', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']);
|
||||
assert.deepEqual(publicAPI.defaults, ['closeNotes', 'closePanel', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']);
|
||||
assert.deepEqual(publicAPI.arities, { closeNotes: 1, closePanel: 1, dismissFiredReminderDot: 0, isNotesOpen: 0, isPanelOpen: 0, newNote: 0, openNote: 1, openNotes: 0, openPanel: 0, refreshDueBadge: 0, togglePanel: 0 });
|
||||
assert.deepEqual(publicAPI.asyncMethods, ['openNote', 'refreshDueBadge']);
|
||||
await page.locator('.note-card').first().waitFor();
|
||||
assert.equal(await page.locator('.note-card').count(), 3);
|
||||
if (evidence) await page.screenshot({ path: path.join(evidence, `notes-${device}.png`), fullPage: true, animations: 'disabled' });
|
||||
await page.locator('#notes-search').fill('checklist');
|
||||
assert.equal(await page.locator('.note-card').count(), 1);
|
||||
await page.locator('#notes-search').fill('');
|
||||
await page.locator('#notes-select-btn').click();
|
||||
await page.locator('.note-card-cb').first().check({ force: mobile });
|
||||
await page.locator('#notes-send-agent').click();
|
||||
assert.equal(await page.locator('.note-card-selectmode').count(), 3, 'rejected selection stays selected');
|
||||
await page.evaluate(() => window.addEventListener('odysseus:notes-context-selected', e => { window.referenceSelected = e.detail.noteIds; e.detail.accepted = true; }, { once: true }));
|
||||
await page.locator('#notes-send-agent').click();
|
||||
assert.equal(await page.locator('.note-card-selectmode').count(), 0);
|
||||
assert.equal((await page.evaluate(() => window.referenceSelected)).length, 1);
|
||||
await page.locator('#notes-search').fill('checklist');
|
||||
await page.evaluate(() => window.referenceNotes.closePanel());
|
||||
await page.locator('#notes-pane').waitFor({ state: 'detached' });
|
||||
await page.evaluate(() => window.referenceNotes.openPanel());
|
||||
await page.locator('.note-card').first().waitFor();
|
||||
assert.equal(await page.locator('#notes-search').inputValue(), '');
|
||||
assert.equal(await page.locator('.note-card').count(), 3);
|
||||
if (!baseline && device !== 'desktop') {
|
||||
const beginTouch = () => page.evaluate(() => {
|
||||
const card = document.querySelector('.note-card');
|
||||
const rect = card.getBoundingClientRect();
|
||||
const touch = new Touch({ identifier: 1, target: card, clientX: rect.left + 5, clientY: rect.top + 5 });
|
||||
card.dispatchEvent(new TouchEvent('touchstart', { touches: [touch], bubbles: true }));
|
||||
});
|
||||
if (device === 'tablet') {
|
||||
await beginTouch();
|
||||
await page.waitForFunction(() => document.querySelector('.note-card.dragging'));
|
||||
await page.evaluate(() => document.querySelector('.note-card.dragging').dispatchEvent(new TouchEvent('touchcancel', { bubbles: true })));
|
||||
await page.locator('#notes-search').fill('checklist');
|
||||
await page.locator('#notes-search').fill('');
|
||||
await beginTouch();
|
||||
await page.waitForFunction(() => document.querySelector('.note-card.dragging'));
|
||||
} else {
|
||||
await beginTouch(); // Close before the selection hold timer can fire.
|
||||
}
|
||||
await page.evaluate(() => window.referenceNotes.closePanel());
|
||||
await page.locator('#notes-pane').waitFor({ state: 'detached' });
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.style.touchAction), '');
|
||||
await page.evaluate(() => window.referenceNotes.openPanel());
|
||||
await page.locator('.note-card').first().waitFor();
|
||||
assert.equal(await page.locator('.note-card-selectmode').count(), 0);
|
||||
}
|
||||
if (mobile) await page.locator('[data-note-id="plain"] .note-card-title').tap();
|
||||
else await page.locator('[data-note-id="plain"] .note-card-title').click();
|
||||
if (mobile) await page.locator('.note-form-content-reader').tap({ position: { x: 5, y: 5 } });
|
||||
await page.locator('.note-form-content').fill('Recovered draft');
|
||||
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('odysseus-note-draft-plain')).content), 'Recovered draft');
|
||||
await page.waitForFunction(() => !localStorage.getItem('odysseus-note-draft-plain'));
|
||||
assert.ok(requests.some(r => r.method === 'PUT' && JSON.parse(r.body).content === 'Recovered draft'));
|
||||
await page.locator('.note-form-type-pill[data-type="draw"]').click();
|
||||
const canvas = page.locator('.note-form-canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
assert.ok(box);
|
||||
await page.mouse.move(box.x + 25, box.y + 25);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 55, box.y + 55, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await page.locator('.note-form-draw-undo').waitFor();
|
||||
assert.equal(await page.locator('.note-form-draw-undo').isDisabled(), false);
|
||||
await page.locator('.note-form-draw-undo').click();
|
||||
assert.equal(await page.locator('.note-form-draw-undo').isDisabled(), true);
|
||||
await page.locator('.note-form-type-pill[data-type="note"]').click();
|
||||
if (!baseline) await page.waitForFunction(() => window.referenceMouseups.size === 0);
|
||||
await page.locator('.note-form-remind-btn').click();
|
||||
await page.locator('.note-reminder-menu').waitFor();
|
||||
await page.evaluate(() => window.referenceNotes.closePanel());
|
||||
await page.locator('#notes-pane').waitFor({ state: 'detached' });
|
||||
if (!baseline) {
|
||||
assert.equal(await page.locator('.note-reminder-menu').count(), 0);
|
||||
assert.equal(await page.evaluate(() => window.referenceIntervals.filter(t => t.ms === 30000).length), 1);
|
||||
const identity = await page.evaluate(async () => {
|
||||
const feature = await import('/static/js/features/notes/index.js');
|
||||
const unversioned = await import('/static/js/notes.js');
|
||||
return feature.default === window.referenceNotes.default && unversioned.default === feature.default && feature.openPanel === window.referenceNotes.openPanel;
|
||||
});
|
||||
assert.equal(identity, true);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
console.log(JSON.stringify({ device, mobile, ...publicAPI, requests: requests.length }));
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
@@ -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,402 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
test('Chat Photo Editor Action Integrity and Turn Scoping', async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const serverResponses = new Map();
|
||||
const recordedUploadRequests = [];
|
||||
|
||||
await page.route('http://photo-test.local/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === '/') {
|
||||
return route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: `<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="toast"></div>
|
||||
<div id="chat-history"></div>
|
||||
</body>
|
||||
</html>`,
|
||||
});
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/upload/')) {
|
||||
const id = path.replace('/api/upload/', '');
|
||||
const method = route.request().method();
|
||||
recordedUploadRequests.push({ id, method });
|
||||
const status = serverResponses.get(id) || 200;
|
||||
if (status === 200) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': '16',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
body: method === 'HEAD' ? Buffer.alloc(0) : Buffer.from('fake-image-bytes'),
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ detail: 'Upload error or forbidden' }),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
|
||||
return route.fulfill({ contentType: 'text/javascript', body: source });
|
||||
} catch (err) {
|
||||
return route.abort();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('http://photo-test.local/');
|
||||
|
||||
const results = await page.evaluate(async () => {
|
||||
const box = document.getElementById('chat-history');
|
||||
|
||||
// Import chat.js to test hooks
|
||||
await import('/static/js/chat.js');
|
||||
const hooks = window.__chatPhotoEditorTestHooks;
|
||||
if (!hooks) throw new Error('Test hooks not exposed on window.__chatPhotoEditorTestHooks');
|
||||
|
||||
const { _isAuthorizedImageUrl, _findOriginatingUserMessage, _attachedImageForTurn, renderPhotoEditorApplyButton } = hooks;
|
||||
|
||||
// --- Scenario 1: Authorization checks ---
|
||||
const authValidUpload = _isAuthorizedImageUrl('/api/upload/img_123');
|
||||
const authValidGallery = _isAuthorizedImageUrl('/api/gallery/img_123');
|
||||
const authBlob = _isAuthorizedImageUrl('blob:http://photo-test.local/550e8400-e29b-41d4-a716-446655440000');
|
||||
const authCrossOriginBlob = _isAuthorizedImageUrl('blob:https://evil.com/550e8400-e29b-41d4-a716-446655440000');
|
||||
const authMalformedBlob = _isAuthorizedImageUrl('blob:javascript:alert(1)');
|
||||
const pixel = document.createElement('canvas'); pixel.width = pixel.height = 1;
|
||||
const authDataPng = _isAuthorizedImageUrl(pixel.toDataURL('image/png'));
|
||||
const authDataJpeg = _isAuthorizedImageUrl(pixel.toDataURL('image/jpeg'));
|
||||
const authDataWebp = _isAuthorizedImageUrl(pixel.toDataURL('image/webp'));
|
||||
const authDataGif = _isAuthorizedImageUrl('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
|
||||
const authDataSvg = _isAuthorizedImageUrl('data:image/svg+xml;base64,PHN2Zz4=');
|
||||
const authDataHtml = _isAuthorizedImageUrl('data:text/html;base64,PGgxPlRlc3Q=');
|
||||
const authDataMalformed = _isAuthorizedImageUrl('data:image/png;base64,invalid!@#$');
|
||||
const authDataOversized = _isAuthorizedImageUrl('data:image/png;base64,' + 'A'.repeat(11 * 1024 * 1024));
|
||||
const authMaliciousDomain = _isAuthorizedImageUrl('https://evil.com/api/upload/img_123');
|
||||
const authSchemeRelative = _isAuthorizedImageUrl('//evil.com/api/upload/img_123');
|
||||
const authJavascript = _isAuthorizedImageUrl('javascript:alert(1)');
|
||||
const authDirectoryTraversal = _isAuthorizedImageUrl('/api/upload/../../../etc/passwd');
|
||||
const authEncodedTraversal = _isAuthorizedImageUrl('/api/upload/%2e%2e%2f');
|
||||
|
||||
// --- Scenario 2: Multi-turn isolation ---
|
||||
// Build Turn 1: User attaches img_turn1
|
||||
const user1 = document.createElement('div');
|
||||
user1.className = 'msg msg-user';
|
||||
user1.innerHTML = '<div class="body">Turn 1 text</div><div class="attach-image-preview" data-file-id="img_turn1"><img src="/api/upload/img_turn1" /></div>';
|
||||
box.appendChild(user1);
|
||||
|
||||
const ai1 = document.createElement('div');
|
||||
ai1.className = 'msg msg-ai round-holder';
|
||||
ai1.innerHTML = '<div class="body">Turn 1 AI reply</div>';
|
||||
box.appendChild(ai1);
|
||||
|
||||
// Build Turn 2: User attaches NO photo (just text)
|
||||
const user2 = document.createElement('div');
|
||||
user2.className = 'msg msg-user';
|
||||
user2.innerHTML = '<div class="body">Turn 2 text without photo</div>';
|
||||
box.appendChild(user2);
|
||||
|
||||
const ai2 = document.createElement('div');
|
||||
ai2.className = 'msg msg-ai round-holder';
|
||||
ai2.innerHTML = '<div class="body">Turn 2 AI reply</div>';
|
||||
box.appendChild(ai2);
|
||||
|
||||
// Build Turn 3: User attaches img_turn3
|
||||
const user3 = document.createElement('div');
|
||||
user3.className = 'msg msg-user';
|
||||
user3.innerHTML = '<div class="body">Turn 3 text</div><div class="attach-card" data-file-id="img_turn3"><img src="/api/upload/img_turn3" /></div>';
|
||||
box.appendChild(user3);
|
||||
|
||||
const ai3 = document.createElement('div');
|
||||
ai3.className = 'msg msg-ai round-holder';
|
||||
ai3.innerHTML = '<div class="body">Turn 3 AI reply</div>';
|
||||
box.appendChild(ai3);
|
||||
|
||||
// Resolve images for each turn
|
||||
const turn1Resolved = _attachedImageForTurn(ai1, { kind: 'open_and_apply', prompt: 'edit turn 1' });
|
||||
const turn2Resolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', prompt: 'edit turn 2' });
|
||||
const turn3Resolved = _attachedImageForTurn(ai3, { kind: 'open_and_apply', prompt: 'edit turn 3' });
|
||||
|
||||
// Action with explicit imageId should honor explicit id
|
||||
const explicitActionResolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', imageId: 'explicit_999' });
|
||||
const filenameResolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', imageId: 'explicit_999.png' });
|
||||
const invalidExplicitResolved = _attachedImageForTurn(ai1, { kind: 'open_and_apply', imageUrl: 'data:image/png;base64,=' });
|
||||
|
||||
// Action on container with no user message above
|
||||
const detachedAi = document.createElement('div');
|
||||
detachedAi.className = 'msg msg-ai';
|
||||
const detachedResolved = _attachedImageForTurn(detachedAi, { kind: 'open_and_apply' });
|
||||
|
||||
return {
|
||||
authValidUpload,
|
||||
authValidGallery,
|
||||
authBlob,
|
||||
authCrossOriginBlob,
|
||||
authMalformedBlob,
|
||||
authDataPng,
|
||||
authDataJpeg,
|
||||
authDataWebp,
|
||||
authDataGif,
|
||||
authDataSvg,
|
||||
authDataHtml,
|
||||
authDataMalformed,
|
||||
authDataOversized,
|
||||
authMaliciousDomain,
|
||||
authSchemeRelative,
|
||||
authJavascript,
|
||||
authDirectoryTraversal,
|
||||
authEncodedTraversal,
|
||||
turn1Resolved,
|
||||
turn2Resolved,
|
||||
turn3Resolved,
|
||||
explicitActionResolved,
|
||||
filenameResolved,
|
||||
invalidExplicitResolved,
|
||||
detachedResolved,
|
||||
};
|
||||
});
|
||||
|
||||
// Verify Scenario 1: Authorization
|
||||
assert.equal(results.authValidUpload, true, 'Valid same-origin upload is authorized');
|
||||
assert.equal(results.authValidGallery, true, 'Valid same-origin gallery is authorized');
|
||||
assert.equal(results.authBlob, false, 'Same-origin alone does not authorize a blob URL');
|
||||
assert.equal(results.authCrossOriginBlob, false, 'Cross-origin blob URL is rejected');
|
||||
assert.equal(results.authMalformedBlob, false, 'Malformed blob URL is rejected');
|
||||
assert.equal(results.authDataPng, true, 'PNG data URL is authorized');
|
||||
assert.equal(results.authDataJpeg, true, 'JPEG data URL is authorized');
|
||||
assert.equal(results.authDataWebp, true, 'WebP data URL is authorized');
|
||||
assert.equal(results.authDataGif, true, 'GIF data URL is authorized');
|
||||
assert.equal(results.authDataSvg, false, 'SVG data URL is rejected');
|
||||
assert.equal(results.authDataHtml, false, 'HTML data URL is rejected');
|
||||
assert.equal(results.authDataMalformed, false, 'Malformed data URL is rejected');
|
||||
assert.equal(results.authDataOversized, false, 'Oversized data URL > 10MB is rejected');
|
||||
assert.equal(results.authMaliciousDomain, false, 'External malicious domain rejected');
|
||||
assert.equal(results.authSchemeRelative, false, 'Scheme-relative URL rejected');
|
||||
assert.equal(results.authJavascript, false, 'Javascript scheme rejected');
|
||||
assert.equal(results.authDirectoryTraversal, false, 'Directory traversal in path rejected');
|
||||
assert.equal(results.authEncodedTraversal, false, 'Encoded directory traversal in path rejected');
|
||||
|
||||
// Verify Scenario 2: Multi-turn isolation
|
||||
assert.equal(results.turn1Resolved, '/api/upload/img_turn1', 'Turn 1 AI correctly resolves Turn 1 user image');
|
||||
assert.equal(results.turn2Resolved, null, 'Turn 2 AI resolves NULL because Turn 2 had no attached image');
|
||||
assert.equal(results.turn3Resolved, '/api/upload/img_turn3', 'Turn 3 AI correctly resolves Turn 3 user image');
|
||||
assert.equal(results.explicitActionResolved, '/api/upload/explicit_999', 'Explicit action imageId honored');
|
||||
assert.equal(results.filenameResolved, '/api/upload/explicit_999.png', 'Canonical filenames keep their extensions');
|
||||
assert.equal(results.invalidExplicitResolved, null, 'Invalid explicit data never falls back to a different image');
|
||||
assert.equal(results.detachedResolved, null, 'Detached AI container without user message resolves to null');
|
||||
|
||||
// --- Scenario 3: Button click lifecycle (deleted upload, rapid click, open apply) ---
|
||||
const clickResults = await page.evaluate(async () => {
|
||||
const hooks = window.__chatPhotoEditorTestHooks;
|
||||
const { renderPhotoEditorApplyButton } = hooks;
|
||||
|
||||
let editorCalls = [];
|
||||
window.gePhotoEditorApply = async (opts) => {
|
||||
editorCalls.push(opts);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
};
|
||||
|
||||
let errorsShown = [];
|
||||
const fakeUi = {
|
||||
showError(msg) { errorsShown.push(msg); },
|
||||
showToast(msg) {},
|
||||
};
|
||||
|
||||
// Case A: Missing attachment on Turn 2
|
||||
const ai2 = document.querySelectorAll('.msg-ai')[1];
|
||||
renderPhotoEditorApplyButton(ai2, { kind: 'open_and_apply', prompt: 'test turn 2' });
|
||||
const btn2 = ai2.querySelector('.chat-open-in-editor-btn');
|
||||
btn2.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const btn2DisabledAfterMissing = btn2.disabled;
|
||||
|
||||
// Case B: Deleted upload (server returns 404)
|
||||
const userDel = document.createElement('div');
|
||||
userDel.className = 'msg msg-user';
|
||||
userDel.innerHTML = '<div class="attach-image-preview" data-file-id="img_deleted"><img src="/api/upload/img_deleted" /></div>';
|
||||
document.getElementById('chat-history').appendChild(userDel);
|
||||
|
||||
const aiDel = document.createElement('div');
|
||||
aiDel.className = 'msg msg-ai round-holder';
|
||||
document.getElementById('chat-history').appendChild(aiDel);
|
||||
|
||||
renderPhotoEditorApplyButton(aiDel, { kind: 'open_and_apply', prompt: 'test deleted' });
|
||||
const btnDel = aiDel.querySelector('.chat-open-in-editor-btn');
|
||||
|
||||
return {
|
||||
hasBtn2: !!btn2,
|
||||
btn2DisabledAfterMissing,
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(clickResults.hasBtn2, true);
|
||||
assert.equal(clickResults.btn2DisabledAfterMissing, false, 'Button re-enabled after missing attachment');
|
||||
|
||||
// Set server status 404 for img_deleted
|
||||
serverResponses.set('img_deleted', 404);
|
||||
|
||||
const deletedResults = await page.evaluate(async () => {
|
||||
const aiDel = document.querySelectorAll('.msg-ai')[3];
|
||||
const btnDel = aiDel.querySelector('.chat-open-in-editor-btn');
|
||||
btnDel.click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
return {
|
||||
disabled: btnDel.disabled,
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(deletedResults.disabled, false, 'Button re-enabled when server returns 404');
|
||||
|
||||
// Case C: Valid attachment on Turn 1 click launches editor
|
||||
const validResults = await page.evaluate(async () => {
|
||||
let editorCalls = [];
|
||||
window.gePhotoEditorApply = async (opts) => {
|
||||
editorCalls.push(opts);
|
||||
};
|
||||
|
||||
const ai1 = document.querySelectorAll('.msg-ai')[0];
|
||||
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
|
||||
renderPhotoEditorApplyButton(ai1, { kind: 'open_and_apply', prompt: 'make b&w' });
|
||||
const btn1 = ai1.querySelector('.chat-open-in-editor-btn');
|
||||
|
||||
// Rapid clicks: trigger multiple synchronous clicks
|
||||
btn1.click();
|
||||
btn1.click();
|
||||
btn1.click();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
return {
|
||||
editorCallsCount: editorCalls.length,
|
||||
editorCall: editorCalls[0],
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(validResults.editorCallsCount, 1, 'Rapid clicks deduplicated, editor called exactly once');
|
||||
assert.equal(validResults.editorCall.imageUrl, '/api/upload/img_turn1', 'Editor opened with Turn 1 image');
|
||||
assert.equal(validResults.editorCall.prompt, 'make b&w');
|
||||
|
||||
// Case D: Verify HEAD probe was used for verification
|
||||
const headProbeObserved = recordedUploadRequests.some((r) => r.id === 'img_turn1' && r.method === 'HEAD');
|
||||
assert.equal(headProbeObserved, true, 'Chat-to-editor preflight executed an authorized HEAD probe');
|
||||
|
||||
// Case E: Unauthorized upload returns 403, surfaces error and re-enables button
|
||||
serverResponses.set('img_forbidden', 403);
|
||||
const forbiddenResults = await page.evaluate(async () => {
|
||||
const userForbidden = document.createElement('div');
|
||||
userForbidden.className = 'msg msg-user';
|
||||
userForbidden.innerHTML = '<div class="attach-image-preview" data-file-id="img_forbidden"><img src="/api/upload/img_forbidden" /></div>';
|
||||
document.getElementById('chat-history').appendChild(userForbidden);
|
||||
|
||||
const aiForbidden = document.createElement('div');
|
||||
aiForbidden.className = 'msg msg-ai round-holder';
|
||||
document.getElementById('chat-history').appendChild(aiForbidden);
|
||||
|
||||
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
|
||||
renderPhotoEditorApplyButton(aiForbidden, { kind: 'open_and_apply', prompt: 'forbidden test' });
|
||||
const btnForb = aiForbidden.querySelector('.chat-open-in-editor-btn');
|
||||
btnForb.click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
return {
|
||||
disabled: btnForb.disabled,
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(forbiddenResults.disabled, false, 'Button re-enabled when server returns 403');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('editor actions validate raster bytes, dimensions, decode and reject all unverified blobs before launch', async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const apiRequests = [];
|
||||
await page.route('http://raster.local/**', async route => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<div id="chat-history"></div>' });
|
||||
if (path === '/api/gallery/owned') { apiRequests.push(path); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ id: 'owned', url: '/api/generated-image/owned.png' }) }); }
|
||||
if (path === '/api/gallery/foreign') { apiRequests.push(path); return route.fulfill({ status: 404, body: '' }); }
|
||||
if (path.startsWith('/api/upload/')) { apiRequests.push(path); return route.fulfill({ status: 200, headers: { 'Content-Type': 'image/png' }, body: '' }); }
|
||||
if (path.startsWith('/api/')) return route.fulfill({ contentType: 'application/json', body: '{}' });
|
||||
try { return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') }); }
|
||||
catch { return route.fulfill({ status: 404, body: '' }); }
|
||||
});
|
||||
await page.goto('http://raster.local/');
|
||||
const results = await page.evaluate(async () => {
|
||||
await import('/static/js/chat.js');
|
||||
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
|
||||
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 2;
|
||||
canvas.getContext('2d').fillRect(0, 0, 2, 2);
|
||||
const png = canvas.toDataURL('image/png');
|
||||
const bytes = Uint8Array.from(atob(png.split(',')[1]), ch => ch.charCodeAt(0));
|
||||
const encode = bytes => { let text = ''; for (const byte of bytes) text += String.fromCharCode(byte); return 'data:image/png;base64,' + btoa(text); };
|
||||
const headerDimensions = (w, h) => { const b = bytes.slice(); const v = new DataView(b.buffer); v.setUint32(16, w); v.setUint32(20, h); return encode(b); };
|
||||
const invalidDecode = bytes.slice(0, 33); // valid signature/dimensions but no pixel data
|
||||
const ownBlob = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }));
|
||||
const revoked = URL.createObjectURL(new Blob([bytes], { type: 'image/png' })); URL.revokeObjectURL(revoked);
|
||||
// This action intentionally has no blob registration API. Even application-created
|
||||
// object URLs must use their authorized server attachment or raster data source.
|
||||
const cases = [
|
||||
['upload', '/api/upload/owned', true], ['gallery', '/api/gallery/owned', true],
|
||||
['png', png, true], ['jpeg', canvas.toDataURL('image/jpeg'), true], ['webp', canvas.toDataURL('image/webp'), true],
|
||||
['gif', 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', true],
|
||||
['padding-one', 'data:image/png;base64,=', false], ['padding-length', 'data:image/png;base64,A', false],
|
||||
['padding-middle', 'data:image/png;base64,AA=AA', false], ['padding-excess', 'data:image/png;base64,AAAA====', false], ['foreign-gallery', '/api/gallery/foreign', false],
|
||||
['pad-bits', 'data:image/png;base64,AB==', false], ['bytes', 'data:image/png;base64,AAAA', false],
|
||||
['mismatch', png.replace('image/png', 'image/jpeg'), false], ['truncated-image', encode(invalidDecode), false],
|
||||
['svg', 'data:image/svg+xml;base64,PHN2Zy8+', false], ['html', 'data:text/html;base64,PGgxLz4=', false],
|
||||
['svg-disguised', 'data:image/png;base64,PHN2Zy8+', false],
|
||||
['foreign-blob', 'blob:https://foreign.invalid/deadbeef', false], ['arbitrary-blob', 'blob:http://raster.local/deadbeef', false],
|
||||
['application-created-blob', ownBlob, false], ['revoked-blob', revoked, false],
|
||||
['oversized-url', 'data:image/png;base64,' + 'A'.repeat(10 * 1024 * 1024), false],
|
||||
['oversized-decoded-bytes', 'data:image/png;base64,' + 'A'.repeat(4 * Math.ceil((7 * 1024 * 1024 + 3) / 3)), false],
|
||||
['dimension', headerDimensions(8193, 1), false], ['pixel-budget', headerDimensions(8192, 8192), false],
|
||||
['scheme-relative', '//raster.local/api/upload/owned', false], ['encoded-traversal', '/api/upload/%2e%2e/owned', false],
|
||||
];
|
||||
const oldDecode = window.createImageBitmap; let decodes = 0;
|
||||
window.createImageBitmap = (...args) => { decodes++; return oldDecode(...args); };
|
||||
let launches = []; window.gePhotoEditorApply = async opts => launches.push(opts.imageUrl);
|
||||
const output = [];
|
||||
for (const [name, url, accepted] of cases) {
|
||||
const host = document.createElement('div'); document.body.append(host);
|
||||
renderPhotoEditorApplyButton(host, { kind: 'open_and_apply', imageUrl: url, prompt: 'synthetic edit' });
|
||||
const btn = host.querySelector('button'); const before = launches.length; const decodeBefore = decodes;
|
||||
btn.click(); btn.click(); btn.click();
|
||||
while (btn.disabled) await new Promise(resolve => setTimeout(resolve, 5));
|
||||
output.push({ name, accepted, launches: launches.length - before, decodes: decodes - decodeBefore }); host.remove();
|
||||
}
|
||||
// Cache reuse across repeated activation of the same complete inline image.
|
||||
const host = document.createElement('div'); document.body.append(host);
|
||||
renderPhotoEditorApplyButton(host, { kind: 'open_and_apply', imageUrl: png, prompt: 'repeat' });
|
||||
const btn = host.querySelector('button'); const before = decodes;
|
||||
for (let i = 0; i < 3; i++) { btn.click(); while (btn.disabled) await new Promise(resolve => setTimeout(resolve, 5)); }
|
||||
const cachedDecodes = decodes - before;
|
||||
URL.revokeObjectURL(ownBlob);
|
||||
return { cases: output, cachedDecodes, launches };
|
||||
});
|
||||
assert.deepEqual(results.cases.filter(item => item.launches !== (item.accepted ? 1 : 0)).map(item => item.name), [], 'every raster authorization case must match');
|
||||
for (const name of ['dimension', 'pixel-budget', 'oversized-url', 'oversized-decoded-bytes', 'bytes', 'mismatch']) {
|
||||
assert.equal(results.cases.find(item => item.name === name).decodes, 0, name + ' rejected before browser allocation');
|
||||
}
|
||||
assert.equal(results.cases.find(item => item.name === 'truncated-image').decodes, 1, 'actual decoder rejects incomplete image');
|
||||
assert.equal(results.cachedDecodes, 1, 'same image only decoded once for repeated activation');
|
||||
assert.deepEqual(apiRequests, ['/api/upload/owned', '/api/gallery/owned', '/api/gallery/foreign']);
|
||||
assert.ok(results.launches.includes('http://raster.local/api/generated-image/owned.png'));
|
||||
} finally { await browser.close(); }
|
||||
});
|
||||
@@ -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")
|
||||
@@ -0,0 +1,204 @@
|
||||
const { chromium } = require('playwright');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const assert = require('node:assert/strict');
|
||||
const { extractThemeBootstrap } = require('./helpers/theme_bootstrap.cjs');
|
||||
|
||||
(async () => {
|
||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
const svgRequests = [];
|
||||
const svgFailures = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
page.on('request', request => { if (request.url().includes('example.invalid/security-probe')) svgRequests.push(request.url()); });
|
||||
page.on('requestfailed', request => { if (request.url().includes('example.invalid/security-probe')) svgFailures.push(request.failure().errorText); });
|
||||
await page.route('**/security-harness', route => route.fulfill({ contentType: 'text/html', body:
|
||||
'<!doctype html><div id="toast"></div><div id="sidebar"></div><div id="chat-container"></div><div id="chat-history"></div><div id="adm-epList-local"></div><div id="adm-epList-api"></div>' }));
|
||||
const secrets = { access_token: 'ACCESS-SENTINEL', refresh_token: 'REFRESH-SENTINEL', api_key: 'KEY-SENTINEL', password: 'PASSWORD-SENTINEL' };
|
||||
const endpoints = ['a', 'b'].map(id => ({ id: 'endpoint-' + id, provider_auth_id: 'session-' + id,
|
||||
provider: 'chatgpt-subscription', name: 'ChatGPT · LABEL-SENTINEL', category: 'api',
|
||||
base_url: 'https://chatgpt.com/backend-api/codex', is_enabled: true, online: true, models: [], ...secrets }));
|
||||
await page.route('**/api/**', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === '/api/model-endpoints') return route.fulfill({ json: endpoints });
|
||||
if (url.pathname.endsWith('/usage')) return route.fulfill({ json: { available: true, usage: { limits: [] }, ...secrets } });
|
||||
if (url.pathname.includes('/device/')) return route.fulfill({ status: 400, json: { detail: 'Fixture declines device flow' } });
|
||||
return route.fulfill({ json: { fonts: {}, value: null, tools: [], models: [] } });
|
||||
});
|
||||
// Expose the real internal loader only in this served test copy.
|
||||
await page.route('**/static/js/admin-codeql-harness.js', route => route.fulfill({ contentType: 'application/javascript',
|
||||
body: readFileSync('static/js/admin.js', 'utf8') + '\nexport { loadEndpoints };\n' }));
|
||||
await page.route('**/static/js/sessions-codeql-harness.js', route => route.fulfill({ contentType: 'application/javascript',
|
||||
body: readFileSync('static/js/sessions.js', 'utf8') + '\nexport { moveToFolder };\n' }));
|
||||
await page.goto(origin + '/security-harness');
|
||||
|
||||
const rendered = await page.evaluate(async () => {
|
||||
const { addMessage } = await import('/static/js/chatRenderer.js');
|
||||
const markdown = await import('/static/js/markdown.js');
|
||||
window.executed = 0;
|
||||
const payloads = [
|
||||
'<img src=x onerror="window.executed++">',
|
||||
'<svg onload="parent.executed++"><script>parent.executed++</script></svg>',
|
||||
'<script>window.executed++</script>',
|
||||
'\"\'><img src=x onerror="window.executed++"> & <angle>',
|
||||
'<details><summary>Nested</summary><img src=x onerror="window.executed++"><a href="javascript:window.executed++">link</a><svg onload="window.executed++"></svg></details>',
|
||||
'<think><img src=x onerror="window.executed++"></think>**Valid** instruction',
|
||||
];
|
||||
const results = [];
|
||||
for (const payload of payloads) {
|
||||
const message = addMessage('user', 'In the document, edit this specific text (line 1):\n```\nselected\n```\n\nInstruction: ' + payload);
|
||||
if (!message) throw new Error('addMessage failed');
|
||||
const body = message.querySelector('.body');
|
||||
results.push({ tag: body.querySelector('.doc-edit-tag')?.dataset.docEditRef,
|
||||
unsafe: body.querySelectorAll('script, svg[onload], [onerror], [onload], a[href^="javascript:"]').length });
|
||||
const direct = document.createElement('div');
|
||||
direct.innerHTML = markdown.processWithThinking(payload);
|
||||
results.push({ unsafe: direct.querySelectorAll('script, [onerror], [onload], a[href^="javascript:"]').length });
|
||||
message.remove();
|
||||
}
|
||||
const valid = addMessage('user', 'In the document, edit this specific text (lines 1–2):\n```\nselected\n```\n\nInstruction: **Keep bold** and `code`');
|
||||
const titles = [
|
||||
{ source: '<svg xmlns="http://www.w3.org/2000/svg"><title>Valid & safe</title></svg>', title: 'Valid & safe' },
|
||||
{ source: '<svg><title>Numeric <safe> & sound</title></svg>', title: 'Numeric <safe> & sound' },
|
||||
{ source: '<svg><title>Nested <b>bold</b> & text</title></svg>', title: 'Visual explanation' },
|
||||
{ source: '<svg><title>\" onload=\"parent.executed++ <script></title></svg>', title: '\" onload=\"parent.executed++ <script>' },
|
||||
{ source: '<svg><title>Malformed <b>nested</title ></svg>', title: 'Visual explanation' },
|
||||
{ source: '<svg><title><script>parent.executed++</script><b onload="parent.executed++">nested</b></title></svg>', title: 'Visual explanation' },
|
||||
{ source: '<svg><title><![CDATA["><img src=x onerror="parent.executed++">]]></title></svg>', title: 'Visual explanation' },
|
||||
{ source: '<svg><title></title><img src=x onerror="parent.executed++"></svg>', title: 'Visual explanation' },
|
||||
{ source: '<svg><title> </title></svg>', title: 'Visual explanation' },
|
||||
{ source: '<svg><text>No title</text></svg>', title: 'Visual explanation' },
|
||||
].map(({ source, title }) => {
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = markdown.mdToHtml('```svg\n' + source + '\n```');
|
||||
document.body.appendChild(host);
|
||||
const frame = host.querySelector('iframe');
|
||||
return { expected: title, actual: frame.title, sandbox: frame.getAttribute('sandbox'),
|
||||
referrer: frame.referrerPolicy, onload: frame.hasAttribute('onload'),
|
||||
csp: new DOMParser().parseFromString(frame.srcdoc, 'text/html').querySelector('meta[http-equiv="Content-Security-Policy"]').content };
|
||||
});
|
||||
const svgHost = document.createElement('div');
|
||||
svgHost.innerHTML = markdown.mdToHtml('```svg\n<svg onload="parent.executed++"><title>Attack</title><script>parent.executed++</script><image href="https://example.invalid/security-probe"/></svg>\n```');
|
||||
document.body.appendChild(svgHost);
|
||||
const directValid = document.createElement('div');
|
||||
directValid.innerHTML = markdown.processWithThinking('**Keep bold** and `code`');
|
||||
return { results, titles, instruction: valid.querySelector('.body').textContent,
|
||||
bold: directValid.querySelector('strong')?.textContent, code: directValid.querySelector('code')?.textContent };
|
||||
});
|
||||
assert(rendered.results.every(result => result.unsafe === 0));
|
||||
assert(rendered.results.filter((result, index) => index % 2 === 0).every(result => result.tag === 'line 1'));
|
||||
assert(rendered.instruction.includes('Keep bold and code'));
|
||||
assert.equal(rendered.bold, 'Keep bold');
|
||||
assert.equal(rendered.code, 'code');
|
||||
for (const title of rendered.titles) {
|
||||
assert.equal(title.actual, title.expected);
|
||||
assert.equal(title.sandbox, '');
|
||||
assert.equal(title.referrer, 'no-referrer');
|
||||
assert.equal(title.onload, false);
|
||||
assert.equal(title.csp, "default-src 'none'; img-src 'none'; media-src 'none'; font-src 'none'; style-src 'unsafe-inline'");
|
||||
}
|
||||
|
||||
const folderRequest = page.waitForRequest(request => request.method() === 'PATCH');
|
||||
const sessionId = 'session/other?folder=bad#fragment%value';
|
||||
await page.evaluate(async id => {
|
||||
const { moveToFolder } = await import('/static/js/sessions-codeql-harness.js');
|
||||
await moveToFolder(id, 'Safe folder');
|
||||
}, sessionId);
|
||||
const folderUrl = new URL((await folderRequest).url());
|
||||
assert.equal(folderUrl.pathname, '/api/session/' + encodeURIComponent(sessionId));
|
||||
assert.equal(folderUrl.search, '');
|
||||
assert.equal(folderUrl.hash, '');
|
||||
|
||||
const menus = await page.evaluate(async () => {
|
||||
const { _showReaderMoreMenu } = await import('/static/js/emailLibrary/menus.js');
|
||||
const { _safeRenderEmailBody } = await import('/static/js/emailLibrary/bodyRender.js');
|
||||
const reader = document.createElement('div');
|
||||
const anchor = document.createElement('button');
|
||||
document.body.append(reader, anchor);
|
||||
const labels = ['<img src=x onerror="window.executed++">', '<svg onload="window.executed++">', '<script>window.executed++</script>', '\" & <angle>', '<b><i>nested</i></b>'];
|
||||
let clicks = 0;
|
||||
for (const [index, label] of labels.entries()) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'reader-action-overflowed';
|
||||
button.innerHTML = '<svg viewBox="0 0 24 24"><path d="M1 1h2"/></svg>';
|
||||
if (index === 0) button.title = label;
|
||||
else {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'reader-btn-label';
|
||||
span.textContent = label;
|
||||
button.appendChild(span);
|
||||
}
|
||||
button.addEventListener('click', () => clicks++);
|
||||
reader.appendChild(button);
|
||||
}
|
||||
// A remote HTML body can preserve action-like CSS classes and escaped
|
||||
// label text, even though the email sanitizer removes its SVG/handlers.
|
||||
const remoteLabel = '<img src=x onerror="window.executed++">';
|
||||
const remoteBody = document.createElement('div');
|
||||
remoteBody.className = 'email-reader-body';
|
||||
remoteBody.innerHTML = _safeRenderEmailBody({ body_html:
|
||||
'<button class="reader-action-overflowed"><svg onload="window.executed++"></svg><span class="reader-btn-label"><img src=x onerror="window.executed++"></span></button>' });
|
||||
if (!remoteBody.querySelector('.reader-action-overflowed')) throw new Error('Remote label fixture was lost');
|
||||
if (remoteBody.querySelector('svg, [onload], [onerror]')) throw new Error('Email body sanitizer failed');
|
||||
reader.appendChild(remoteBody);
|
||||
labels.push(remoteLabel);
|
||||
_showReaderMoreMenu({ uid: 'fixture' }, document.createElement('div'), reader, anchor, {});
|
||||
const menu = document.querySelector('.email-card-dropdown');
|
||||
const items = [...menu.querySelectorAll('.dropdown-item-compact')].slice(0, labels.length);
|
||||
items[0].click();
|
||||
return { labels, actual: items.map(item => item.children[1].textContent), clicks,
|
||||
icons: items.slice(0, -1).every(item => item.querySelector('.dropdown-icon svg path')),
|
||||
remoteIconRemoved: !items.at(-1).querySelector('svg'),
|
||||
arrows: [...menu.querySelectorAll('.dropdown-item-compact')].filter(item => item.lastChild.textContent === '›').length,
|
||||
unsafe: menu.querySelectorAll('script, img, [onload], [onerror]').length };
|
||||
});
|
||||
assert.deepEqual(menus.actual, menus.labels);
|
||||
assert.equal(menus.clicks, 1);
|
||||
assert.equal(menus.icons, true);
|
||||
assert.equal(menus.remoteIconRemoved, true);
|
||||
assert(menus.arrows >= 2);
|
||||
assert.equal(menus.unsafe, 0);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
localStorage.removeItem('odysseus-chatgpt-usage-expanded');
|
||||
window.adminTest = await import('/static/js/admin-codeql-harness.js');
|
||||
await window.adminTest.loadEndpoints();
|
||||
});
|
||||
const toggle = id => page.locator(`[data-adm-chatgpt-usage-toggle="session-${id}"]`);
|
||||
const stored = () => page.evaluate(() => JSON.parse(localStorage.getItem('odysseus-chatgpt-usage-expanded') || '[]').sort());
|
||||
assert.deepEqual(await stored(), []);
|
||||
await toggle('a').click();
|
||||
assert.deepEqual(await stored(), ['endpoint-a', 'session-a']);
|
||||
assert.equal(await toggle('a').getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(await toggle('b').getAttribute('aria-expanded'), 'false');
|
||||
await page.evaluate(() => window.adminTest.loadEndpoints());
|
||||
assert.equal(await toggle('a').getAttribute('aria-expanded'), 'true');
|
||||
await toggle('a').click();
|
||||
assert.deepEqual(await stored(), []);
|
||||
await page.locator('.adm-chatgpt-controls [data-adm-chatgpt-reconnect="session-b"]').click();
|
||||
assert.deepEqual(await stored(), ['endpoint-b', 'session-b']);
|
||||
const storageText = JSON.stringify(await stored());
|
||||
for (const secret of [...Object.values(secrets), 'LABEL-SENTINEL']) assert(!storageText.includes(secret));
|
||||
|
||||
const code = '/* Apply font early */ window.executed++';
|
||||
const fixtures = [
|
||||
`<SCRIPT>${code}</SCRIPT >`,
|
||||
`<script data-note=">">${code}</script\t>`,
|
||||
`<!-- <script>${code}</script> --><script src="external.js">${code}</script><script>${code}</script>`,
|
||||
];
|
||||
for (const html of fixtures) assert.equal(await page.evaluate(extractThemeBootstrap, html), code);
|
||||
await assert.rejects(page.evaluate(extractThemeBootstrap, `<script>${code}</script><script>${code}</script>`), /found 2/);
|
||||
await assert.rejects(page.evaluate(extractThemeBootstrap, '<script>unrelated</script>'), /found 0/);
|
||||
assert.equal(await page.evaluate(() => window.executed), 0);
|
||||
// Chromium reports a request event even when CSP prevents network access.
|
||||
assert(svgRequests.length > 0);
|
||||
assert.equal(svgFailures.length, svgRequests.length);
|
||||
assert(svgFailures.every(reason => reason === 'csp'));
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ chat: true, markdown: true, svg: true, menus: true, admin: true, bootstrap: true }));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exit(1); });
|
||||
+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,67 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const src = await readFile(new URL('../static/js/sessions.js', import.meta.url), 'utf8');
|
||||
const START = '// === copy-chat header (debug trace block) ===';
|
||||
const END = '// === end copy-chat header ===';
|
||||
const block = src.slice(src.indexOf(START) + START.length, src.indexOf(END));
|
||||
const extract = new Function(block + '\nreturn { _buildCopyChatHeader, _toolSurfaceLabel, _formatJst };');
|
||||
const { _buildCopyChatHeader, _toolSurfaceLabel, _formatJst } = extract();
|
||||
|
||||
test('copy-chat header has exact shape and values', () => {
|
||||
const header = _buildCopyChatHeader({
|
||||
version: '1.0.3',
|
||||
build: '20261006.1',
|
||||
sourceCommit: 'a1b2c3d4e5f6',
|
||||
model: 'gpt-4o',
|
||||
endpointUrl: 'https://api.example.com/v1',
|
||||
toolSurface: 'Ajax Spec (router)',
|
||||
sessionId: 'sess-123',
|
||||
copiedAt: new Date(Date.UTC(2026, 9, 13, 5, 30)),
|
||||
});
|
||||
const expected = [
|
||||
'--- Odysseus info ---',
|
||||
'Odysseus: 1.0.3 | Harness build: 20261006.1 | Commit: a1b2c3d4',
|
||||
'Model: gpt-4o @ https://api.example.com/v1 | Tool surface: Ajax Spec (router)',
|
||||
'Copied: 2026-10-13 14:30 JST | Session: sess-123',
|
||||
'----------------------',
|
||||
].join('\n');
|
||||
assert.equal(header, expected);
|
||||
});
|
||||
|
||||
test('unknown fields are never omitted', () => {
|
||||
const header = _buildCopyChatHeader({});
|
||||
const expected = [
|
||||
'--- Odysseus info ---',
|
||||
'Odysseus: unknown | Harness build: unknown | Commit: unknown',
|
||||
'Model: unknown @ unknown | Tool surface: unknown',
|
||||
'Copied: unknown JST | Session: unknown',
|
||||
'----------------------',
|
||||
].join('\n');
|
||||
assert.equal(header, expected);
|
||||
});
|
||||
|
||||
test('closing separator is exactly 22 dashes', () => {
|
||||
const header = _buildCopyChatHeader({});
|
||||
const lines = header.split('\n');
|
||||
assert.equal(lines.length, 5);
|
||||
assert.equal(lines[lines.length - 1], '----------------------');
|
||||
assert.equal(lines[lines.length - 1].length, 22);
|
||||
});
|
||||
|
||||
test('tool surface labels map modes to human labels', () => {
|
||||
assert.equal(_toolSurfaceLabel('full'), 'Regular tools');
|
||||
assert.equal(_toolSurfaceLabel('compact'), 'Ajax Compact');
|
||||
assert.equal(_toolSurfaceLabel('spec'), 'Ajax Spec (router)');
|
||||
assert.equal(_toolSurfaceLabel('none'), 'Tools off');
|
||||
assert.equal(_toolSurfaceLabel(''), 'Auto');
|
||||
assert.equal(_toolSurfaceLabel(undefined), 'Auto');
|
||||
assert.equal(_toolSurfaceLabel(null), 'Auto');
|
||||
assert.equal(_toolSurfaceLabel('weird'), 'unknown');
|
||||
});
|
||||
|
||||
test('JST timestamp is UTC+9 with HH:MM padding', () => {
|
||||
assert.equal(_formatJst(new Date(Date.UTC(2026, 0, 1, 0, 5))), '2026-01-01 09:05');
|
||||
assert.equal(_formatJst(new Date(Date.UTC(2026, 11, 31, 15, 0))), '2027-01-01 00:00');
|
||||
});
|
||||
@@ -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": "67c4508478daa8e0",
|
||||
"elements": {
|
||||
"app-shell": {
|
||||
"app-loader": "f04ad5bf6312d53f",
|
||||
"attach-strip": "a6474951e2481214",
|
||||
"body": "4a4b8a158ea6b360",
|
||||
"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": "c800b4ef07f5f1f0",
|
||||
"icon-rail-btn": "54ffb10e056ebf44",
|
||||
"incognito-btn": "ee42bd3f6a32576b",
|
||||
"input-icon-btn": "3d1c773acd84a825",
|
||||
"memory-close-btn": "c3f4ba99098a6e4b",
|
||||
"memory-list": "da9a37a3de3c1a31",
|
||||
"memory-modal": "e2f97b123dc9bc55",
|
||||
"memory-modal-content": "5c5fe055c711e642",
|
||||
"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": "a6694a5a5dbfbe44",
|
||||
"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": "e2f97b123dc9bc55",
|
||||
"settings-nav-search": "f4484b2980f2ccc5",
|
||||
"settings-row": "de568724268cec6e",
|
||||
"settings-select": "a96e8430b0ec6668",
|
||||
"settings-sidebar-toggle": "6be9025ab24bc434",
|
||||
"sidebar": "0dbf510da61afa07",
|
||||
"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": "fb86cc5f81d0aaa6",
|
||||
"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": "9b2145292f439a88",
|
||||
"#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": "262753f287410717",
|
||||
"#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": "92541b678e8ddd3e",
|
||||
".ge-adj-row input[type=\"range\"]": "3f1690aad291a7da",
|
||||
".ge-adj-title": "7609b8e4f862ad23",
|
||||
".ge-ai-command-collapsed": "5a38c8ab93d1818f",
|
||||
".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": "ac882350a59601a1",
|
||||
".ge-layer-lock-menu": "528deb6e31022e89",
|
||||
".ge-layer-lock-option": "38a3da4a8785330a",
|
||||
".ge-layers-grab": "094e764489b1b069",
|
||||
".ge-layers-header": "1793db7034e4b67b",
|
||||
".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": "67f8bb05e2438d92",
|
||||
".ge-tool-sep": "4ae61e4e4eddd504",
|
||||
".ge-toolbar": "0e05d7a6bc8d85c0",
|
||||
".ge-topbar": "cdb801be32727e0b",
|
||||
".ge-transform-field": "76a785f630632652",
|
||||
".ge-transform-field > input.ge-transform-popup-input": "b870e4e9d5fc2d91",
|
||||
".ge-transform-popup-body": "af7f9dceddb20374",
|
||||
".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": "ad94130c6f1829d9",
|
||||
".ge-transform-spin button[data-spin=\"down\"]": "9cb76293b224cf00",
|
||||
".ge-transform-spin button[data-spin=\"up\"]": "4f6f103a02c3838d",
|
||||
".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": "c800b4ef07f5f1f0",
|
||||
".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": "0dbf510da61afa07",
|
||||
".sidebar .section": "37fc733d59519a08",
|
||||
".sidebar-brand-title": "2b20f75768b50346",
|
||||
".sidebar-header": "0d58816ca65b7216",
|
||||
".sidebar-inner": "ad5e28fedbfeb5db",
|
||||
".sidebar-notif-dot": "2673e215d0cd84a6",
|
||||
".sidebar-user-bar": "b77372dbdcb069ef",
|
||||
".sidebar.hidden": "9021ec7e4e052131",
|
||||
".sidebar.right-side": "bb6ed4427d07657e",
|
||||
".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": "6ccb1b611fff1417",
|
||||
"desktop-dark-compact": "fa8811351d590698",
|
||||
"desktop-dark-spacious": "8b23cc34d8dacb5d",
|
||||
"desktop-light-comfortable": "4a98edc12f46490e",
|
||||
"desktop-light-compact": "3712d798c576cf07",
|
||||
"desktop-light-spacious": "4aee43267423df1f",
|
||||
"laptop-dark-comfortable": "35682719e8aeea91",
|
||||
"laptop-dark-compact": "a113f77e47ef20d6",
|
||||
"laptop-dark-spacious": "a94cb0602b8ebf8d",
|
||||
"laptop-light-comfortable": "b523ba8c4d5f5180",
|
||||
"laptop-light-compact": "aff4e1e8a5c192a8",
|
||||
"laptop-light-spacious": "dc83f76cba448444",
|
||||
"phone-dark-comfortable": "10af205a321eb35d",
|
||||
"phone-dark-compact": "1f2816f672a2ff9f",
|
||||
"phone-dark-spacious": "10af205a321eb35d",
|
||||
"phone-light-comfortable": "b781b1eec0f33d3e",
|
||||
"phone-light-compact": "131a7b11605617bb",
|
||||
"phone-light-spacious": "b781b1eec0f33d3e",
|
||||
"tablet-dark-comfortable": "4e005f33938a3026",
|
||||
"tablet-dark-compact": "3a389740245ce8e9",
|
||||
"tablet-dark-spacious": "4e005f33938a3026",
|
||||
"tablet-light-comfortable": "e7d5a710864503d3",
|
||||
"tablet-light-compact": "e65e1bcd50cebf33",
|
||||
"tablet-light-spacious": "e7d5a710864503d3"
|
||||
},
|
||||
"bench": {
|
||||
"desktop-dark-comfortable": "48a5eeb1259fd422",
|
||||
"desktop-dark-compact": "cef4e9f2ceacefbb",
|
||||
"desktop-dark-spacious": "d87bef476928e845",
|
||||
"desktop-light-comfortable": "69a68072200b3669",
|
||||
"desktop-light-compact": "9c6deaecd2265ad9",
|
||||
"desktop-light-spacious": "8a355029e7261110",
|
||||
"laptop-dark-comfortable": "aaf69c3500c38635",
|
||||
"laptop-dark-compact": "ca5a5cb542ee1901",
|
||||
"laptop-dark-spacious": "9f85415f735e040e",
|
||||
"laptop-light-comfortable": "06caf416811d5778",
|
||||
"laptop-light-compact": "f2bab4d31395dd49",
|
||||
"laptop-light-spacious": "6c56a4469a2b42ff",
|
||||
"phone-dark-comfortable": "eb27d09bf0ff4ca1",
|
||||
"phone-dark-compact": "e60d82de1d36c09b",
|
||||
"phone-dark-spacious": "3578747ada4bcbb2",
|
||||
"phone-light-comfortable": "8b0b9f1e7d73d36f",
|
||||
"phone-light-compact": "d2f58cb1c1675907",
|
||||
"phone-light-spacious": "edae71cdde26fd58",
|
||||
"tablet-dark-comfortable": "8fc2eb75bed6417d",
|
||||
"tablet-dark-compact": "2fd7fb77dfb379ba",
|
||||
"tablet-dark-spacious": "1376b658cfc7cb4b",
|
||||
"tablet-light-comfortable": "b456d13e3bbf627e",
|
||||
"tablet-light-compact": "44eb1164a0db4654",
|
||||
"tablet-light-spacious": "5d999940aa547f86"
|
||||
},
|
||||
"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,341 @@
|
||||
// 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:
|
||||
// - active script elements are removed and script execution is blocked, so
|
||||
// no app module can mutate the served shell's 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 {
|
||||
// Keep parsing separate from the page whose navigation is intercepted.
|
||||
const parser = await browser.newPage();
|
||||
await parser.route('**/*', route => route.abort());
|
||||
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();
|
||||
// Browser parsing handles HTML/SVG scripts and unusual end tags.
|
||||
// The parsed document is inert; only the script-free shell is served.
|
||||
html = await parser.evaluate(source => {
|
||||
const doc = new DOMParser().parseFromString(source, 'text/html');
|
||||
doc.querySelectorAll('script').forEach(script => script.remove());
|
||||
const doctype = doc.doctype ? new XMLSerializer().serializeToString(doc.doctype) : '';
|
||||
return doctype + doc.documentElement.outerHTML;
|
||||
}, html);
|
||||
// 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}">`);
|
||||
const headers = response.headers();
|
||||
// Also deny handlers or scripts exposed by serialization/re-parsing.
|
||||
// Playwright evaluation still runs the CSS measurement functions.
|
||||
const scriptPolicy = "script-src 'none'";
|
||||
headers['content-security-policy'] = headers['content-security-policy']
|
||||
? `${headers['content-security-policy']}, ${scriptPolicy}` : scriptPolicy;
|
||||
await route.fulfill({ response, body: html, headers: { ...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,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN || 'http://127.0.0.1:7011';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const width of [390, 900]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 844 }, acceptDownloads: true });
|
||||
if (!process.env.ODYSSEUS_TEST_STATIC_ORIGIN) {
|
||||
await page.route(`${origin}/**`, async route => {
|
||||
const url = new URL(route.request().url());
|
||||
const rel = url.pathname.replace(/^\//, '');
|
||||
try {
|
||||
const file = new URL('../' + rel, import.meta.url);
|
||||
const body = await readFile(file);
|
||||
const ext = rel.split('.').pop();
|
||||
const type = ext === 'css' ? 'text/css' : ext === 'js' ? 'text/javascript' : 'application/octet-stream';
|
||||
return route.fulfill({ contentType: type, body });
|
||||
} catch {
|
||||
return route.fulfill({ status: 404, body: 'Not found' });
|
||||
}
|
||||
});
|
||||
}
|
||||
await page.goto(`${origin}/static/js/documentStats.js`);
|
||||
await page.setContent('<link rel="stylesheet" href="/static/css/00-tokens.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?mobile-export-regression=1');
|
||||
mod.init('/api');
|
||||
mod.injectFreshDoc({ id: 'export-test', title: 'Export test', language: 'markdown', version_count: 1, current_content: '# Mobile export\n\nActual document content.' });
|
||||
});
|
||||
await page.locator('#doc-footer-export-btn').waitFor({ state: 'visible' });
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('.doc-editor-pane').style.setProperty('z-index', '12000', 'important');
|
||||
});
|
||||
await page.locator('#doc-footer-export-btn').click();
|
||||
const menu = page.locator('#doc-export-menu');
|
||||
await menu.waitFor({ state: 'visible' });
|
||||
const bounds = await menu.boundingBox();
|
||||
assert(bounds.x >= 0 && bounds.y >= 0);
|
||||
assert(bounds.x + bounds.width <= width && bounds.y + bounds.height <= 844);
|
||||
assert(await menu.evaluate(el => Number(getComputedStyle(el).zIndex) > 12000));
|
||||
await page.screenshot({ path: `/tmp/document-export-${width}.png` });
|
||||
// A second click closes the menu, then reopening must still allow export.
|
||||
await page.locator('#doc-footer-export-btn').click();
|
||||
assert.equal(await menu.count(), 0);
|
||||
await page.locator('#doc-footer-export-btn').click();
|
||||
const pending = page.waitForEvent('download');
|
||||
await page.locator('#doc-export-menu button').filter({ hasText: /^Export Markdown$/ }).click();
|
||||
const download = await pending;
|
||||
assert(download.suggestedFilename().endsWith('.md'));
|
||||
assert.equal(await download.failure(), null);
|
||||
const stream = await download.createReadStream();
|
||||
let content = '';
|
||||
for await (const chunk of stream) content += chunk;
|
||||
assert.equal(content, '# Mobile export\n\nActual document content.');
|
||||
await page.close();
|
||||
console.log(`PASS ${width}px: visible menu, toggle, download contents`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -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,20 @@
|
||||
# Browser End-to-End Tests
|
||||
|
||||
The photo editor release gate launches an isolated Odysseus server on port
|
||||
`7013` with a temporary SQLite database and disposable authenticated account.
|
||||
|
||||
```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.
|
||||
|
||||
The disposable account is created in the temporary data directory and exercises
|
||||
owner-scoped draft and notification routes without using an existing account.
|
||||
`PHOTO_EDITOR_E2E_AUTH=0` selects the legacy unauthenticated configuration for
|
||||
investigating it; offline-upgrade coverage requires the authenticated mode.
|
||||
@@ -0,0 +1,363 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { chooseFilterAction, selectTool } = require('./helpers.js');
|
||||
const {
|
||||
dragOnCanvas,
|
||||
encodedImagePixelDigest,
|
||||
editorState,
|
||||
flattenedPixelDigest,
|
||||
openBlankEditor,
|
||||
reopenDraft,
|
||||
waitForDraft,
|
||||
} = require('./helpers.js');
|
||||
|
||||
async function addAdjustment(page, type) {
|
||||
await page.locator('#ge-add-fx').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 selectTool(page, 'brush');
|
||||
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-controls:visible .ge-layer-clip-btn').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).clipped).toBe(true);
|
||||
await page.locator('.ge-layer-controls:visible .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-controls:visible .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-controls:visible .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 selectTool(page, 'brush');
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
|
||||
const before = await flattenedPixelDigest(page);
|
||||
|
||||
await chooseFilterAction(page, 'effect-blur-gaussian');
|
||||
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 chooseFilterAction(page, 'effect-preset-crisp-detail');
|
||||
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 selectTool(page, 'marquee');
|
||||
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 chooseFilterAction(page, 'effect-color-overlay');
|
||||
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 chooseFilterAction(page, 'effect-drop-shadow');
|
||||
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 selectTool(page, 'brush');
|
||||
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
|
||||
await chooseFilterAction(page, 'effect-stroke');
|
||||
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,111 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
// These tests borrow /login as a lightweight page. A signed-in session would
|
||||
// make the login page redirect to the app mid-test, so run them signed out.
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
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,48 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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 selectTool(page, 'brush');
|
||||
|
||||
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,213 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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 selectTool(page, 'brush');
|
||||
// Spacing / Smoothing / Blend and the pen-pressure toggles now live behind
|
||||
// the brush caret ("v") in the options strip instead of the retired "...".
|
||||
await page.locator('#ge-brush-more').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: 'Flow' }).locator('.toggle-slider').click();
|
||||
await expect(page.locator('#ge-pressure-flow')).not.toBeChecked();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
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-more').click();
|
||||
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 selectTool(page, 'brush');
|
||||
await page.locator('.ge-size-slider').fill('700');
|
||||
await page.locator('#ge-brush-more').click();
|
||||
await page.locator('#ge-brush-spacing').fill('8');
|
||||
await page.locator('#ge-brush-smoothing').fill('65');
|
||||
await page.keyboard.press('Escape');
|
||||
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();
|
||||
const strokeDuration = Date.now() - startedAt;
|
||||
expect(strokeDuration).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 selectTool(page, 'brush');
|
||||
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 selectTool(page, 'eyedropper');
|
||||
await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
|
||||
await page.locator('#ge-eyedropper-sample').selectOption('composite');
|
||||
// The options strip height differs per tool, which re-centres the canvas a
|
||||
// few pixels; hover the same image point that was painted.
|
||||
const painted = await canvasPoint(page, 0.3, 0.3);
|
||||
await page.mouse.move(painted.x, painted.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(painted.x, painted.y);
|
||||
await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55');
|
||||
|
||||
for (const tool of ['dodge', 'burn']) {
|
||||
const before = await flattenedPixelDigest(page);
|
||||
await selectTool(page, tool);
|
||||
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 selectTool(page, 'brush');
|
||||
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 selectTool(page, 'heal');
|
||||
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 selectTool(page, 'brush');
|
||||
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 selectTool(page, 'heal');
|
||||
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 selectTool(page, 'brush');
|
||||
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 selectTool(page, 'smudge');
|
||||
// Smudge strength lives in the brush-family "More" menu with the other
|
||||
// per-tool stroke modifiers.
|
||||
await page.locator('#ge-brush-more').click();
|
||||
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
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,44 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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 selectTool(page, 'brush');
|
||||
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,107 @@
|
||||
const fs = require('node:fs');
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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 selectTool(page, 'brush');
|
||||
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 editItem.locator('.ge-true-mask-btn').click();
|
||||
let current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.name === 'Edit').masks).toHaveLength(1);
|
||||
|
||||
await selectTool(page, 'transform');
|
||||
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 selectTool(page, 'text');
|
||||
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 selectTool(page, 'crop');
|
||||
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,200 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor, settledCanvasBox } = 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 selectTool(page, 'crop');
|
||||
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 selectTool(page, 'move');
|
||||
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 selectTool(page, 'marquee');
|
||||
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 selectTool(page, 'brush');
|
||||
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 selectTool(page, 'crop');
|
||||
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 touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
|
||||
type,
|
||||
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
|
||||
});
|
||||
|
||||
await selectTool(page, 'crop');
|
||||
// Picking a tool minimizes the phone Layers sheet and refits the canvas;
|
||||
// aim at where the canvas settles, as a finger would.
|
||||
const canvasBox = await settledCanvasBox(page);
|
||||
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 selectTool(page, 'marquee');
|
||||
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 touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
|
||||
type,
|
||||
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
|
||||
});
|
||||
await selectTool(page, 'brush');
|
||||
// Re-tapping the active tool collapses its controls sheet (canvas-first
|
||||
// phone editing), which also uncovers the Layers sheet grab handle.
|
||||
await selectTool(page, 'brush');
|
||||
const canvasBox = await settledCanvasBox(page);
|
||||
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);
|
||||
// History buttons live in the Layers sheet, which the tool pick minimized.
|
||||
await page.locator('.ge-layers-grab').click();
|
||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
|
||||
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 selectTool(page, 'brush');
|
||||
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 selectTool(page, 'crop');
|
||||
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-fx').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-fx').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,107 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { chooseFilterAction, selectTool } = require('./helpers.js');
|
||||
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 selectTool(page, 'brush');
|
||||
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 chooseFilterAction(page, 'effect-blur-gaussian', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
|
||||
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 selectTool(page, 'brush');
|
||||
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 chooseFilterAction(page, 'effect-color-overlay', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
|
||||
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,69 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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-controls:visible .ge-group-mask-btn').click();
|
||||
|
||||
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
|
||||
await expect(page.locator('.ge-layer-controls:visible 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 selectTool(page, 'eraser');
|
||||
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-controls:visible button[title="Ungroup layers"]').click();
|
||||
expect((await editorState(page)).groups).toHaveLength(0);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
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();
|
||||
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-title').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,410 @@
|
||||
const { expect } = require('@playwright/test');
|
||||
|
||||
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
// Workflow tests exercise the canvas, not the optional setup overlay.
|
||||
await page.evaluate(() => localStorage.setItem('ge-preflight-dismissed', '1'));
|
||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
|
||||
await page.evaluate(async () => {
|
||||
window.__photoEditorImport = path => import(path);
|
||||
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 });
|
||||
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 });
|
||||
}
|
||||
|
||||
async function selectTool(page, toolId) {
|
||||
const face = page.locator(`.ge-tool-slot-face[data-tool="${toolId}"]`);
|
||||
if (await face.count() > 0) {
|
||||
await face.first().click();
|
||||
return;
|
||||
}
|
||||
// Open the flyout via its corner triangle, wait for item visibility, and click realistically
|
||||
const slot = page.locator('.ge-tool-slot', { has: page.locator(`[data-tool-option="${toolId}"]`) }).first();
|
||||
const tri = slot.locator('.ge-tool-flyout-tri');
|
||||
if (await tri.count() > 0) {
|
||||
await tri.click();
|
||||
}
|
||||
const item = slot.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`);
|
||||
await expect(item).toBeVisible({ timeout: 3000 });
|
||||
await item.click();
|
||||
}
|
||||
|
||||
// On phones, picking a tool minimizes the Layers sheet and refits the canvas.
|
||||
// Wait for the canvas to stop moving before aiming at it.
|
||||
async function settledCanvasBox(page) {
|
||||
let box = null;
|
||||
await expect.poll(async () => {
|
||||
const previous = box;
|
||||
box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
return JSON.stringify(previous) === JSON.stringify(box);
|
||||
}).toBe(true);
|
||||
return box;
|
||||
}
|
||||
|
||||
// Rare text/shape options live in the options strip's "More" popover, which
|
||||
// closes on any outside press. Open it (idempotently) before using them.
|
||||
async function openOptionsMore(page, kind) {
|
||||
const menu = page.locator(`#ge-${kind}-more`);
|
||||
if (await menu.isHidden()) await page.locator(`#ge-${kind}-more-toggle`).click();
|
||||
await expect(menu).toBeVisible();
|
||||
}
|
||||
|
||||
// Filter actions live in Photoshop-style flyouts (Blur, Retained effects, ...).
|
||||
// Open the menu, then the item's parent row, as a user would.
|
||||
async function chooseFilterAction(page, action, opener = '#ge-filter-menu-btn') {
|
||||
await page.locator(opener).click();
|
||||
const menu = page.locator('#ge-filter-menu');
|
||||
await expect(menu).toBeVisible();
|
||||
const item = menu.locator(`[data-filter-action="${action}"]`);
|
||||
const panel = await item.evaluate(node => node.closest('.ge-submenu')?.dataset.submenuPanel || null);
|
||||
if (panel) await menu.locator(`.ge-menu-parent[data-submenu="${panel}"]`).click();
|
||||
await item.click();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
chooseFilterAction,
|
||||
compareExportPixels,
|
||||
dragOnCanvas,
|
||||
encodedImagePixelDigest,
|
||||
editorState,
|
||||
flattenedPixelDigest,
|
||||
openBlankEditor,
|
||||
openExportDialog,
|
||||
openOptionsMore,
|
||||
reopenDraft,
|
||||
selectTool,
|
||||
settledCanvasBox,
|
||||
waitForDraft,
|
||||
};
|
||||
@@ -0,0 +1,414 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { chooseFilterAction, dragOnCanvas, editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
|
||||
|
||||
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('save button icon follows the draft status through an edit and autosave', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
// Autosave can settle within a frame, so record the rendered icon at every
|
||||
// status change instead of racing it with polling assertions.
|
||||
await page.evaluate(() => {
|
||||
const status = document.getElementById('ge-draft-status');
|
||||
const visibleIcon = () => [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')]
|
||||
.filter(icon => icon.getBoundingClientRect().width > 0)
|
||||
.map(icon => (icon.classList.contains('ge-save-state-saved') ? 'saved' : 'dirty'));
|
||||
window.__saveStates = [];
|
||||
new MutationObserver(() => window.__saveStates.push({ text: status.textContent, icons: visibleIcon() }))
|
||||
.observe(status, { childList: true, characterData: true, subtree: true });
|
||||
});
|
||||
await page.keyboard.press('b');
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
|
||||
await waitForDraft(page);
|
||||
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
|
||||
const states = await page.evaluate(() => window.__saveStates);
|
||||
expect(states.some(entry => entry.text === 'Unsaved')).toBe(true);
|
||||
for (const entry of states) {
|
||||
expect(entry.icons).toEqual([entry.text === 'Saved' ? 'saved' : 'dirty']);
|
||||
}
|
||||
});
|
||||
|
||||
test('save-state representations cover all draft states without error normalization', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await waitForDraft(page);
|
||||
|
||||
const cases = [
|
||||
{ label: 'Unsaved', state: 'dirty', expectedText: 'Unsaved', expectedState: 'dirty', iconClass: 'ge-save-state-dirty', isError: false },
|
||||
{ label: 'Saving soon…', state: 'pending', expectedText: 'Saving soon…', expectedState: 'pending', iconClass: 'ge-save-state-dirty', isError: false },
|
||||
{ label: 'Saving…', state: 'saving', expectedText: 'Saving…', expectedState: 'saving', iconClass: 'ge-save-state-dirty', isError: false },
|
||||
{ label: 'Saved', state: 'saved', expectedText: 'Saved', expectedState: 'saved', iconClass: 'ge-save-state-saved', isError: false },
|
||||
{ label: 'Save failed', state: 'error', expectedText: 'Save failed', expectedState: 'error', iconClass: 'ge-save-state-error', isError: true },
|
||||
{ label: 'Save paused', state: 'paused', expectedText: 'Save paused', expectedState: 'paused', iconClass: 'ge-save-state-error', isError: true },
|
||||
{ label: 'Save failed: network offline', state: '', expectedText: 'Save failed: network offline', expectedState: 'error', iconClass: 'ge-save-state-error', isError: true },
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
await page.evaluate(({ label, state }) => {
|
||||
window.__geSetDraftStatus(label, state);
|
||||
}, c);
|
||||
|
||||
const statusEl = page.locator('#ge-draft-status');
|
||||
const saveBtn = page.locator('#ge-save-menu-btn');
|
||||
await expect(statusEl).toHaveText(c.expectedText);
|
||||
await expect(statusEl).toHaveAttribute('data-state', c.expectedState);
|
||||
|
||||
const visibleIcon = await page.evaluate(() => {
|
||||
const icons = [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')];
|
||||
const visible = icons.filter(i => !i.hasAttribute('hidden'));
|
||||
return visible.map(i => i.className.baseVal || i.className);
|
||||
});
|
||||
expect(visibleIcon.length).toBe(1);
|
||||
expect(visibleIcon[0]).toContain(c.iconClass);
|
||||
|
||||
if (c.isError) {
|
||||
await expect(saveBtn).toHaveAttribute('aria-label', new RegExp(`Draft autosave failed: ${c.expectedText}`));
|
||||
} else {
|
||||
await expect(saveBtn).toHaveAttribute('aria-label', `Draft status: ${c.expectedText}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('autosave network failure transitions to error state and shows failure indicator', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await page.route(/\/api\/editor-drafts/, route => route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ detail: 'Database error' }),
|
||||
}));
|
||||
|
||||
await page.keyboard.press('b');
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
|
||||
|
||||
const statusEl = page.locator('#ge-draft-status');
|
||||
await expect(statusEl).toHaveText('Save failed', { timeout: 10_000 });
|
||||
await expect(statusEl).toHaveAttribute('data-state', 'error');
|
||||
|
||||
const saveBtn = page.locator('#ge-save-menu-btn');
|
||||
await expect(saveBtn).toHaveAttribute('aria-label', /Draft autosave failed/);
|
||||
|
||||
const visibleIcons = await page.evaluate(() => {
|
||||
const icons = [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')];
|
||||
return icons.filter(i => !i.hasAttribute('hidden')).map(i => i.className.baseVal || i.className);
|
||||
});
|
||||
expect(visibleIcons).toHaveLength(1);
|
||||
expect(visibleIcons[0]).toContain('ge-save-state-error');
|
||||
});
|
||||
|
||||
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 chooseFilterAction(page, 'blur-gaussian');
|
||||
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.
|
||||
// Frame width lives in the text options' collapsed "More" section.
|
||||
await page.locator('#ge-text-more-toggle').click();
|
||||
await page.locator('#ge-text-frame-width').focus();
|
||||
await expect(page.locator('#ge-text-frame-width')).toBeFocused();
|
||||
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);
|
||||
});
|
||||
|
||||
test('concurrent edits during in-flight save preserve dirty unsaved status and revision sequence', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await waitForDraft(page);
|
||||
|
||||
let delayedRouteResolve = null;
|
||||
const delayedRoutePromise = new Promise(resolve => { delayedRouteResolve = resolve; });
|
||||
|
||||
let saveCallCount = 0;
|
||||
await page.route(/\/api\/editor-drafts/, async route => {
|
||||
saveCallCount += 1;
|
||||
if (saveCallCount === 1) {
|
||||
await delayedRoutePromise;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ id: 'concurrent-draft-1', name: 'Untitled' }),
|
||||
});
|
||||
});
|
||||
|
||||
// Edit 1
|
||||
await page.keyboard.press('b');
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
|
||||
|
||||
// Wait until save is in flight
|
||||
await expect(page.locator('#ge-draft-status')).toHaveText('Saving…', { timeout: 10_000 });
|
||||
|
||||
// Edit 2 while save 1 is in flight
|
||||
await dragOnCanvas(page, { x: 0.5, y: 0.5 }, { x: 0.7, y: 0.7 });
|
||||
|
||||
const revsDuringFlight = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return {
|
||||
docRev: state.documentRevision,
|
||||
persistingRev: state.persistingRevision,
|
||||
dirty: state.persistDirty,
|
||||
};
|
||||
});
|
||||
expect(revsDuringFlight.docRev).toBeGreaterThan(revsDuringFlight.persistingRev);
|
||||
expect(revsDuringFlight.dirty).toBe(true);
|
||||
|
||||
// Now let save 1 complete
|
||||
delayedRouteResolve();
|
||||
|
||||
// Wait for the chained second save to complete
|
||||
await expect.poll(async () => {
|
||||
return page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.savedRevision >= state.documentRevision && !state.persistDirty;
|
||||
});
|
||||
}, { timeout: 15_000 }).toBe(true);
|
||||
|
||||
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
|
||||
});
|
||||
|
||||
test('failed draft save retains unsaved dirty status and failure indicator', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await waitForDraft(page);
|
||||
|
||||
await page.route(/\/api\/editor-drafts/, route => route.abort('failed'));
|
||||
|
||||
await page.keyboard.press('b');
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
|
||||
|
||||
await expect(page.locator('#ge-draft-status')).toHaveText('Save failed', { timeout: 10_000 });
|
||||
|
||||
const dirtyAfterFailure = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.persistDirty;
|
||||
});
|
||||
expect(dirtyAfterFailure).toBe(true);
|
||||
});
|
||||
@@ -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-controls:visible .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-controls:visible 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-controls:visible .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,66 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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 selectTool(page, 'move');
|
||||
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 selectTool(page, 'brush');
|
||||
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,142 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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 selectTool(page, 'brush');
|
||||
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-controls:visible .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 selectTool(page, 'move');
|
||||
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 selectTool(page, 'transform');
|
||||
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,326 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden', async ({ page }) => {
|
||||
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.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();
|
||||
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 selectTool(page, 'shape');
|
||||
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 selectTool(page, 'move');
|
||||
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 selectTool(page, 'transform');
|
||||
await expect(page.locator('.ge-auto-select-option')).toBeHidden();
|
||||
await selectTool(page, 'move');
|
||||
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 selectTool(page, 'move');
|
||||
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();
|
||||
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 selectTool(page, 'move');
|
||||
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-controls:visible 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();
|
||||
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-controls:visible 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,47 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { chooseFilterAction, 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 chooseFilterAction(page, 'effect-color-overlay');
|
||||
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-fx').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,57 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor, selectTool } = require('./helpers.js');
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
|
||||
test('minimizing layers restores fit view and keeps an explicit zoom', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
const canvas = page.locator('.ge-main-canvas');
|
||||
const zoom = () => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoom);
|
||||
await page.locator('.ge-layers-title').click();
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
|
||||
await selectTool(page, 'eraser');
|
||||
await selectTool(page, 'eraser');
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
|
||||
await expect.poll(async () => (await canvas.boundingBox()).width).toBeGreaterThan(200);
|
||||
|
||||
await page.locator('.ge-layers-grab').click();
|
||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
|
||||
await page.locator('#ge-zoom-in').evaluate(button => button.click());
|
||||
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoomViewMode)).toBe('scale');
|
||||
const manualZoom = await zoom();
|
||||
await page.locator('.ge-layers-title').click();
|
||||
await selectTool(page, 'eraser');
|
||||
await selectTool(page, 'eraser');
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
|
||||
await expect.poll(zoom).toBe(manualZoom);
|
||||
});
|
||||
|
||||
test('minimizing layers keeps a pinch zoom', async ({ page, context, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP multi-touch injection');
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
const state = () => page.evaluate(async () => {
|
||||
const { state: editor } = await import('/static/js/editor/state.js');
|
||||
return { zoom: editor.zoom, mode: editor.zoomViewMode };
|
||||
});
|
||||
const fitted = await state();
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
const cx = box.x + box.width / 2;
|
||||
const cy = box.y + box.height / 2;
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const fingers = spread => [{ x: cx - spread, y: cy, id: 1 }, { x: cx + spread, y: cy, id: 2 }];
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: fingers(20) });
|
||||
for (const spread of [30, 40, 50]) {
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: fingers(spread) });
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
const pinched = await state();
|
||||
expect(pinched.mode).toBe('scale');
|
||||
expect(pinched.zoom).toBeGreaterThan(fitted.zoom * 2);
|
||||
|
||||
await page.locator('.ge-layers-title').click();
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
|
||||
await selectTool(page, 'eraser');
|
||||
await selectTool(page, 'eraser');
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
|
||||
await expect.poll(async () => (await state()).zoom).toBe(pinched.zoom);
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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-controls:visible .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 selectTool(page, 'brush');
|
||||
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 selectTool(page, 'eraser');
|
||||
await selectTool(page, 'eraser');
|
||||
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-controls:visible .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 selectTool(page, 'eraser');
|
||||
// Collapse the bottom-sheet controls so the full canvas is available to the
|
||||
// touch gesture, matching the canvas-first mobile editing mode.
|
||||
await selectTool(page, 'eraser');
|
||||
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);
|
||||
// History controls live in the layers header, which is hidden while the
|
||||
// sheet is minimized for painting. Reopen it as a phone user would.
|
||||
await page.locator('.ge-layers-grab').click();
|
||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
|
||||
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-controls:visible .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 selectTool(page, 'move');
|
||||
await selectTool(page, 'move');
|
||||
|
||||
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-layers-grab').click();
|
||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
|
||||
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,128 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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-controls:visible .ge-true-mask-btn').click();
|
||||
|
||||
await selectTool(page, 'text');
|
||||
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-controls:visible .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 selectTool(page, 'transform');
|
||||
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 selectTool(page, 'transform');
|
||||
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 selectTool(page, 'transform');
|
||||
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,87 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const path = require('node:path');
|
||||
|
||||
test('static editor modules survive a cache upgrade and offline reopen', async ({ browser }, testInfo) => {
|
||||
const context = await browser.newContext({
|
||||
baseURL: testInfo.project.use.baseURL,
|
||||
storageState: path.join(process.env.PHOTO_EDITOR_E2E_DATA_DIR, 'browser-auth.json'),
|
||||
serviceWorkers: 'allow',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const pickerUrl = '/static/js/colorPicker.js?v=20260910eyedropper1';
|
||||
const cssUrl = '/static/css/13-image-editor.css?v=20260929csssplit';
|
||||
const editorUrl = '/static/js/galleryEditor.js?v=20260909movepicklayer1';
|
||||
try {
|
||||
// The app's worker intentionally controls /static/, not the SPA root.
|
||||
await page.goto('/static/js/panels.js?v=20260909movepicklayer1');
|
||||
const installed = await page.evaluate(async ({ pickerUrl, cssUrl }) => {
|
||||
const previous = await caches.open('odysseus-v1113-pr40-editor-precache');
|
||||
await previous.put(pickerUrl, new Response('stale picker'));
|
||||
await previous.put(cssUrl, new Response('stale css'));
|
||||
const registration = await navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1');
|
||||
await navigator.serviceWorker.ready;
|
||||
if (!navigator.serviceWorker.controller) {
|
||||
await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
|
||||
}
|
||||
return { scope: new URL(registration.scope).pathname, root: !!(await navigator.serviceWorker.getRegistration('/')) };
|
||||
}, { pickerUrl, cssUrl });
|
||||
expect(installed).toEqual({ scope: '/static/', root: false });
|
||||
await expect.poll(() => page.evaluate(async () => (await caches.keys()).includes('odysseus-v1113-pr40-editor-precache'))).toBe(false);
|
||||
for (const url of [pickerUrl, cssUrl, editorUrl, '/static/js/platform.js', '/static/js/modelSort.js']) {
|
||||
expect(await page.evaluate(async value => !!(await caches.match(value)), url)).toBe(true);
|
||||
}
|
||||
|
||||
// Simulate stale entries in the active version, then verify network-first
|
||||
// refresh updates both JS and CSS before testing the offline fallback.
|
||||
const currentCache = await page.evaluate(async () => (await caches.keys()).find(name => name.startsWith('odysseus-')));
|
||||
expect(currentCache).toMatch(/^odysseus-v\d+-pr40-editor-precache$/);
|
||||
expect(currentCache).not.toBe('odysseus-v1113-pr40-editor-precache');
|
||||
await page.evaluate(async ({ currentCache, pickerUrl, cssUrl }) => {
|
||||
const cache = await caches.open(currentCache);
|
||||
await cache.put(pickerUrl, new Response('stale picker'));
|
||||
await cache.put(cssUrl, new Response('stale css'));
|
||||
for (const url of [pickerUrl, cssUrl]) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Network refresh failed: ${url} ${response.status}`);
|
||||
}
|
||||
}, { currentCache, pickerUrl, cssUrl });
|
||||
for (const url of [pickerUrl, cssUrl]) {
|
||||
await expect.poll(() => page.evaluate(async value => {
|
||||
const response = await caches.match(value);
|
||||
return response ? !(await response.text()).startsWith('stale ') : false;
|
||||
}, url)).toBe(true);
|
||||
}
|
||||
|
||||
// Origin is the only Vary header the offline fallback may ignore. An entry
|
||||
// that varies on anything else (as a per-user response would) must miss.
|
||||
const varyProbeUrl = '/static/js/e2e-vary-probe.js';
|
||||
await page.evaluate(async ({ currentCache, varyProbeUrl }) => {
|
||||
const cache = await caches.open(currentCache);
|
||||
await cache.put(
|
||||
new Request(varyProbeUrl, { headers: { 'X-Probe-Owner': 'another-user' } }),
|
||||
new Response('export default "private";', { headers: { 'Content-Type': 'text/javascript', Vary: 'Origin, X-Probe-Owner' } }),
|
||||
);
|
||||
}, { currentCache, varyProbeUrl });
|
||||
|
||||
await context.setOffline(true);
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const offline = await context.newPage();
|
||||
await offline.goto('/static/js/panels.js?v=20260909movepicklayer1');
|
||||
expect(await offline.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
|
||||
const result = await offline.evaluate(async ({ pickerUrl, editorUrl, cssUrl, varyProbeUrl }) => {
|
||||
const editor = await import(editorUrl);
|
||||
const picker = await import(pickerUrl);
|
||||
const css = await fetch(cssUrl);
|
||||
const privateProbe = await import(varyProbeUrl).then(() => 'served', () => 'rejected');
|
||||
return {
|
||||
editor: typeof editor.openEditor,
|
||||
picker: ['getRecentColors', 'recordRecentColor', 'suggestColorsFor'].every(key => typeof picker[key] === 'function'),
|
||||
cssOk: css.ok,
|
||||
privateProbe,
|
||||
};
|
||||
}, { pickerUrl, editorUrl, cssUrl, varyProbeUrl });
|
||||
expect(result).toEqual({ editor: 'function', picker: true, cssOk: true, privateProbe: 'rejected' });
|
||||
await offline.close();
|
||||
}
|
||||
} finally { await context.close(); }
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor } = require('./helpers.js');
|
||||
|
||||
test('channel and path panels display hostile layer metadata as text', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 64, height: 64 });
|
||||
const result = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const { createColorPanels } = await import('/static/js/editor/panels/panels.js');
|
||||
const { createStudioPanels } = await import('/static/js/editor/panels/studio-panels.js');
|
||||
const attack = '<img src=x onerror="window.__panelXss=true">';
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
layer.name = attack;
|
||||
layer.kind = 'shape';
|
||||
layer.shape = { type: attack };
|
||||
const colors = createColorPanels({ state });
|
||||
const studio = createStudioPanels({ state });
|
||||
document.body.append(colors.channels, studio.paths);
|
||||
colors.refresh();
|
||||
studio.refresh();
|
||||
return {
|
||||
attack,
|
||||
channel: colors.channels.querySelector('.ge-channel-name').textContent,
|
||||
path: studio.paths.querySelector('.ge-channel-name').textContent,
|
||||
images: colors.channels.querySelectorAll('img').length + studio.paths.querySelectorAll('img').length,
|
||||
executed: !!window.__panelXss,
|
||||
};
|
||||
});
|
||||
expect(result.channel).toBe(result.attack);
|
||||
expect(result.path).toBe(`${result.attack} · ${result.attack}`);
|
||||
expect(result.images).toBe(0);
|
||||
expect(result.executed).toBe(false);
|
||||
});
|
||||
|
||||
test('unknown effect types cannot inject markup into the layer panel', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 64, height: 64 });
|
||||
const attack = '<img src=x onerror="window.__effectXss=true">';
|
||||
const id = await page.evaluate(async value => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
layer.effects = [{ id: 'effect-test', type: value, visible: true, params: {} }];
|
||||
return layer.id;
|
||||
}, attack);
|
||||
await page.locator(`.ge-layer-item[data-layer-id="${id}"] .ge-layer-vis`).first().click();
|
||||
const list = page.locator('#ge-layers-list');
|
||||
await expect(list).toContainText('Gaussian Blur');
|
||||
await expect(list).not.toContainText(attack);
|
||||
await expect(list.locator('img')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => !!window.__effectXss)).toBe(false);
|
||||
});
|
||||
|
||||
test('saved brush preset names remain literal text in the flyout', async ({ page }) => {
|
||||
const attack = '<img src=x onerror="window.__brushXss=true">';
|
||||
await page.addInitScript(value => {
|
||||
localStorage.setItem('odysseus.editor.brush-presets.v1', JSON.stringify([
|
||||
{ id: 'hostile-preset', name: value, size: 8, opacity: 100, flow: 100, softness: 20 },
|
||||
]));
|
||||
}, attack);
|
||||
await openBlankEditor(page, { width: 64, height: 64 });
|
||||
const preset = page.locator('.ge-tool-flyout-preset[data-brush-preset="hostile-preset"]');
|
||||
await expect(preset.locator('.ge-tool-flyout-label')).toHaveText(attack);
|
||||
await expect(preset.locator('img')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => !!window.__brushXss)).toBe(false);
|
||||
});
|
||||
|
||||
test('imported adjustment parameters cannot inject markup into the adjustment popup', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 64, height: 64 });
|
||||
const attack = '"><img src=x onerror="window.__adjXss=true">';
|
||||
const cases = [
|
||||
{ type: 'color-lookup', params: { cube: { size: attack, data: [0, 0, 0] }, strength: attack }, text: '.ge-adj-lut-name', expected: 'Loaded LUT · 0³' },
|
||||
{ type: 'gradient-map', params: { shadows: attack, highlights: attack } },
|
||||
{ type: 'levels', params: { inBlack: attack, outWhite: attack } },
|
||||
];
|
||||
for (const item of cases) {
|
||||
await page.locator('#ge-add-fx').click();
|
||||
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${item.type}"]`).click();
|
||||
await page.locator('.ge-adj-popup [data-adj-action="ok"]').click();
|
||||
await expect(page.locator('.ge-adj-popup')).toHaveCount(0);
|
||||
// Project files and drafts reach the editor through the codec's
|
||||
// normalizer, which validates the type but not every parameter.
|
||||
await page.evaluate(async ({ type, params }) => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const { normalizeAdjustmentData } = await import('/static/js/editor/adjustment-layer.js');
|
||||
const layer = state.layers.find(entry => entry.id === state.activeLayerId);
|
||||
layer.adjustment = normalizeAdjustmentData({ type, params });
|
||||
}, item);
|
||||
await page.locator('.ge-layer-controls:visible .ge-layer-fx-btn').click();
|
||||
const popup = page.locator('.ge-adj-popup');
|
||||
await expect(popup).toBeVisible();
|
||||
await expect(popup.locator('img')).toHaveCount(0);
|
||||
if (item.text) await expect(popup.locator(item.text)).toHaveText(item.expected);
|
||||
expect(await popup.evaluate(node => node.innerHTML.includes('onerror'))).toBe(false);
|
||||
await popup.locator('[data-adj-action="cancel"]').click();
|
||||
}
|
||||
expect(await page.evaluate(() => !!window.__adjXss)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor } = require('./helpers');
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
|
||||
async function gaps(page) {
|
||||
return page.evaluate(() => {
|
||||
const rect = selector => document.querySelector(selector).getBoundingClientRect();
|
||||
const canvas = rect('.ge-main-canvas');
|
||||
const footer = rect('.ge-editor-footer');
|
||||
const sheet = rect('.ge-right-panel');
|
||||
return {
|
||||
canvasToFooter: Math.abs(canvas.bottom - footer.top),
|
||||
footerToSheet: Math.abs(footer.bottom - sheet.top),
|
||||
footerLeft: footer.left,
|
||||
footerRight: footer.right,
|
||||
sheetTop: sheet.top,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test('mobile footer docks flush between the canvas and the layers sheet', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile footer dock');
|
||||
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
|
||||
|
||||
await expect(page.locator('.ge-editor-footer')).toHaveClass(/ge-footer-floating/);
|
||||
await expect(page.locator('.ge-layers-grab')).toBeHidden();
|
||||
|
||||
// Wait until the dock has settled: canvas bottom == footer top == sheet top.
|
||||
await expect.poll(async () => {
|
||||
const g = await gaps(page);
|
||||
return g.canvasToFooter <= 2 && g.footerToSheet <= 2;
|
||||
}, { timeout: 10_000 }).toBe(true);
|
||||
|
||||
const g = await gaps(page);
|
||||
expect(g.canvasToFooter).toBeLessThanOrEqual(2);
|
||||
expect(g.footerToSheet).toBeLessThanOrEqual(2);
|
||||
expect(g.footerLeft).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(g.footerRight - 390)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('phone footer controls for zoom, setup, and AI settings are accessible via normal clicks', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile footer controls');
|
||||
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
|
||||
|
||||
const footer = page.locator('.ge-editor-footer');
|
||||
await expect(footer).toBeVisible();
|
||||
|
||||
const setupBtn = page.locator('#ge-setup-btn');
|
||||
const aiBtn = page.locator('#ge-ai-settings-btn');
|
||||
const zoomIn = page.locator('#ge-zoom-in');
|
||||
const zoomOut = page.locator('#ge-zoom-out');
|
||||
const zoomFit = page.locator('#ge-zoom-100');
|
||||
|
||||
await expect(setupBtn).toBeVisible();
|
||||
await expect(aiBtn).toBeVisible();
|
||||
await expect(zoomIn).toBeVisible();
|
||||
await expect(zoomOut).toBeVisible();
|
||||
await expect(zoomFit).toBeVisible();
|
||||
|
||||
// Test zoom controls via normal clicks
|
||||
const zoomLabel = page.locator('.ge-zoom-label');
|
||||
const initialZoom = await zoomLabel.textContent();
|
||||
await zoomIn.click();
|
||||
await expect(zoomLabel).not.toHaveText(initialZoom);
|
||||
await zoomFit.click();
|
||||
await expect(zoomLabel).toHaveText(/%/);
|
||||
|
||||
// Test Setup modal accessibility via normal click
|
||||
await setupBtn.click();
|
||||
const preflight = page.locator('.ge-preflight');
|
||||
await expect(preflight).toBeVisible();
|
||||
await page.locator('.ge-preflight-start').click();
|
||||
await expect(preflight).toBeHidden();
|
||||
|
||||
// Test AI Settings modal accessibility via normal click
|
||||
await aiBtn.click();
|
||||
const aiSettings = page.locator('.ge-ai-settings');
|
||||
await expect(aiSettings).toBeVisible();
|
||||
await page.locator('.ge-ai-settings-done').click();
|
||||
await expect(aiSettings).toBeHidden();
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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-controls:visible .ge-true-mask-btn').click();
|
||||
await selectTool(page, 'transform');
|
||||
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 selectTool(page, 'transform');
|
||||
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-controls:visible 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-controls:visible 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-controls:visible 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-controls:visible 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-controls:visible 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-controls:visible button[title="Rasterize placed layer"]').click();
|
||||
const rasterBeforeTransform = (await editorState(page)).layers.find(layer => layer.id === placed.id);
|
||||
const sourceHash = rasterBeforeTransform.pixelHash;
|
||||
await selectTool(page, 'transform');
|
||||
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-controls:visible button[title="Rasterize placed layer"]').click();
|
||||
await selectTool(page, 'transform');
|
||||
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,17 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor } = require('./helpers.js');
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
|
||||
test('setup checklist remains usable above the mobile layers sheet', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('#ge-setup-btn').click();
|
||||
const checklist = page.locator('.ge-preflight');
|
||||
const start = checklist.locator('[data-preflight-start]');
|
||||
await expect(checklist).toBeVisible();
|
||||
await start.scrollIntoViewIfNeeded();
|
||||
await expect(start).toHaveCSS('white-space', 'nowrap');
|
||||
expect(await start.evaluate(button => button.scrollWidth <= button.clientWidth)).toBe(true);
|
||||
await start.click();
|
||||
await expect(checklist).toBeHidden();
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { editorState, openBlankEditor, waitForDraft } = 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');
|
||||
await waitForDraft(page);
|
||||
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 Photoshop text menu 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,104 @@
|
||||
#!/usr/bin/env node
|
||||
// Quick Edit benchmark v2 grader (gpt-5.4 vision). Reads the runner output
|
||||
// (quickedit-bench.json) plus the saved before/after PNGs, scores each case
|
||||
// 0-3, and writes quickedit-bench-grades.json with per-case scores and
|
||||
// per-category averages. If an eye-scores JSON is provided (id->score), it
|
||||
// merges the human (eye) score, averages eye+gpt, and flags disagreements.
|
||||
//
|
||||
// Usage:
|
||||
// OUT=/tmp/opencode/qe-bench \
|
||||
// EYE=/path/eye-scores.json \
|
||||
// node quickedit-bench-grade.js
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const https = require('node:https');
|
||||
|
||||
const OUT = process.env.OUT || '/tmp/opencode/qe-bench';
|
||||
const MANIFEST = process.env.MANIFEST || path.join(OUT, 'quickedit-bench.json');
|
||||
const EYE = process.env.EYE || '';
|
||||
const MODEL = process.env.GRADE_MODEL || 'gpt-5.4';
|
||||
const KEY_FILE = process.env.OPENAI_KEY_FILE || '/home/pewds/.config/tiger/openai_user_20261009.key';
|
||||
|
||||
function getKey() {
|
||||
if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY.trim();
|
||||
return fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
}
|
||||
const KEY = getKey();
|
||||
const URL = 'https://api.openai.com/v1/chat/completions';
|
||||
function b64(p) { return fs.readFileSync(p).toString('base64'); }
|
||||
|
||||
function call(messages) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify({ model: MODEL, messages, max_completion_tokens: 2000 });
|
||||
const req = https.request(URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${KEY}`, 'Content-Length': Buffer.byteLength(body) },
|
||||
}, (res) => {
|
||||
let d = ''; res.on('data', (c) => (d += c));
|
||||
res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(new Error('bad json: ' + d.slice(0, 300))); } });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(body); req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function rubric(prompt) {
|
||||
return [
|
||||
'You are a STRICT photo-editing grader. You are given the BEFORE image and the AFTER image.',
|
||||
`The user asked: "${prompt}".`,
|
||||
'Score the AFTER versus the BEFORE on an integer 0-3:',
|
||||
'0 = worse than the original, or broken/ugly/artifacts; 1 = did something but weak, irrelevant, or barely visible; 2 = decent, clearly visible and natural; 3 = what a good retoucher would produce.',
|
||||
'Judge harshly. If a face/eyes/lips/hair are smeared, distorted, blown out, plastic, or lost natural texture, score 0. If highlights are clipped or the image is washed out, cap at 1.',
|
||||
'Reply with ONLY JSON: {"score": <int>, "worse": <bool>, "artifacts": "<short>", "reason": "<short>"}.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
|
||||
const eye = EYE && fs.existsSync(EYE) ? JSON.parse(fs.readFileSync(EYE, 'utf8')) : {};
|
||||
const out = [];
|
||||
for (const c of manifest.results) {
|
||||
const before = path.join(OUT, c.tag + '-before.png');
|
||||
const after = path.join(OUT, c.tag + '-after.png');
|
||||
let json = null, raw = null;
|
||||
if (fs.existsSync(before) && fs.existsSync(after)) {
|
||||
const content = [
|
||||
{ type: 'text', text: rubric(c.prompt) },
|
||||
{ type: 'text', text: 'BEFORE:' },
|
||||
{ type: 'image_url', image_url: { url: 'data:image/png;base64,' + b64(before) } },
|
||||
{ type: 'text', text: 'AFTER:' },
|
||||
{ type: 'image_url', image_url: { url: 'data:image/png;base64,' + b64(after) } },
|
||||
];
|
||||
for (let attempt = 0; attempt < 3 && !json; attempt++) {
|
||||
try {
|
||||
const res = await call([{ role: 'user', content }]);
|
||||
raw = res.choices && res.choices[0] && res.choices[0].message && res.choices[0].message.content;
|
||||
if (!raw && res.error) raw = 'API error: ' + JSON.stringify(res.error);
|
||||
const m = String(raw || '').match(/\{[\s\S]*\}/);
|
||||
if (m) { try { json = JSON.parse(m[0]); } catch (_) {} }
|
||||
} catch (e) { raw = String(e.message || e); }
|
||||
}
|
||||
} else {
|
||||
raw = 'missing before/after images';
|
||||
}
|
||||
const gpt = json && typeof json.score === 'number' ? json.score : null;
|
||||
const eyeScore = typeof eye[c.id] === 'number' ? eye[c.id] : null;
|
||||
const combined = (gpt != null && eyeScore != null) ? (gpt + eyeScore) / 2 : (gpt != null ? gpt : eyeScore);
|
||||
out.push({ id: c.id, cat: c.cat, prompt: c.prompt, ms: c.ms, ok: c.ok, gpt, eye: eyeScore, avg: combined, disagree: (gpt != null && eyeScore != null && Math.abs(gpt - eyeScore) >= 2), artifacts: json && json.artifacts, reason: json && json.reason });
|
||||
console.log(`GRADE #${c.id} [${c.cat}] gpt=${gpt} eye=${eyeScore} avg=${combined} ${c.prompt}`);
|
||||
}
|
||||
|
||||
const byCat = {};
|
||||
for (const r of out) {
|
||||
if (typeof r.avg !== 'number') continue;
|
||||
(byCat[r.cat] = byCat[r.cat] || []).push(r.avg);
|
||||
}
|
||||
const cats = {};
|
||||
for (const [k, v] of Object.entries(byCat)) cats[k] = +(v.reduce((a, b) => a + b, 0) / v.length).toFixed(2);
|
||||
const scored = out.filter(r => typeof r.avg === 'number');
|
||||
const overall = scored.length ? +(scored.reduce((a, b) => a + b.avg, 0) / scored.length).toFixed(2) : null;
|
||||
const disagreements = out.filter(r => r.disagree).map(r => r.id);
|
||||
|
||||
fs.writeFileSync(path.join(OUT, 'quickedit-bench-grades.json'), JSON.stringify({ model: MODEL, overall, byCat, disagreements, results: out }, null, 2));
|
||||
console.log(`BENCH_GRADES overall=${overall}/3 byCat=${JSON.stringify(cats)} disagreements=${JSON.stringify(disagreements)} -> ${path.join(OUT, 'quickedit-bench-grades.json')}`);
|
||||
})().catch((e) => { console.error('FAIL', e); process.exit(1); });
|
||||
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env node
|
||||
// Quick Edit benchmark v2 runner (standalone; not a Playwright spec).
|
||||
//
|
||||
// For each fixed prompt: import a fixed photo, run the editor agent, and
|
||||
// record the executed op trace, latency (ms), undo-group count (history.mark
|
||||
// delta), new layers, and before/after screenshots. Grading (eye + gpt-5.4)
|
||||
// happens separately on the saved images (see quickedit-bench-grade.js).
|
||||
//
|
||||
// Usage:
|
||||
// BASE_URL=http://127.0.0.1:7011 SESSION_FILE=/path/sessions.json \
|
||||
// NODE_PATH=/path/node_modules node quickedit-bench.js
|
||||
//
|
||||
// CATS=structural,looks limit to categories
|
||||
// PROMPTS=1,2,3 limit to ids
|
||||
// OUT=/tmp/opencode/qe-bench
|
||||
// PHOTOS=/tmp/opencode where the fixed jpg fixtures live
|
||||
// HEADFUL=1
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('@playwright/test');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:7011';
|
||||
const OUT = process.env.OUT || '/tmp/opencode/qe-bench';
|
||||
const PHOTOS = process.env.PHOTOS || '/tmp/opencode';
|
||||
const SESSION_FILE = process.env.SESSION_FILE || '/home/pewds/odysseus-cookbook-fresh/data/sessions.json';
|
||||
|
||||
// photo fixtures (in PHOTOS/): real-person.jpg pic-64.jpg pic-1015.jpg
|
||||
// pic-1018.jpg real-sky.jpg real-city.jpg real-mountainlake.jpg
|
||||
// real-beachsunset.jpg old-faded.jpg food.jpg night.jpg pic-1016.jpg
|
||||
// pic-1027.jpg demo-street.jpg
|
||||
const P = {
|
||||
portrait: 'real-person.jpg',
|
||||
portrait2: 'pic-64.jpg',
|
||||
fjord: 'pic-1015.jpg',
|
||||
hills: 'pic-1018.jpg',
|
||||
sky: 'real-sky.jpg',
|
||||
city: 'real-city.jpg',
|
||||
lake: 'real-mountainlake.jpg',
|
||||
sunset: 'real-beachsunset.jpg',
|
||||
old: 'old-faded.jpg',
|
||||
canyon: 'pic-1016.jpg',
|
||||
face: 'pic-1027.jpg',
|
||||
street: 'demo-street.jpg',
|
||||
};
|
||||
|
||||
const CASES = [
|
||||
// ---- structural (30) ----
|
||||
{ id: 1, cat: 'structural', photo: P.sky, prompt: 'crop to a square' },
|
||||
{ id: 2, cat: 'structural', photo: P.sky, prompt: 'make this 16:9' },
|
||||
{ id: 3, cat: 'structural', photo: P.portrait, prompt: 'crop to 9:16 for a story' },
|
||||
{ id: 4, cat: 'structural', photo: P.fjord, prompt: 'crop to 4:5' },
|
||||
{ id: 5, cat: 'structural', photo: P.city, prompt: 'trim the empty borders' },
|
||||
{ id: 6, cat: 'structural', photo: P.hills, prompt: 'resize to 1920 wide' },
|
||||
{ id: 7, cat: 'structural', photo: P.fjord, prompt: 'resize to 1080 tall' },
|
||||
{ id: 8, cat: 'structural', photo: P.hills, prompt: 'rotate the whole image 90 degrees' },
|
||||
{ id: 9, cat: 'structural', photo: P.city, prompt: 'rotate the canvas 180' },
|
||||
{ id: 10, cat: 'structural', photo: P.fjord, prompt: 'flip the canvas horizontally' },
|
||||
{ id: 11, cat: 'structural', photo: P.sky, prompt: 'flip the whole image vertically' },
|
||||
{ id: 12, cat: 'structural', photo: P.hills, prompt: 'rotate only this layer 90 degrees' },
|
||||
{ id: 13, cat: 'structural', photo: P.city, prompt: 'flip this layer horizontally' },
|
||||
{ id: 14, cat: 'structural', photo: P.sky, prompt: 'add the title "SUMMER"' },
|
||||
{ id: 15, cat: 'structural', photo: P.fjord, setup: 'text', prompt: 'make the text bigger' },
|
||||
{ id: 16, cat: 'structural', photo: P.city, setup: 'text', prompt: 'make the text smaller' },
|
||||
{ id: 17, cat: 'structural', photo: P.hills, setup: 'text', prompt: 'make the title red' },
|
||||
{ id: 18, cat: 'structural', photo: P.sky, prompt: 'align the layer to the centre', setup: 'align' },
|
||||
{ id: 19, cat: 'structural', photo: P.fjord, prompt: 'align the layer to the left', setup: 'align' },
|
||||
{ id: 20, cat: 'structural', photo: P.city, prompt: 'align the layer to the right', setup: 'align' },
|
||||
{ id: 21, cat: 'structural', photo: P.sky, setup: 'text', prompt: 'add a drop shadow to the title' },
|
||||
{ id: 22, cat: 'structural', photo: P.fjord, setup: 'text', prompt: 'add a stroke to the layer' },
|
||||
{ id: 23, cat: 'structural', photo: P.city, prompt: 'add a colour overlay' },
|
||||
{ id: 24, cat: 'structural', photo: P.sky, prompt: 'fill the white area with red' },
|
||||
{ id: 25, cat: 'structural', photo: P.sky, prompt: 'flood fill the sky with blue' },
|
||||
{ id: 26, cat: 'structural', photo: P.hills, prompt: 'add a new layer' },
|
||||
{ id: 27, cat: 'structural', photo: P.fjord, prompt: 'duplicate this layer' },
|
||||
{ id: 28, cat: 'structural', photo: P.city, prompt: 'flatten the image' },
|
||||
{ id: 29, cat: 'structural', photo: P.sky, prompt: 'merge the layer down' },
|
||||
{ id: 30, cat: 'structural', photo: P.hills, prompt: 'delete this layer' },
|
||||
|
||||
// ---- looks / adjustments (25) ----
|
||||
{ id: 31, cat: 'looks', photo: P.fjord, prompt: 'make it brighter' },
|
||||
{ id: 32, cat: 'looks', photo: P.hills, prompt: 'make it darker' },
|
||||
{ id: 33, cat: 'looks', photo: P.city, prompt: 'warm it up' },
|
||||
{ id: 34, cat: 'looks', photo: P.portrait, prompt: 'make it cooler' },
|
||||
{ id: 35, cat: 'looks', photo: P.fjord, prompt: 'make it black and white' },
|
||||
{ id: 36, cat: 'looks', photo: P.old, prompt: 'make it sepia' },
|
||||
{ id: 37, cat: 'looks', photo: P.hills, prompt: 'make it punchy' },
|
||||
{ id: 38, cat: 'looks', photo: P.fjord, prompt: 'make it vivid and colourful' },
|
||||
{ id: 39, cat: 'looks', photo: P.city, prompt: 'make it dramatic' },
|
||||
{ id: 40, cat: 'looks', photo: P.hills, prompt: 'make it moody and cinematic' },
|
||||
{ id: 41, cat: 'looks', photo: P.old, prompt: 'make it vintage' },
|
||||
{ id: 42, cat: 'looks', photo: P.old, prompt: 'give it a faded film look' },
|
||||
{ id: 43, cat: 'looks', photo: P.city, prompt: 'increase the contrast' },
|
||||
{ id: 44, cat: 'looks', photo: P.fjord, prompt: 'lower the contrast' },
|
||||
{ id: 45, cat: 'looks', photo: P.hills, prompt: 'sharpen it' },
|
||||
{ id: 46, cat: 'looks', photo: P.portrait, prompt: 'soften it' },
|
||||
{ id: 47, cat: 'looks', photo: P.fjord, prompt: 'blur it slightly' },
|
||||
{ id: 48, cat: 'looks', photo: P.city, prompt: 'add a vignette' },
|
||||
{ id: 49, cat: 'looks', photo: P.hills, prompt: 'dehaze it' },
|
||||
{ id: 50, cat: 'looks', photo: P.fjord, prompt: 'make it HDR' },
|
||||
{ id: 51, cat: 'looks', photo: P.city, prompt: 'auto enhance' },
|
||||
{ id: 52, cat: 'looks', photo: P.hills, prompt: 'give it a teal and orange look' },
|
||||
{ id: 53, cat: 'looks', photo: P.fjord, prompt: 'golden hour' },
|
||||
{ id: 54, cat: 'looks', photo: P.old, prompt: 'clean it up' },
|
||||
{ id: 55, cat: 'looks', photo: P.city, prompt: 'make it look professional' },
|
||||
|
||||
// ---- portrait (10) ----
|
||||
{ id: 56, cat: 'portrait', photo: P.portrait, prompt: 'smooth the skin' },
|
||||
{ id: 57, cat: 'portrait', photo: P.portrait, prompt: 'whiten the teeth' },
|
||||
{ id: 58, cat: 'portrait', photo: P.portrait, prompt: 'remove the blemishes' },
|
||||
{ id: 59, cat: 'portrait', photo: P.portrait, prompt: 'brighten the face' },
|
||||
{ id: 60, cat: 'portrait', photo: P.portrait, prompt: 'brighten the eyes' },
|
||||
{ id: 61, cat: 'portrait', photo: P.portrait2, prompt: 'make her look glamorous' },
|
||||
{ id: 62, cat: 'portrait', photo: P.portrait, prompt: 'give it a professional headshot look' },
|
||||
{ id: 63, cat: 'portrait', photo: P.portrait, prompt: 'reduce the shine on the face' },
|
||||
{ id: 64, cat: 'portrait', photo: P.portrait2, prompt: 'warm up the portrait' },
|
||||
{ id: 65, cat: 'portrait', photo: P.portrait, prompt: 'soft smooth skin with a nice glow' },
|
||||
|
||||
// ---- generative (20) ----
|
||||
{ id: 66, cat: 'generative', photo: P.city, prompt: 'remove the person on the left' },
|
||||
{ id: 67, cat: 'generative', photo: P.hills, prompt: 'remove the distractions' },
|
||||
{ id: 68, cat: 'generative', photo: P.portrait, prompt: 'put her on a beach' },
|
||||
{ id: 69, cat: 'generative', photo: P.portrait, prompt: 'replace the background with mountains' },
|
||||
{ id: 70, cat: 'generative', photo: P.fjord, prompt: 'add a full moon in the sky' },
|
||||
{ id: 71, cat: 'generative', photo: P.sky, prompt: 'add dramatic clouds' },
|
||||
{ id: 72, cat: 'generative', photo: P.sky, prompt: 'extend the canvas to 16:9 with generative fill' },
|
||||
{ id: 73, cat: 'generative', photo: P.sky, prompt: 'extend the canvas to 9:16' },
|
||||
{ id: 74, cat: 'generative', photo: P.fjord, prompt: 'add a bird flying in the sky' },
|
||||
{ id: 75, cat: 'generative', photo: P.hills, prompt: 'add wildflowers in the foreground' },
|
||||
{ id: 76, cat: 'generative', photo: P.fjord, prompt: 'make it look snowy' },
|
||||
{ id: 77, cat: 'generative', photo: P.hills, prompt: 'make it autumn with orange leaves' },
|
||||
{ id: 78, cat: 'generative', photo: P.city, prompt: 'add rain and wet streets' },
|
||||
{ id: 79, cat: 'generative', photo: P.city, prompt: 'remove the car' },
|
||||
{ id: 80, cat: 'generative', photo: P.city, prompt: 'remove the sign' },
|
||||
{ id: 81, cat: 'generative', photo: P.fjord, prompt: 'change the sky to a sunset' },
|
||||
{ id: 82, cat: 'generative', photo: P.portrait, prompt: 'put sunglasses on her' },
|
||||
{ id: 83, cat: 'generative', photo: P.portrait, prompt: 'add a hat' },
|
||||
{ id: 84, cat: 'generative', photo: P.fjord, prompt: 'add a rainbow' },
|
||||
{ id: 85, cat: 'generative', photo: P.sky, prompt: 'generative fill the empty sky' },
|
||||
|
||||
// ---- multi-step (15) ----
|
||||
{ id: 86, cat: 'multistep', photo: P.city, prompt: 'crop to a square, make it warmer and add the title SUMMER' },
|
||||
{ id: 87, cat: 'multistep', photo: P.fjord, prompt: 'make it black and white and add a vignette' },
|
||||
{ id: 88, cat: 'multistep', photo: P.hills, prompt: 'brighten it and sharpen it' },
|
||||
{ id: 89, cat: 'multistep', photo: P.city, setup: 'text', prompt: 'warm it up and add a drop shadow to the title' },
|
||||
{ id: 90, cat: 'multistep', photo: P.portrait, prompt: 'remove the background and make it brighter' },
|
||||
{ id: 91, cat: 'multistep', photo: P.fjord, prompt: 'crop to 16:9 and give it a moody cinematic look' },
|
||||
{ id: 92, cat: 'multistep', photo: P.hills, prompt: 'flip it horizontally and add a vignette' },
|
||||
{ id: 93, cat: 'multistep', photo: P.city, prompt: 'sharpen it and increase the contrast' },
|
||||
{ id: 94, cat: 'multistep', photo: P.fjord, prompt: 'make it punchy and warm it up' },
|
||||
{ id: 95, cat: 'multistep', photo: P.hills, prompt: 'rotate the whole image 90 and add a vignette' },
|
||||
{ id: 96, cat: 'multistep', photo: P.sky, prompt: 'add the title HELLO and a drop shadow' },
|
||||
{ id: 97, cat: 'multistep', photo: P.fjord, prompt: 'select the subject, invert it and blur it' },
|
||||
{ id: 98, cat: 'multistep', photo: P.old, prompt: 'make it black and white then add a title' },
|
||||
{ id: 99, cat: 'multistep', photo: P.city, prompt: 'warm and brighten with a vignette' },
|
||||
{ id: 100, cat: 'multistep', photo: P.hills, prompt: 'crop to a square and make it vintage' },
|
||||
|
||||
// ---- v2.1 harder prompts (30): multi-step with generative, poster/text
|
||||
// design, reference looks, and targeted local recolours. ----
|
||||
|
||||
// hardmulti (10): compound instructions where at least one step is generative.
|
||||
{ id: 101, cat: 'hardmulti', photo: P.city, prompt: 'remove the person on the left and then warm up the photo' },
|
||||
{ id: 102, cat: 'hardmulti', photo: P.portrait, prompt: 'put her on a beach and add a warm sunset' },
|
||||
{ id: 103, cat: 'hardmulti', photo: P.fjord, prompt: 'add a full moon and make the sky more purple' },
|
||||
{ id: 104, cat: 'hardmulti', photo: P.city, prompt: 'remove the sign and add some flowers' },
|
||||
{ id: 105, cat: 'hardmulti', photo: P.portrait, prompt: 'replace the background with mountains and make it cooler' },
|
||||
{ id: 106, cat: 'hardmulti', photo: P.sky, prompt: 'add dramatic clouds then increase the contrast' },
|
||||
{ id: 107, cat: 'hardmulti', photo: P.hills, prompt: 'make it snowy and add a warm glow' },
|
||||
{ id: 108, cat: 'hardmulti', photo: P.street, prompt: 'remove the red car and add a bicycle' },
|
||||
{ id: 109, cat: 'hardmulti', photo: P.sky, prompt: 'extend to 16:9 with generative fill and add a title SUMMER' },
|
||||
{ id: 110, cat: 'hardmulti', photo: P.city, prompt: 'add rain and make it moody' },
|
||||
|
||||
// poster (8): title / tagline text design.
|
||||
{ id: 111, cat: 'poster', photo: P.canyon, prompt: 'make a movie poster titled THE SUMMIT' },
|
||||
{ id: 112, cat: 'poster', photo: P.fjord, prompt: 'add a bold title "ESCAPE" at the bottom and a tagline' },
|
||||
{ id: 113, cat: 'poster', photo: P.canyon, prompt: 'add large centred title text in white with a drop shadow' },
|
||||
{ id: 114, cat: 'poster', photo: P.fjord, prompt: 'make a travel poster titled VISIT NORWAY' },
|
||||
{ id: 115, cat: 'poster', photo: P.face, prompt: 'add the title "DREAM" in a stylish font and a thin border' },
|
||||
{ id: 116, cat: 'poster', photo: P.canyon, prompt: 'turn it into a vintage travel poster with the text EXPLORE' },
|
||||
{ id: 117, cat: 'poster', photo: P.city, setup: 'text', prompt: 'add a subtitle under the title and align it to the centre' },
|
||||
{ id: 118, cat: 'poster', photo: P.fjord, prompt: 'design a minimalist poster: add the title "SILENCE" and desaturate the background' },
|
||||
|
||||
// reference (20): "make it look like <style>" — any film, photographer or era.
|
||||
{ id: 119, cat: 'reference', photo: P.city, prompt: 'make it look like a Wes Anderson film' },
|
||||
{ id: 120, cat: 'reference', photo: P.hills, prompt: 'give it a Studio Ghibli animated look' },
|
||||
{ id: 121, cat: 'reference', photo: P.face, prompt: 'make it look like a 1970s film photograph' },
|
||||
{ id: 122, cat: 'reference', photo: P.city, prompt: 'give it a Blade Runner neon night look' },
|
||||
{ id: 123, cat: 'reference', photo: P.canyon, prompt: 'make it look like a National Geographic photo' },
|
||||
{ id: 124, cat: 'reference', photo: P.portrait, prompt: 'make it look like a vintage Polaroid' },
|
||||
{ id: 131, cat: 'reference', photo: P.fjord, prompt: 'make it look like a Kodak film photograph' },
|
||||
{ id: 132, cat: 'reference', photo: P.city, prompt: 'give it a cinematic teal and orange movie look' },
|
||||
{ id: 133, cat: 'reference', photo: P.canyon, prompt: 'make it look like an old western film' },
|
||||
{ id: 134, cat: 'reference', photo: P.hills, prompt: 'make it look like a National Geographic wildlife photo' },
|
||||
{ id: 135, cat: 'reference', photo: P.city, prompt: 'give it a moody film noir look' },
|
||||
{ id: 136, cat: 'reference', photo: P.street, prompt: 'make it look like a 1980s VHS still' },
|
||||
{ id: 137, cat: 'reference', photo: P.lake, prompt: 'make it look like a golden hour film photograph' },
|
||||
{ id: 138, cat: 'reference', photo: P.hills, prompt: 'give it a Kurosawa colour film look' },
|
||||
{ id: 139, cat: 'reference', photo: P.fjord, prompt: 'make it look like a David Attenborough nature documentary' },
|
||||
{ id: 140, cat: 'reference', photo: P.portrait2, prompt: 'make it look like a 1990s disposable camera photo' },
|
||||
{ id: 141, cat: 'reference', photo: P.sunset, prompt: 'give it a Technicolor vintage look' },
|
||||
{ id: 142, cat: 'reference', photo: P.lake, prompt: 'make it look like a black and white Ansel Adams landscape' },
|
||||
{ id: 143, cat: 'reference', photo: P.city, prompt: 'make it look like a Wong Kar-wai film' },
|
||||
{ id: 144, cat: 'reference', photo: P.old, prompt: 'give it a faded Kodachrome look' },
|
||||
|
||||
// targeted (6): recolour only a specific object/region.
|
||||
{ id: 125, cat: 'targeted', photo: P.street, prompt: 'make only the red car blue' },
|
||||
{ id: 126, cat: 'targeted', photo: P.street, prompt: 'change the yellow car to red' },
|
||||
{ id: 127, cat: 'targeted', photo: P.canyon, prompt: 'recolour the red rock to teal' },
|
||||
{ id: 128, cat: 'targeted', photo: P.fjord, prompt: 'turn the sky purple but keep the water natural' },
|
||||
{ id: 129, cat: 'targeted', photo: P.city, prompt: 'make the flowers bright pink' },
|
||||
{ id: 130, cat: 'targeted', photo: P.old, prompt: 'colourise the faded photo naturally' },
|
||||
|
||||
// bgremove (6): every common background-removal phrasing must map to the
|
||||
// deterministic ai.removeBackground op, never the AI agent / a tone.
|
||||
{ id: 145, cat: 'bgremove', photo: P.portrait, prompt: 'remove the background' },
|
||||
{ id: 146, cat: 'bgremove', photo: P.portrait, prompt: 'bg removal' },
|
||||
{ id: 147, cat: 'bgremove', photo: P.portrait, prompt: 'remove bg' },
|
||||
{ id: 148, cat: 'bgremove', photo: P.portrait, prompt: 'no background' },
|
||||
{ id: 149, cat: 'bgremove', photo: P.portrait, prompt: 'make the background transparent' },
|
||||
{ id: 150, cat: 'bgremove', photo: P.portrait, prompt: 'cut out the subject' },
|
||||
// ---- vague / misspelled (5) — the planner must not silently apply a
|
||||
// default brightness/vibrance tweak; it now falls back to the image model so
|
||||
// the prompt still does something meaningful.
|
||||
{ id: 151, cat: 'vague', photo: P.city, prompt: 'make it better' },
|
||||
{ id: 152, cat: 'vague', photo: P.fjord, prompt: 'fix it' },
|
||||
{ id: 153, cat: 'vague', photo: P.portrait, prompt: 'enhace the pic' },
|
||||
{ id: 154, cat: 'vague', photo: P.hills, prompt: 'do the thing' },
|
||||
{ id: 155, cat: 'vague', photo: P.portrait, prompt: 'make it look proffesional' },
|
||||
// ---- auto fall-through (model) — a generative prompt in Auto mode must land
|
||||
// as ONE NEW layer with a single undo group; a vague/unrecognised prompt now
|
||||
// falls through to the image model too (no dead-end "not sure" reply). ----
|
||||
{ id: 156, cat: 'auto', photo: P.city, prompt: 'add a man to the scene' },
|
||||
{ id: 157, cat: 'auto', photo: P.sky, prompt: 'add a hot air balloon' },
|
||||
{ id: 158, cat: 'auto', photo: P.fjord, prompt: 'do the thing' },
|
||||
// ---- restyle — must route to ai_edit_image (model edit), NOT ask and NOT
|
||||
// a filter tweak; run with MODE=agent to exercise the server planner. ----
|
||||
{ id: 159, cat: 'restyle', photo: P.fjord, prompt: 'turn it into an oil painting' },
|
||||
{ id: 160, cat: 'restyle', photo: P.hills, prompt: 'make it look like a watercolour' },
|
||||
{ id: 161, cat: 'restyle', photo: P.portrait, prompt: 'make it look like an anime' },
|
||||
{ id: 162, cat: 'restyle', photo: P.city, prompt: 'make it a comic book illustration' },
|
||||
];
|
||||
|
||||
// Cases that load a heavy in-process model on the app host (remove-background,
|
||||
// ~9.5 GB) and can OOM-kill the user's service; HARNESS is moving these to the
|
||||
// seneca image server. Marked pending instead of executed so a bench run never
|
||||
// crashes the app. Extend with SKIP=<ids> (e.g. SAM-heavy cases) if needed.
|
||||
// Heavy in-process cases to skip. Default #90 (remove-background) while it
|
||||
// risked the 7011 OOM; now that heavy image ops proxy to seneca, set
|
||||
// PENDING= (empty) to run it.
|
||||
const PENDING = new Set(String(process.env.PENDING != null ? process.env.PENDING : '90')
|
||||
.split(',').map(s => Number(s.trim())).filter(Boolean));
|
||||
for (const id of String(process.env.SKIP || '').split(',').map(s => Number(s.trim())).filter(Boolean)) PENDING.add(id);
|
||||
|
||||
function authToken() {
|
||||
const data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
||||
const now = Date.now() / 1000;
|
||||
for (const [token, info] of Object.entries(data)) {
|
||||
if (info.expiry == null || info.expiry > now) return token;
|
||||
}
|
||||
throw new Error(`no unexpired session in ${SESSION_FILE}`);
|
||||
}
|
||||
|
||||
function selected() {
|
||||
let cases = CASES;
|
||||
if (process.env.CATS) {
|
||||
const cats = new Set(process.env.CATS.split(',').map(s => s.trim()));
|
||||
cases = cases.filter(c => cats.has(c.cat));
|
||||
}
|
||||
if (process.env.PROMPTS) {
|
||||
const want = new Set(process.env.PROMPTS.split(',').map(s => Number(s.trim())));
|
||||
cases = cases.filter(c => want.has(c.id));
|
||||
}
|
||||
return cases;
|
||||
}
|
||||
|
||||
function saveShot(url, file) {
|
||||
const idx = url.indexOf(',');
|
||||
fs.writeFileSync(file, Buffer.from(url.slice(idx + 1), 'base64'));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: !process.env.HEADFUL });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 860 }, serviceWorkers: 'block' });
|
||||
await context.addCookies([{ name: 'odysseus_session', value: authToken(), domain: new URL(BASE).hostname, path: '/' }]);
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', e => console.log(' [pageerror]', String(e).slice(0, 240)));
|
||||
// Per-case image-op HTTP statuses so a real no-op (request succeeded but the
|
||||
// image is unchanged) is distinguishable from an outage (instant 5xx/000).
|
||||
let netLog = [];
|
||||
page.on('response', r => {
|
||||
const m = r.url().match(/\/api\/image\/([a-z-]+)/);
|
||||
if (m) netLog.push({ op: m[1], status: r.status() });
|
||||
});
|
||||
page.on('requestfailed', r => {
|
||||
const m = r.url().match(/\/api\/image\/([a-z-]+)/);
|
||||
if (m) netLog.push({ op: m[1], status: 'failed:' + ((r.failure() && r.failure().errorText) || '?') });
|
||||
});
|
||||
|
||||
const results = [];
|
||||
for (const c of selected()) {
|
||||
const tag = String(c.id).padStart(3, '0') + '-' + c.cat + '-' + c.prompt.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
|
||||
const rec = { id: c.id, cat: c.cat, prompt: c.prompt, photo: c.photo, ms: null, ok: null, blocked: null, reply: null, ops: [], newLayers: [], undoGroups: null, error: null, tag };
|
||||
if (PENDING.has(c.id)) {
|
||||
rec.pending = true;
|
||||
results.push(rec);
|
||||
console.log(`BENCH PENDING #${c.id} [${c.cat}] "${c.prompt}" (heavy in-process model; excluded)`);
|
||||
fs.writeFileSync(path.join(OUT, 'quickedit-bench.json'), JSON.stringify({ base: BASE, results }, null, 2));
|
||||
continue;
|
||||
}
|
||||
netLog = [];
|
||||
try {
|
||||
await page.goto(BASE + '/?nodraft=1', { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 30000 });
|
||||
await page.evaluate(async () => {
|
||||
const gallery = await import('/static/js/gallery.js?v=20260830editor4');
|
||||
gallery.openGallery();
|
||||
});
|
||||
await page.locator('#gallery-editor-tab').first().waitFor({ state: 'attached', timeout: 30000 });
|
||||
await page.evaluate(() => document.getElementById('gallery-editor-tab')?.click());
|
||||
const dataUrl = 'data:image/jpeg;base64,' + fs.readFileSync(path.join(PHOTOS, c.photo)).toString('base64');
|
||||
const selfCheck = process.env.SELFCHECK !== '0';
|
||||
const mode = process.env.MODE || '';
|
||||
await page.evaluate(async ({ selfCheck, mode, dataUrl }) => {
|
||||
const editor = await import('/static/js/galleryEditor.js?qb=' + Date.now());
|
||||
window.__geNoDraft = true;
|
||||
if (editor.isEditorOpen()) editor.closeEditor({ force: true });
|
||||
window.geQuickEditSelfCheck = selfCheck;
|
||||
try { if (mode) localStorage.setItem('ge-ai-command-mode', mode); else localStorage.removeItem('ge-ai-command-mode'); } catch (_) {}
|
||||
try { localStorage.setItem('ge-preflight-dismissed', '1'); } catch (_) {}
|
||||
// Size the document to the photo's aspect so importing never leaves
|
||||
// white letterbox margins (which the model then paints as an edge band).
|
||||
const dim = await new Promise((res) => { const im = new Image(); im.onload = () => res({ w: im.naturalWidth || 800, h: im.naturalHeight || 600 }); im.onerror = () => res({ w: 800, h: 600 }); im.src = dataUrl; });
|
||||
const W = 800; const H = Math.max(1, Math.min(1600, Math.round(W * dim.h / dim.w)));
|
||||
editor.openEditor(null, null, { w: W, h: H }, 'BENCH ' + Date.now());
|
||||
}, { selfCheck, mode, dataUrl });
|
||||
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 30000 });
|
||||
await page.waitForFunction(() => !!window.geEditorControl && !!window.geEditorAgent, null, { timeout: 20000 });
|
||||
|
||||
const out = await page.evaluate(async ({ dataUrl, prompt, setup }) => {
|
||||
const invoke = (op, params) => window.geEditorControl.invoke(op, params);
|
||||
await invoke('document.importImage', { dataUrl, name: 'photo', raster: true });
|
||||
if (setup === 'text') await invoke('text.add', { text: 'Summer', x: 80, y: 80, size: 64 });
|
||||
else if (setup === 'align') await invoke('layers.addRaster', {});
|
||||
const before = (await invoke('screenshot')).result.dataUrl;
|
||||
const mark0 = (await invoke('history.mark')).result;
|
||||
const layers0 = (await invoke('layers.list')).result.map(l => l.name + ':' + l.kind);
|
||||
const ops = [];
|
||||
const control = window.geEditorControl;
|
||||
const orig = control.invoke.bind(control);
|
||||
control.invoke = async (op, params) => { ops.push(op); return orig(op, params); };
|
||||
window.__selfCheckLog = [];
|
||||
const t0 = Date.now();
|
||||
let run = null, error = null;
|
||||
try {
|
||||
run = window.geQuickEditRun
|
||||
? await window.geQuickEditRun(prompt, {})
|
||||
: await window.geEditorAgent.run(prompt, {});
|
||||
}
|
||||
catch (err) { error = String((err && err.message) || err); }
|
||||
finally { control.invoke = orig; }
|
||||
const ms = Date.now() - t0;
|
||||
const after = (await invoke('screenshot')).result.dataUrl;
|
||||
const mark1 = (await invoke('history.mark')).result;
|
||||
const layers1 = (await invoke('layers.list')).result;
|
||||
const newLayers = layers1.map(l => l.name + ':' + l.kind).filter(s => !layers0.includes(s));
|
||||
return { before, after, mark0, mark1, newLayers, ms, ops, reply: run && (run.reply || run.message), ok: run && run.ok, blocked: run && run.blocked, error, selfChecks: window.__selfCheckLog || [] };
|
||||
}, { dataUrl, prompt: c.prompt, setup: c.setup || null });
|
||||
|
||||
rec.ms = out.ms; rec.ops = out.ops; rec.reply = out.reply; rec.ok = out.ok;
|
||||
rec.blocked = out.blocked; rec.newLayers = out.newLayers;
|
||||
rec.selfChecks = out.selfChecks || [];
|
||||
rec.undoGroups = (typeof out.mark1 === 'number' && typeof out.mark0 === 'number') ? out.mark1 - out.mark0 : null;
|
||||
rec.error = out.error;
|
||||
rec.net = netLog.slice();
|
||||
saveShot(out.before, path.join(OUT, tag + '-before.png'));
|
||||
saveShot(out.after, path.join(OUT, tag + '-after.png'));
|
||||
} catch (err) {
|
||||
rec.error = String((err && err.message) || err);
|
||||
}
|
||||
results.push(rec);
|
||||
for (const sc of (rec.selfChecks || [])) {
|
||||
console.log(`SELFCHECK #${c.id} verdict=${JSON.stringify(sc.verdict)} retry=${sc.retry ? JSON.stringify(sc.retry) : 'null'}`);
|
||||
}
|
||||
console.log(`BENCH ${rec.ok === false ? 'FAIL' : 'OK'} #${c.id} [${c.cat}] "${c.prompt}" ${rec.ms}ms ops=${rec.ops.join(',') || rec.error} net=${JSON.stringify(rec.net || [])}`);
|
||||
fs.writeFileSync(path.join(OUT, 'quickedit-bench.json'), JSON.stringify({ base: BASE, results }, null, 2));
|
||||
}
|
||||
|
||||
const pending = results.filter(r => r.pending).length;
|
||||
const ran = results.filter(r => !r.pending && r.ok !== false && !r.error).length;
|
||||
console.log(`QUICKEDIT_BENCH ${ran}/${results.length - pending} ran without error (${pending} pending) -> ${path.join(OUT, 'quickedit-bench.json')}`);
|
||||
await browser.close();
|
||||
process.exit(0);
|
||||
})().catch(err => { console.error('FAIL', err); process.exit(1); });
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
// Quick Edit coverage audit runner (standalone; not a Playwright spec).
|
||||
//
|
||||
// Drives the live editor agent for every prompt in CASES and grades each by
|
||||
// whether an expected control op appears in the executed-step trace.
|
||||
//
|
||||
// Usage:
|
||||
// BASE_URL=http://127.0.0.1:7011 SESSION_FILE=/path/sessions.json \
|
||||
// NODE_PATH=/path/node_modules node quickedit-coverage.js
|
||||
//
|
||||
// PROMPTS=1,2,3 limit to a subset
|
||||
// OUT=/tmp/qe output dir for the JSON report
|
||||
// HEADFUL=1 show the browser
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('@playwright/test');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:7011';
|
||||
const OUT = process.env.OUT || '/tmp/opencode/qe-coverage';
|
||||
const SESSION_FILE = process.env.SESSION_FILE || '/home/pewds/odysseus-cookbook-fresh/data/sessions.json';
|
||||
|
||||
const CASES = [
|
||||
{ id: 1, group: 'Fill', prompt: 'fill the white area with red', expect: ['fill.flood', 'selection.fill'] },
|
||||
{ id: 2, group: 'Fill', prompt: 'flood fill the sky with blue', expect: ['fill.flood', 'selection.fill', 'selection.sky'] },
|
||||
{ id: 3, group: 'Text', prompt: 'make the text bigger', expect: ['text.edit'], setup: 'text' },
|
||||
{ id: 4, group: 'Text', prompt: 'make the title red', expect: ['text.edit'], setup: 'text' },
|
||||
{ id: 5, group: 'Text', prompt: 'add the title "Summer"', expect: ['text.add'] },
|
||||
{ id: 6, group: 'Layer style', prompt: 'add a drop shadow to the title', expect: ['layers.addStyle'], setup: 'text' },
|
||||
{ id: 7, group: 'Layer style', prompt: 'add a stroke to the layer', expect: ['layers.addStyle'] },
|
||||
{ id: 8, group: 'Layer style', prompt: 'add a colour overlay', expect: ['layers.addStyle'] },
|
||||
{ id: 9, group: 'Align', prompt: 'align the logo to the centre', expect: ['layers.align'] },
|
||||
{ id: 10, group: 'Align', prompt: 'align the layer to the left', expect: ['layers.align'] },
|
||||
{ id: 11, group: 'Layer transform', prompt: 'rotate only this layer 90 degrees', expect: ['layer.rotate'] },
|
||||
{ id: 12, group: 'Layer transform', prompt: 'flip this layer horizontally', expect: ['layer.flip'] },
|
||||
{ id: 13, group: 'Document transform', prompt: 'rotate the whole image 90 degrees', expect: ['document.rotate'] },
|
||||
{ id: 14, group: 'Document transform', prompt: 'flip the canvas vertically', expect: ['document.flip'] },
|
||||
{ id: 15, group: 'Crop', prompt: 'crop to a square', expect: ['document.crop'] },
|
||||
{ id: 16, group: 'Crop', prompt: 'make this 16:9', expect: ['document.crop'] },
|
||||
{ id: 17, group: 'Crop', prompt: 'crop to 9:16 for a story', expect: ['document.crop'] },
|
||||
{ id: 18, group: 'Extend', prompt: 'extend the canvas to 16:9', expect: ['ai.outpaint'] },
|
||||
{ id: 19, group: 'Clipboard', prompt: 'copy the selection', expect: ['selection.copy'], setup: 'select' },
|
||||
{ id: 20, group: 'Clipboard', prompt: 'paste', expect: ['selection.paste'], setup: 'select' },
|
||||
{ id: 21, group: 'Selection', prompt: 'select the subject', expect: ['selection.subject'] },
|
||||
{ id: 22, group: 'Selection', prompt: 'select the sky', expect: ['selection.sky'] },
|
||||
{ id: 23, group: 'Selection', prompt: 'select all', expect: ['selection.selectAll'] },
|
||||
{ id: 24, group: 'Selection', prompt: 'invert the selection', expect: ['selection.invert'] },
|
||||
{ id: 25, group: 'Background', prompt: 'remove the background', expect: ['ai.removeBackground', 'selection.background'] },
|
||||
{ id: 26, group: 'Background', prompt: 'blur the background', expect: ['filters.applyMasked', 'filters.apply'] },
|
||||
{ id: 27, group: 'Adjustment', prompt: 'make it brighter', expect: ['photo.autoTone', 'adjustments.apply', 'shadows-highlights'] },
|
||||
{ id: 28, group: 'Adjustment', prompt: 'make it black and white', expect: ['adjustments.apply', 'black-white'], assert: 'black' },
|
||||
{ id: 29, group: 'Adjustment', prompt: 'warm it up', expect: ['adjustments.apply', 'photo.autoTone', 'white-balance'] },
|
||||
{ id: 30, group: 'Adjustment', prompt: 'make it punchy', expect: ['photo.autoTone', 'adjustments.apply', 'vibrance'] },
|
||||
{ id: 31, group: 'Adjustment', prompt: 'make it moody and cinematic', expect: ['adjustments.apply', 'white-balance'] },
|
||||
{ id: 32, group: 'Restore', prompt: 'restore this old photo', expect: ['photo.autoTone', 'adjustments.apply'] },
|
||||
{ id: 33, group: 'Retouch', prompt: 'smooth the skin', expect: ['retouch.skin', 'adjustments.apply'] },
|
||||
{ id: 34, group: 'Retouch', prompt: 'whiten the teeth', expect: ['retouch.teeth', 'adjustments.apply'] },
|
||||
{ id: 35, group: 'Retouch', prompt: 'remove the blemishes', expect: ['retouch.blemish', 'retouch.skin', 'adjustments.apply'] },
|
||||
{ id: 36, group: 'Filter', prompt: 'sharpen it', expect: ['filters.apply', 'adjustments.apply', 'photo.autoTone'] },
|
||||
{ id: 37, group: 'Filter', prompt: 'add a vignette', expect: ['filters.apply', 'adjustments.apply'] },
|
||||
{ id: 38, group: 'Layers', prompt: 'add a new layer', expect: ['layers.addRaster', 'layers.addAdjustment'] },
|
||||
{ id: 39, group: 'Layers', prompt: 'duplicate this layer', expect: ['layers.duplicate'] },
|
||||
{ id: 40, group: 'Layers', prompt: 'flatten the image', expect: ['layers.flatten'] },
|
||||
{ id: 41, group: 'Layers', prompt: 'merge the layer down', expect: ['layers.mergeDown'] },
|
||||
{ id: 42, group: 'Document', prompt: 'save as PSD', expect: ['document.savePsd'] },
|
||||
{ id: 43, group: 'Document', prompt: 'resize to 1920 wide', expect: ['document.resizeCanvas'] },
|
||||
{ id: 44, group: 'AI edit', prompt: 'remove the person on the left', expect: ['ai.generativeFill'] },
|
||||
{ id: 45, group: 'AI generate', prompt: 'add a full moon in the sky', expect: ['ai.generativeFill'] },
|
||||
];
|
||||
|
||||
function authToken() {
|
||||
const data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
||||
const now = Date.now() / 1000;
|
||||
for (const [token, info] of Object.entries(data)) {
|
||||
if (info.expiry == null || info.expiry > now) return token;
|
||||
}
|
||||
throw new Error(`no unexpired session in ${SESSION_FILE}`);
|
||||
}
|
||||
|
||||
function selected() {
|
||||
const raw = process.env.PROMPTS;
|
||||
if (!raw) return CASES;
|
||||
const want = new Set(raw.split(',').map(s => Number(s.trim())));
|
||||
return CASES.filter(c => want.has(c.id));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: !process.env.HEADFUL });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 860 }, serviceWorkers: 'block' });
|
||||
await context.addCookies([{ name: 'odysseus_session', value: authToken(), domain: new URL(BASE).hostname, path: '/' }]);
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', e => console.log(' [pageerror]', String(e).slice(0, 240)));
|
||||
|
||||
const results = [];
|
||||
for (const c of selected()) {
|
||||
let record = { id: c.id, group: c.group, prompt: c.prompt, expect: c.expect, ops: [], pass: false, error: null };
|
||||
try {
|
||||
await page.goto(BASE + '/?nodraft=1', { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 30000 });
|
||||
await page.evaluate(async () => {
|
||||
const gallery = await import('/static/js/gallery.js?v=20260830editor4');
|
||||
gallery.openGallery();
|
||||
});
|
||||
await page.locator('#gallery-editor-tab').first().waitFor({ state: 'attached', timeout: 30000 });
|
||||
await page.evaluate(() => document.getElementById('gallery-editor-tab')?.click());
|
||||
await page.evaluate(async () => {
|
||||
const editor = await import('/static/js/galleryEditor.js?qe=' + Date.now());
|
||||
window.__geNoDraft = true;
|
||||
if (editor.isEditorOpen()) editor.closeEditor({ force: true });
|
||||
editor.openEditor(null, null, { w: 640, h: 480 }, 'QE ' + Date.now());
|
||||
});
|
||||
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 30000 });
|
||||
await page.waitForFunction(() => !!window.geEditorControl, null, { timeout: 20000 });
|
||||
|
||||
if (c.setup) {
|
||||
await page.evaluate(async (setup) => {
|
||||
const control = window.geEditorControl;
|
||||
if (setup === 'text') await control.invoke('text.add', { text: 'Summer', x: 80, y: 80, size: 64 });
|
||||
else if (setup === 'select') await control.invoke('selection.rectangle', { x: 100, y: 100, width: 200, height: 160 });
|
||||
}, c.setup);
|
||||
}
|
||||
|
||||
const trace = await page.evaluate(async (prompt) => {
|
||||
const ops = [];
|
||||
const control = window.geEditorControl;
|
||||
const orig = control.invoke.bind(control);
|
||||
control.invoke = async (op, params) => { ops.push({ op, params }); return orig(op, params); };
|
||||
let result = null, error = null;
|
||||
try {
|
||||
result = window.geQuickEditRun
|
||||
? await window.geQuickEditRun(prompt, {})
|
||||
: await window.geEditorAgent.run(prompt, {});
|
||||
}
|
||||
catch (err) { error = String((err && err.message) || err); }
|
||||
finally { control.invoke = orig; }
|
||||
return { ops, reply: result && (result.reply || result.message), error };
|
||||
}, c.prompt);
|
||||
|
||||
record.ops = trace.ops.map(o => o.op);
|
||||
record.reply = trace.reply;
|
||||
record.error = trace.error;
|
||||
const opHit = c.expect.some(e => record.ops.some(o => o === e || o.startsWith(e + '.')));
|
||||
const assertHit = !c.assert || trace.ops.some(o => JSON.stringify(o.params || {}).includes(c.assert));
|
||||
record.pass = opHit && assertHit;
|
||||
} catch (err) {
|
||||
record.error = String((err && err.message) || err);
|
||||
}
|
||||
results.push(record);
|
||||
console.log(`QE ${record.pass ? 'PASS' : 'FAIL'} #${c.id} [${c.group}] "${c.prompt}" -> ${record.ops.join(', ') || record.error}`);
|
||||
}
|
||||
|
||||
const pass = results.filter(r => r.pass).length;
|
||||
fs.writeFileSync(path.join(OUT, 'quickedit-coverage.json'), JSON.stringify({ base: BASE, pass, total: results.length, results }, null, 2));
|
||||
console.log(`QUICKEDIT_COVERAGE ${pass}/${results.length} passed -> ${path.join(OUT, 'quickedit-coverage.json')}`);
|
||||
await browser.close();
|
||||
process.exit(0);
|
||||
})().catch(err => { console.error('FAIL', err); process.exit(1); });
|
||||
@@ -0,0 +1,48 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, selectTool } = require('./helpers.js');
|
||||
|
||||
test('paint tools require confirmation before rasterizing text layers, preserving text on cancel', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await selectTool(page, 'text');
|
||||
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');
|
||||
expect(retained).toBeDefined();
|
||||
const kind = async () => (await editorState(page)).layers.find(layer => layer.id === retained.id)?.kind;
|
||||
|
||||
// Tool selection alone does not prompt and does not rasterize
|
||||
for (const tool of ['brush', 'eraser']) {
|
||||
await selectTool(page, tool);
|
||||
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
||||
expect(await kind()).toBe('text');
|
||||
}
|
||||
|
||||
// Attempting to draw triggers confirmation
|
||||
await selectTool(page, 'brush');
|
||||
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.25, y: 0.2 });
|
||||
await expect(page.locator('#styled-confirm-overlay')).toBeVisible();
|
||||
|
||||
// Cancelling keeps text editable with no changes
|
||||
await page.locator('#styled-confirm-cancel').click();
|
||||
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
||||
expect(await kind()).toBe('text');
|
||||
|
||||
// Second stroke prompt confirmed -> rasterizes layer
|
||||
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.25, y: 0.2 });
|
||||
await expect(page.locator('#styled-confirm-overlay')).toBeVisible();
|
||||
await page.locator('#styled-confirm-ok').click();
|
||||
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
||||
expect(await kind()).toBe('raster');
|
||||
|
||||
// Subsequent strokes on the raster layer draw immediately without prompting
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.35, y: 0.3 });
|
||||
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
||||
|
||||
// Undo restores editable text
|
||||
await page.keyboard.press('Control+z');
|
||||
await page.keyboard.press('Control+z');
|
||||
expect(await kind()).toBe('text');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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 selectTool(page, 'brush');
|
||||
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 });
|
||||
const readState = async (key) => {
|
||||
try {
|
||||
return (await editorState(page))[key];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
await expect.poll(() => readState('draftId'), { timeout: 20_000 }).toBe(draftId);
|
||||
await expect.poll(() => readState('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,225 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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 selectTool(page, 'marquee');
|
||||
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-ratio geometry', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Precise marquee');
|
||||
await selectTool(page, 'marquee');
|
||||
|
||||
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 selectTool(page, 'marquee');
|
||||
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-section')).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();
|
||||
await expect(page.locator('#ge-transform-section')).toBeVisible();
|
||||
const mobileSection = await page.locator('#ge-transform-section').boundingBox();
|
||||
expect(mobileSection.x).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileSection.y).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileSection.x + mobileSection.width).toBeLessThanOrEqual(390);
|
||||
expect(mobileSection.y + mobileSection.height).toBeLessThanOrEqual(844);
|
||||
await page.locator('#ge-transform-cancel-btn').click();
|
||||
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.transformActive)).toBe(false);
|
||||
expect((await editorState(page)).selection.pixelHash).toBe(original.selection.pixelHash);
|
||||
});
|
||||
|
||||
test('named selections support reselect, load, delete, and server-draft reopen', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Saved selections');
|
||||
await selectTool(page, 'marquee');
|
||||
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);
|
||||
});
|
||||
|
||||
test('marquee released over the right panel commits a clamped selection', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Marquee overshoot');
|
||||
await selectTool(page, 'marquee');
|
||||
const setup = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const rect = state.mainCanvas.getBoundingClientRect();
|
||||
const panel = document.querySelector('.ge-right-panel');
|
||||
const panelBox = panel ? panel.getBoundingClientRect() : null;
|
||||
return {
|
||||
document: { width: state.imgWidth, height: state.imgHeight },
|
||||
start: { x: rect.left + rect.width * 0.2, y: rect.top + rect.height * 0.2 },
|
||||
panelPoint: panelBox
|
||||
? { x: panelBox.left + panelBox.width / 2, y: rect.top + rect.height * 0.6 }
|
||||
: null,
|
||||
};
|
||||
});
|
||||
expect(setup.panelPoint, 'right panel must be visible on desktop').toBeTruthy();
|
||||
|
||||
await page.mouse.move(setup.start.x, setup.start.y);
|
||||
await page.mouse.down();
|
||||
// Drag straight onto the right panel. The window mousemove guard must
|
||||
// keep the raw rect from extending off-canvas, and the release must
|
||||
// commit a clamped selection rather than discard it.
|
||||
await page.mouse.move(setup.panelPoint.x, setup.panelPoint.y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
if (!state.wandMask) return { active: false };
|
||||
const data = state.wandMask.getContext('2d')
|
||||
.getImageData(0, 0, state.wandMask.width, state.wandMask.height).data;
|
||||
let left = state.wandMask.width, top = state.wandMask.height, right = -1, bottom = -1;
|
||||
for (let y = 0; y < state.wandMask.height; y += 1) {
|
||||
for (let x = 0; x < state.wandMask.width; x += 1) {
|
||||
if (data[(y * state.wandMask.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 { active: true, left, top, right, bottom, width: state.wandMask.width, height: state.wandMask.height };
|
||||
});
|
||||
expect(result.active).toBe(true);
|
||||
expect(result.right).toBeGreaterThanOrEqual(result.left);
|
||||
expect(result.bottom).toBeGreaterThanOrEqual(result.top);
|
||||
// Clamped: never beyond the document edge on any side.
|
||||
expect(result.left).toBeGreaterThanOrEqual(0);
|
||||
expect(result.top).toBeGreaterThanOrEqual(0);
|
||||
expect(result.right).toBeLessThan(result.width);
|
||||
expect(result.bottom).toBeLessThan(result.height);
|
||||
expect(result.right - result.left).toBeGreaterThan(0);
|
||||
expect(result.bottom - result.top).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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 selectTool(page, 'brush');
|
||||
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
|
||||
await selectTool(page, 'marquee');
|
||||
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 removes source pixels in one undoable step', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection cut');
|
||||
await selectTool(page, 'brush');
|
||||
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
|
||||
await selectTool(page, 'marquee');
|
||||
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');
|
||||
// Cut copies to the clipboard and erases the selected pixels in place —
|
||||
// it does not create a new layer (that is Ctrl+Shift+J, Layer via Cut).
|
||||
const cut = await editorState(page);
|
||||
expect(cut.layers).toHaveLength(before.layers.length);
|
||||
expect(cut.selection).toBeNull();
|
||||
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 selectTool(page, 'brush');
|
||||
await dragOnCanvas(page, { x: 0.28, y: 0.3 }, { x: 0.62, y: 0.58 });
|
||||
await selectTool(page, 'lasso');
|
||||
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 selectTool(page, 'marquee');
|
||||
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-controls:visible .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 selectTool(page, 'lasso');
|
||||
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,46 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor, selectTool } = require('./helpers.js');
|
||||
|
||||
// Editors opened wider than 700px move the tool controls into the options
|
||||
// strip. At tablet widths (701-820px) that strip must stay visible, or every
|
||||
// tool control is stranded in a hidden container.
|
||||
for (const width of [768, 820]) {
|
||||
test(`tool controls stay reachable at a ${width}px tablet width`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 1024 });
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, `Tablet ${width}`);
|
||||
const inViewport = locator => locator.evaluate(el => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return box.width > 0 && box.left >= 0 && box.right <= innerWidth + 1 && box.bottom <= innerHeight + 1;
|
||||
});
|
||||
|
||||
await selectTool(page, 'brush');
|
||||
await expect(page.locator('#ge-options-bar')).toBeVisible();
|
||||
await expect(page.locator('#ge-options-bar .ge-size-slider')).toBeVisible();
|
||||
expect(await inViewport(page.locator('#ge-options-bar .ge-size-slider'))).toBe(true);
|
||||
|
||||
await selectTool(page, 'transform');
|
||||
await expect(page.locator('#ge-transform-w')).toBeVisible();
|
||||
await page.locator('#ge-transform-w').fill('200');
|
||||
await page.locator('#ge-transform-w').dispatchEvent('input');
|
||||
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.transformPendingW)).toBe(200);
|
||||
await page.locator('#ge-transform-cancel-btn').click();
|
||||
});
|
||||
}
|
||||
|
||||
test('responsive transition across 700px breakpoint preserves dock/sheet layout and state', async ({ page }) => {
|
||||
// Desktop -> Phone transition
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Responsive transitions');
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/ge-right-panel--dock/);
|
||||
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeVisible();
|
||||
|
||||
// Resize across 700px to phone
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/ge-right-panel--dock/);
|
||||
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeHidden();
|
||||
|
||||
// Resize back to desktop
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/ge-right-panel--dock/);
|
||||
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const crypto = require('crypto');
|
||||
const { openBlankEditor } = require('./helpers');
|
||||
|
||||
const FONTS = [
|
||||
'Inter', 'Roboto', 'Playfair Display', 'Lobster',
|
||||
'Bebas Neue', 'Roboto Mono', 'Space Grotesk', 'Fraunces',
|
||||
'Syne', 'Bungee', 'Instrument Serif', 'Georgia', 'Courier New',
|
||||
];
|
||||
const md5 = (buf) => crypto.createHash('md5').update(buf).digest('hex');
|
||||
|
||||
test('text font dropdown renders a distinct face for each font', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 900, height: 600 }, 'Font swap');
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.ge-tour-close,.ge-protip,.ge-tip,[class*="protip"]').forEach((e) => e.remove());
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
const tool = document.querySelector('[data-tool="text"]');
|
||||
if (tool) tool.click();
|
||||
await window.geEditorControl.invoke('text.add', { text: 'AWjgyq 128', x: 60, y: 80 });
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
|
||||
const hashes = {};
|
||||
for (const font of FONTS) {
|
||||
await page.evaluate((f) => {
|
||||
const sel = document.getElementById('ge-text-font');
|
||||
sel.value = f;
|
||||
sel.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, font);
|
||||
await page.waitForTimeout(250);
|
||||
const shot = await page.screenshot({ clip: box });
|
||||
hashes[font] = md5(shot);
|
||||
}
|
||||
|
||||
const distinct = new Set(Object.values(hashes)).size;
|
||||
expect(distinct, `distinct font hashes: ${JSON.stringify(hashes)}`).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, openOptionsMore, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
test('text edits directly on canvas and remains retained after reopen', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await selectTool(page, 'text');
|
||||
await openOptionsMore(page, 'text');
|
||||
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 openOptionsMore(page, 'text');
|
||||
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 openOptionsMore(page, 'text');
|
||||
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 selectTool(page, 'text');
|
||||
const section = page.locator('#ge-text-section');
|
||||
await expect(section).toBeVisible();
|
||||
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
|
||||
await openOptionsMore(page, 'text');
|
||||
await expect(page.locator('#ge-text-frame-height')).toBeVisible();
|
||||
await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
|
||||
expect(await page.locator('#ge-text-more').evaluate(el => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return box.left >= 0 && box.right <= innerWidth + 1;
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await selectTool(page, 'shape');
|
||||
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 openOptionsMore(page, 'shape');
|
||||
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 selectTool(page, 'transform');
|
||||
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 selectTool(page, 'shape');
|
||||
await openOptionsMore(page, 'shape');
|
||||
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 openOptionsMore(page, 'shape');
|
||||
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 selectTool(page, 'shape');
|
||||
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
|
||||
await openOptionsMore(page, 'shape');
|
||||
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 selectTool(page, 'gradient');
|
||||
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 selectTool(page, 'gradient');
|
||||
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,415 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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 selectTool(page, 'transform');
|
||||
await expect(page.locator('#ge-transform-section')).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 selectTool(page, 'transform');
|
||||
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 selectTool(page, 'transform');
|
||||
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-section')).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 selectTool(page, 'transform');
|
||||
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 selectTool(page, 'transform');
|
||||
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 selectTool(page, 'transform');
|
||||
await expect(page.locator('#ge-transform-section')).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 frame = {
|
||||
centerX: state.transformCenter.x,
|
||||
centerY: state.transformCenter.y,
|
||||
width: state.transformPendingW,
|
||||
height: state.transformPendingH,
|
||||
rotation: state.transformPendingRot,
|
||||
};
|
||||
const rect = state.mainCanvas.getBoundingClientRect();
|
||||
const outside = transformFrameGeometry(frame, { zoom: state.zoom }).rotationHandle;
|
||||
const x = rect.left + outside.x * rect.width / state.mainCanvas.width;
|
||||
const y = rect.top + outside.y * rect.height / state.mainCanvas.height;
|
||||
const area = state.container.querySelector('.ge-canvas-area').getBoundingClientRect();
|
||||
const popup = state.transformPopup?.getBoundingClientRect();
|
||||
const rotationInside = y < area.top + 6 || x < area.left + 6 || x > area.right - 6 ||
|
||||
!!(popup && x >= popup.left && x <= popup.right && y >= popup.top && y <= popup.bottom);
|
||||
const geometry = transformFrameGeometry(frame, { zoom: state.zoom, rotationInside });
|
||||
const handle = geometry.handles.find(item => item.id === handleId);
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
test('transform handle keeps tracking when the pointer crosses the right panel', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform overshoot');
|
||||
await selectTool(page, 'transform');
|
||||
await expect(page.locator('#ge-transform-section')).toBeVisible();
|
||||
|
||||
const setup = 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 rect = state.mainCanvas.getBoundingClientRect();
|
||||
const panel = document.querySelector('.ge-right-panel');
|
||||
const panelBox = panel ? panel.getBoundingClientRect() : null;
|
||||
return {
|
||||
width: state.transformPendingW,
|
||||
handle: {
|
||||
x: rect.left + right.x * rect.width / state.mainCanvas.width,
|
||||
y: rect.top + right.y * rect.height / state.mainCanvas.height,
|
||||
},
|
||||
// A point safely inside the right panel (editor chrome).
|
||||
panelPoint: panelBox
|
||||
? { x: panelBox.left + panelBox.width / 2, y: rect.top + rect.height / 2 }
|
||||
: null,
|
||||
};
|
||||
});
|
||||
expect(setup.panelPoint, 'right panel must be visible on desktop').toBeTruthy();
|
||||
|
||||
await page.mouse.move(setup.handle.x, setup.handle.y);
|
||||
await page.mouse.down();
|
||||
// Resize far to the right, ending over the right panel. The handle must
|
||||
// keep tracking across chrome — an overshoot must not freeze the drag.
|
||||
await page.mouse.move(setup.panelPoint.x, setup.panelPoint.y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
|
||||
const after = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformPendingW;
|
||||
});
|
||||
expect(after).toBeGreaterThan(setup.width);
|
||||
});
|
||||
|
||||
test('phone transform overlay follows the canvas through refits and zoom', async ({ browser }) => {
|
||||
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 }, 'Phone transform overlay');
|
||||
const fitBefore = await page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoom);
|
||||
await selectTool(page, 'transform');
|
||||
await expect(page.locator('#ge-transform-section')).toBeVisible();
|
||||
const alignment = () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const overlay = state.transformOverlay.getBoundingClientRect();
|
||||
const canvas = state.mainCanvas.getBoundingClientRect();
|
||||
return {
|
||||
zoom: state.zoom,
|
||||
overlayScale: overlay.width / state.transformOverlay.width,
|
||||
covers: overlay.left <= canvas.left + 1 && overlay.top <= canvas.top + 1
|
||||
&& overlay.right >= canvas.right - 1 && overlay.bottom >= canvas.bottom - 1,
|
||||
};
|
||||
});
|
||||
// Selecting Transform collapses the Layers sheet, which refits the canvas.
|
||||
await expect.poll(async () => (await alignment()).zoom).not.toBe(fitBefore);
|
||||
let current = await alignment();
|
||||
expect(Math.abs(current.overlayScale - current.zoom)).toBeLessThan(0.002);
|
||||
expect(current.covers).toBe(true);
|
||||
|
||||
await page.locator('#ge-zoom-in').evaluate(button => button.click());
|
||||
await expect.poll(async () => (await alignment()).zoom).toBeGreaterThan(current.zoom);
|
||||
current = await alignment();
|
||||
expect(Math.abs(current.overlayScale - current.zoom)).toBeLessThan(0.002);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
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 tmpBase = fs.existsSync('/var/tmp') ? '/var/tmp' : os.tmpdir();
|
||||
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR
|
||||
|| fs.mkdtempSync(path.join(tmpBase, 'odysseus-photo-editor-e2e-'));
|
||||
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH
|
||||
|| path.join(dataDirectory, 'app.db');
|
||||
const authenticated = process.env.PHOTO_EDITOR_E2E_AUTH !== '0';
|
||||
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}`);
|
||||
}
|
||||
|
||||
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
|
||||
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
|
||||
|
||||
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: isCI ? 'line' : 'line',
|
||||
globalSetup: require.resolve('./setup.js'),
|
||||
globalTeardown: require.resolve('./teardown.js'),
|
||||
use: {
|
||||
baseURL,
|
||||
browserName,
|
||||
headless: true,
|
||||
launchOptions: {
|
||||
args: ['--disable-dev-shm-usage', '--no-sandbox'],
|
||||
},
|
||||
viewport: { width: 1440, height: 960 },
|
||||
extraHTTPHeaders: { 'Accept-Encoding': 'identity' },
|
||||
serviceWorkers: 'block',
|
||||
...(authenticated ? { storageState: path.join(dataDirectory, 'browser-auth.json') } : {}),
|
||||
screenshot: 'only-on-failure',
|
||||
// Unconditional CI trace suppression: Playwright traces are NEVER recorded in CI.
|
||||
// Local developer tracing requires explicit PLAYWRIGHT_ENABLE_TRACE=1.
|
||||
trace: traceOptIn ? 'retain-on-failure' : 'off',
|
||||
},
|
||||
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: authenticated ? 'true' : 'false',
|
||||
DATABASE_URL: `sqlite:///${databasePath}`,
|
||||
ODYSSEUS_DATA_DIR: dataDirectory,
|
||||
ODYSSEUS_STARTUP_WARMUPS: '0',
|
||||
RESPONSE_COMPRESSION_ENABLED: 'false',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { request } = require('@playwright/test');
|
||||
|
||||
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 });
|
||||
if (process.env.PHOTO_EDITOR_E2E_AUTH !== '0') {
|
||||
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
|
||||
const client = await request.newContext({ baseURL: `http://127.0.0.1:${port}` });
|
||||
try {
|
||||
const credentials = { username: 'editor-e2e', password: 'disposable-editor-e2e-password' };
|
||||
const setup = await client.post('/api/auth/setup', { data: credentials });
|
||||
if (!setup.ok()) throw new Error(`Disposable editor account setup failed: ${setup.status()}`);
|
||||
const login = await client.post('/api/auth/login', { data: credentials });
|
||||
if (!login.ok()) throw new Error(`Disposable editor account login failed: ${login.status()}`);
|
||||
await client.storageState({ path: path.join(dataDirectory, 'browser-auth.json') });
|
||||
} finally {
|
||||
await client.dispose();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const defaultResultsDir = path.join(repoRoot, 'test-results');
|
||||
const defaultReportDir = path.join(repoRoot, 'playwright-report');
|
||||
const defaultSanitizerScript = path.join(repoRoot, 'scripts', 'sanitize_trace_artifacts.py');
|
||||
|
||||
/**
|
||||
* Confine dynamic paths to explicitly allowed test directories.
|
||||
*/
|
||||
function validateTestPath(targetPath, allowedBases, label) {
|
||||
if (!targetPath || typeof targetPath !== 'string') return null;
|
||||
const resolved = path.resolve(targetPath);
|
||||
const isAllowed = allowedBases.some((base) => {
|
||||
const resolvedBase = path.resolve(base);
|
||||
return resolved.startsWith(resolvedBase + path.sep) || resolved === resolvedBase;
|
||||
});
|
||||
if (!isAllowed) {
|
||||
throw new Error(`Security rejection: ${label} path "${targetPath}" is outside allowed test directories`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
module.exports = async (options = {}) => {
|
||||
// If options is a Playwright FullConfig object, ignore it and use safe defaults
|
||||
const isPlaywrightConfig = options && (options.configFile || options.rootDir);
|
||||
const testOpts = (!isPlaywrightConfig && typeof options === 'object') ? options : {};
|
||||
|
||||
// Confine cleanup of PHOTO_EDITOR_E2E_DATA_DIR:
|
||||
// Strictly require it to be within an OS temporary directory and match the expected test prefix
|
||||
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
|
||||
if (dataDirectory && typeof dataDirectory === 'string') {
|
||||
try {
|
||||
const resolvedData = path.resolve(dataDirectory);
|
||||
const allowedTmpBases = [os.tmpdir(), '/var/tmp', '/tmp'].map((b) => path.resolve(b));
|
||||
const isTmp = allowedTmpBases.some((base) => resolvedData.startsWith(base + path.sep));
|
||||
const baseName = path.basename(resolvedData);
|
||||
if (isTmp && baseName.startsWith('odysseus-photo-editor-e2e-')) {
|
||||
fs.rmSync(resolvedData, { recursive: true, force: true });
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Secure default tracing: never publish trace archives in CI or without explicit opt-in.
|
||||
// When traces are not opted-in, purge all archives unconditionally.
|
||||
// When local opt-in tracing is active, run the strict fail-closed structured sanitizer.
|
||||
const allowedBases = [defaultResultsDir, defaultReportDir, os.tmpdir(), '/var/tmp', '/tmp'];
|
||||
const resultsDir = testOpts.resultsDir
|
||||
? validateTestPath(testOpts.resultsDir, allowedBases, 'resultsDir')
|
||||
: defaultResultsDir;
|
||||
const reportDir = testOpts.reportDir
|
||||
? validateTestPath(testOpts.reportDir, allowedBases, 'reportDir')
|
||||
: defaultReportDir;
|
||||
|
||||
if (fs.existsSync(reportDir)) {
|
||||
try {
|
||||
fs.rmSync(reportDir, { recursive: true, force: true });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (fs.existsSync(resultsDir)) {
|
||||
const purgeSensitiveArtifacts = (dir) => {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
purgeSensitiveArtifacts(fullPath);
|
||||
} else if (entry.isFile() && (
|
||||
entry.name.endsWith('.zip') ||
|
||||
entry.name.endsWith('.tmp') ||
|
||||
entry.name.endsWith('.png') ||
|
||||
entry.name.endsWith('.har')
|
||||
)) {
|
||||
try {
|
||||
fs.rmSync(fullPath, { force: true });
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const purgeZipAndTmp = (dir) => {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
purgeZipAndTmp(fullPath);
|
||||
} else if (entry.isFile() && (entry.name.endsWith('.zip') || entry.name.endsWith('.tmp'))) {
|
||||
try {
|
||||
fs.rmSync(fullPath, { force: true });
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
|
||||
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
|
||||
|
||||
if (isCI || !traceOptIn) {
|
||||
// In CI or default local runs, purge all sensitive trace archives and artifacts
|
||||
purgeSensitiveArtifacts(resultsDir);
|
||||
} else {
|
||||
const python = process.env.ODYSSEUS_TEST_PYTHON || 'python';
|
||||
const sanitizerScript = testOpts.sanitizerScript
|
||||
? validateTestPath(testOpts.sanitizerScript, [path.join(repoRoot, 'scripts'), os.tmpdir(), '/var/tmp', '/tmp'], 'sanitizerScript')
|
||||
: defaultSanitizerScript;
|
||||
const rawTimeout = Number(testOpts.timeout) || 30_000;
|
||||
const timeout = Math.max(100, Math.min(rawTimeout, 120_000));
|
||||
let sanitizerSucceeded = false;
|
||||
try {
|
||||
execFileSync(python, [sanitizerScript, resultsDir], {
|
||||
stdio: 'ignore',
|
||||
timeout,
|
||||
shell: false,
|
||||
});
|
||||
sanitizerSucceeded = true;
|
||||
} catch (_) {
|
||||
sanitizerSucceeded = false;
|
||||
}
|
||||
|
||||
if (!sanitizerSucceeded) {
|
||||
// Fail closed: purge all remaining zip or tmp archives
|
||||
purgeZipAndTmp(resultsDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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/);
|
||||
});
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
# CA trust regression fixtures
|
||||
|
||||
The public certificates embedded in `tests/test_ca_trust.py` exercise the October 2026 eMudhra trust-store check. They contain no private keys. `emsign-g3.pem` and `aaa.pem` are the incident's affected trust anchors; `emsign-g1.pem` is an unaffected emSign anchor that must remain usable. `localhost-leaf.pem` is a synthetic self-signed Ed25519 server certificate with `CA:FALSE`, generated solely to reproduce hashed non-CA certificates found in Ubuntu's OpenSSL directory; its private key was discarded. The test creates temporary PEM files from these public constants. The certificate fingerprints are the factual identity of each fixture.
|
||||
|
||||
Sources: the [incident root list](https://emudhra.we-have-a-problem.com/roots.json) and the public Mozilla root certificates distributed in Debian's `ca-certificates` package.
|
||||
|
||||
`incident.json` contains the four published roots, sixteen [compromised intermediate certificates and cross-signs](https://emudhra.we-have-a-problem.com/impacted-intermediates.json), and the [fourteen published server chains](https://emudhra.we-have-a-problem.com/chains.zip), retrieved on 2026-10-21 with chains refreshed on 2026-10-23. The refresh adds the two DV G3B hosts without Certificate Transparency entries and preserves the original twelve chains. Certificates are deduplicated by SHA-256 of DER; each chain lists its leaf first. The additional cross-signed `emSign Root TLS CA - G3` issuer comes from those chains and has fingerprint `3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd`. Like the intermediates, it must not be accepted as a custom trust anchor. All data is public certificate material, with no private keys.
|
||||
|
||||
Downloaded source SHA-256 values:
|
||||
|
||||
- `roots.json`: `119f32cd81a6b5a4e3c4c2df5fe07daa90dc43cafa123a62f0394f1895d92380`
|
||||
- `impacted-intermediates.json`: `f67b3abe9b4879f4e91874ed041beccd71257f1db41c588c724ccf12aa90c59e`
|
||||
- `chains.zip`: `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`
|
||||
|
||||
The offline OpenSSL regression verifies server purpose, hostname and strict certificate rules with partial-chain trust, at `2026-10-23T12:00:00Z` (`1792756800`). Fixing the verification date keeps later certificate expiry from masking a broken mitigation. All fourteen chains must verify against the affected positive-control roots and fail against the unaffected G1-only store. Adding the compromised DV G3B intermediate restores four paths, including the two new hosts, which the checker must reject. The regression makes no network requests and skips chain verification if the OpenSSL executable is unavailable; certificate-inventory tests still run using Python's standard library.
|
||||
|
||||
The upstream SearXNG image validation uses the same `incident.json` inventory and fixed verification timestamp, checking each stored DER fingerprint before use. See [the image documentation](../../../docs/searxng-trust-image.md) for its validation targets and source provenance.
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"verification_time": 1792756800,
|
||||
"roots": {
|
||||
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3",
|
||||
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3",
|
||||
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "AAA Certificate Services",
|
||||
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "emSign ECC Root CA - G3"
|
||||
},
|
||||
"intermediates": {
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "EM DV TLS CA - G3B (eMudhra)",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "EM DV TLS CA - G3B (Sectigo)",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "EM OV TLS CA - G3B (eMudhra)",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "EM OV TLS CA - G3B (Sectigo)",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "EM EV TLS CA - G3B (eMudhra)",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "EM EV TLS CA - G3B (Sectigo)",
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "EM DV TLS CA - G3C (eMudhra)",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "EM DV TLS CA - G3C (Sectigo)",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "EM OV TLS CA - G3C (eMudhra)",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "EM OV TLS CA - G3C (Sectigo)",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "EM EV TLS CA - G3C (eMudhra)",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "EM EV TLS CA - G3C (Sectigo)",
|
||||
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "EM OV CS CA - G3B (eMudhra)",
|
||||
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "EM OV CS CA - G3B (Sectigo)",
|
||||
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "EM OV CS CA - G3C (eMudhra)",
|
||||
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "EM OV CS CA - G3C (Sectigo)"
|
||||
},
|
||||
"certificates": {
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPWIsuPCu4zaE+GhIJJUMhMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM1\nMDlaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFy\npDPjYw8CvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpV\nbMyLpQVh3xsqRB0Bo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMEN2HWMocbuWdy1d33pH6JpMvP/V/DIS5PtZbv1Q7TXEbxrcyxg\nyuddOJscYDJJlAIwB4sH1BpbScDzkdD4tJBSwrG/1Mx82vb8YaySehNuu1+1oXNd\nAEKwaJ0mVzScy2+6\n-----END CERTIFICATE-----\n",
|
||||
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcigAwIBAgIUG01Z/jOotSvB49K4K27kFjpdnWMwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMjU3MzFaFw0yNzA1MDUxMzAyMzFa\nMDQxMjAwBgNVBAMMKWR2LWczYi1ub2N0LWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEI805yWr+RxanILifET+/\nGwHsXo7dp8ghA3OgwR0Mg11Bl6iYyOzldy4iU7kM+3qfDNsUrzxvkUkJZQpYieuC\n+aOBrDCBqTAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATA0BgNVHREELTArgilkdi1nM2Itbm9jdC1lbXVkaHJhLndlLWhh\ndmUtYS1wcm9ibGVtLmNvbTAdBgNVHQ4EFgQUOlRrLQGIczrmr4H+Oak06aUmJ4Yw\nHwYDVR0jBBgwFoAU4h4v5xwZOQ5VS2LBICRXNSSKJRQwCgYIKoZIzj0EAwIDZwAw\nZAIwf4ekmRgXAxXP2AjpVY2f1bO42GC7Nnc80t4OpaWJeVdXpT+X3dOsLRwlIUm0\nYbMwAjBihTx7oHfaYB3KRnFuhKqlHYmUcOe9Hjxqs6bubZohhrqMQzPocyasDhJL\nUx3HsgQ=\n-----END CERTIFICATE-----\n",
|
||||
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0": "-----BEGIN CERTIFICATE-----\nMIICPzCCAcagAwIBAgIUH0gWfBRnOiSjcduti/cukjDGAjIwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDJaFw0yNzA1MDMwMjI3NDJa\nMDMxMTAvBgNVBAMMKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3EfjVE+6fUm9gr7UoUv6N\nYPttIdILR9OMZnAdTj+RWT1Z4fp7dpC5mat1gbIo4J6EDelcOQQz5/oGeB3gFJKw\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFKzQPp6gTBOWb0TBxZbkL5zsQn3oMB8G\nA1UdIwQYMBaAFJvYZVGv5+csN60I7mkZ2b83hK4KMAoGCCqGSM49BAMCA2cAMGQC\nMARKrRRBEWY2Ix1d80DGUvC7bWENP1Q9pf6BOGF4+84UiwV5+nAuht/+hdpAanWB\nsQIwJF2dW8tpPN6lTm1kDerzG6yJAJOz4drotaTthMF2+efuv5W2FL7Dr8qZSd1z\n201o\n-----END CERTIFICATE-----\n",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "-----BEGIN CERTIFICATE-----\nMIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw\nCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy\nYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290\nIENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD\nVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg\nMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr\ngQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m\nWO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw\nLKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj\nQjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB\nBQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB\nFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi\nBggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290\nQ0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t\nP1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB\nATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg\nTroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0\n1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV\n-----END CERTIFICATE-----\n",
|
||||
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5": "-----BEGIN CERTIFICATE-----\nMIICODCCAb+gAwIBAgIUbWvtwSNK/Qro223vAqyz323+VBAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM2WhcNMjcwNTAzMDEwNDM2\nWjAvMS0wKwYDVQQDDCRkdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAScdBQpNUOOCcag6YEVFccNRSFe\nUIl02iH53vDfoWPJFS005WzqF3CPDclB/kygP2gWF4pg/r5gPYdnI3/6Vdzro4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGR2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUBj2yGO/TzDOE/u/6QeUvcW8y1iMwHwYDVR0jBBgw\nFoAUrn4QXDwplsGoRycFWfHgPdTlCvMwCgYIKoZIzj0EAwIDZwAwZAIwBQOOQqG9\nccdzM/7XIlXLtota1esALaLmgEN/ZweosBIvk4xabHcERZInWVIwO45NAjBos8Ue\nNwDe1X3ahdK2lkxqBEoBMtUHJN18TnJKs2nK1gmXpMbmY5oKnAQQgCEZPA4=\n-----END CERTIFICATE-----\n",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1\nNDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/J\nhZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p\n1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPO\nIbh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8\ngt2TlfJbHii+KNvK\n-----END CERTIFICATE-----\n",
|
||||
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUXcJ4+VeNEICJ9JbBANtIyfP96EowCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRvdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATLEvRHdoPc0Z9ovgqlYmoKTHPU\nApwd6lAp0ou1G63SzuESyBzIW6Tl64CsKzlV9ggkZjX+L9/tOx97/1wmSTYvo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJG92LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUrytBb+px+b58Df+d5vP8GpTW+/wwHwYDVR0jBBgw\nFoAUNOrqDOvJCtr50MXi7Zt4yYeeG2wwCgYIKoZIzj0EAwIDaAAwZQIwNdoOUOKz\nMLV1OFEoMjBeUqvkQAwsajvVWu0A23qyyvYlosyMp8aFv0gxURW6Yj6DAjEAhe0z\nkOQmmqb0eyhjh7weF4fHtwMtLi4jNFA6M/EfPTfkqE91MN9fSin5uhua5xhk\n-----END CERTIFICATE-----\n",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAIAJkopkEHYBrwX6LxnxCTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTUz\nOTMxWhcNNDAwNDI4MTgzMDAwWjBAMQswCQYDVQQGEwJVUzEUMBIGA1UECgwLZU11\nZGhyYSBJbmMxGzAZBgNVBAMMEkVNIEVWIFRMUyBDQSAtIEczQzB2MBAGByqGSM49\nAgEGBSuBBAAiA2IABC1W6Cmm+4LGSkaX2WCvlR6QZ66R+SbTQwbaSKiq63hptqZ+\nbSdCbgRCqgOzbhmI3/Y2UU7hzwf1uks/8qBmRa59MdWGatMao9JG6wsxd+6Zy3Vm\nxRbOdICKZo+R8MR/yqOCAZUwggGRMB8GA1UdIwQYMBaAFOE6qyF88eNNVK7Ucrf9\ni1lCIJBUMB0GA1UdDgQWBBSmJV6AqU2385Z8G8eR35K87kvrXzAdBgNVHSUEFjAU\nBggrBgEFBQcDAQYIKwYBBQUHAwIwXAYDVR0gBFUwUzA5BgsrBgEEAYOOIQEAATAq\nMCgGCCsGAQUFBwIBFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMA0GCysG\nAQQBg44hAQJ4MAcGBWeBDAEBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRVZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwBy1wTnzH4lXDTy2PD91ysLjvnlVN3JbJmc1BIMEU8TOLkYVHibMc\n9HeyonikFSfmAjEA6uE3QRxlrZTz/1/lXPTEMUhoauAtycagtlNFp79kjhvNa2/f\nAal1GNxsOutLnr0Y\n-----END CERTIFICATE-----\n",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO\nL4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2\nPM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE\nwqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM\nQkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ\n7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl\nbN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V\nGUyRCEIDhF5TBUuKF4E3kf1h\n-----END CERTIFICATE-----\n",
|
||||
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuGgAwIBAgIPVEt/kiwxriIKSpIpFvbmMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2Mzg0\nMFoXDTQwMDQyODE4MzAwMFowPzELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRo\ncmEgSW5jMRowGAYDVQQDDBFFTSBPViBDUyBDQSAtIEczQzB2MBAGByqGSM49AgEG\nBSuBBAAiA2IABLnw4GNHAqacpqrdRDz9YsQbm6SyCSRpfXl052kaEm+5avs+NML9\nO8X0gwYfJl3CpYQ2NHMjUQQ2so8E84YjYwZ5gOJQPgJTLWVGI1pxZaJZoe9Jb3Qb\nX97neHII5Lj676OCAYswggGHMB8GA1UdIwQYMBaAFGh0A/iFyo1UUFcdFuDI3kUN\nq80tMB0GA1UdDgQWBBTub3MpjW+/p7Mbw5U09hVWRHhmtzATBgNVHSUEDDAKBggr\nBgEFBQcDAzBeBgNVHSAEVzBVMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEW\nHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDgYMKwYBBAGDjiEBAoFIMAgG\nBmeBDAEEATB0BggrBgEFBQcBAQRoMGYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3Nw\nLWMuZW1zaWduLmNvbTA+BggrBgEFBQcwAoYyaHR0cDovL3JlcG9zaXRvcnkuZW1z\naWduLmNvbS9jZXJ0cy9FTU9WQ1NDQUczQy5wN2MwNgYDVR0fBC8wLTAroCmgJ4Yl\naHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290Y3NjYWczLmNybDAOBgNVHQ8BAf8E\nBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNoADBlAjEA8yPg\n8sntOhLwV2QXJ5LEaKSpo/fnE3uOgTadK73fDhyjiqr0Io9GVuQTYqBPbQ2TAjBg\neuApmBS486+iei03lRrp139dPw6k28jjGNvWsAU7A1N86J1zPJVDEyVH+nMAWC0=\n-----END CERTIFICATE-----\n",
|
||||
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db": "-----BEGIN CERTIFICATE-----\nMIICSTCCAdCgAwIBAgIUO7oRaIOhMWD0jFgvxusAGNi5CI0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMzE0NDhaFw0yNzA1MDUxMzE5NDha\nMDgxNjA0BgNVBAMMLWR2LWczYi1hYWEtbm9jdC1lbXVkaHJhLndlLWhhdmUtYS1w\ncm9ibGVtLmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJ0yLMf28vw2E6pO\nc74doinU47pwTYD6xZ60nkrGkqI05DxVKXblos+V5S0+bgDjT2qw1F39EW9EUj8b\nNQmmqv6jgbAwga0wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0l\nBAwwCgYIKwYBBQUHAwEwOAYDVR0RBDEwL4ItZHYtZzNiLWFhYS1ub2N0LWVtdWRo\ncmEud2UtaGF2ZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBTruPPaZ1PiEFfpqdLo\n8Ra5Ah5a2jAfBgNVHSMEGDAWgBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjO\nPQQDAgNnADBkAjBUTUTymJylZiZRV/RqMayZdUPJKRDIcn9+sUPpG66AjVgx8M+h\nDGzxxgeroOSxzSwCMBM/r1y7g371RI10nDU16T0WEAO/Udqyyuc8mJ2LCQ+Gxv3c\njU2NXGQLsvosv6T0ww==\n-----END CERTIFICATE-----\n",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIRANj4aF5PK9nYJAKoWhwetzEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQl26haZNOX2gsQUunqVG5Ie+/qnJ2daKDF6tz+73Wmm7XW/AH0K/vKbrQL\nzWTAdn42/9Bdl6gTxaoMbJUw55z18IZkaPE4BkIKpchRhAseJSVA55DktlTDhS7W\nSvHoZX2jggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUPtwg2YlGmId7hz/YgUeyXBXYRwYwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAJNFuAFW9Gnw/8+kCO4KIlZdnM1Sh2u+8cK0EYAHhXpb5FlGvYkcARigo5oS7VoM\n7FOYlTueV6yICZKNAYawZPJPhNCGK+rp3cQBEz6d0n8OZEYZov9lb3ll8ORgSDuf\nFVIW6Gagg/UnOamLBTOR8eD5V73SCme/B2cHUDw+m5E8cc+0Ujzz3Cn3ex5JrhXk\n2T3s8PoVzEmhTgGboFfcWO1EYhPPrYwdxPYRQIXAXUXhLEOVNL9ASbZJLFI8E1IM\nm7NwxVN2s38jWpaCUaaF0tg7hfiy7oe13rzJfHsdqldJVTTUTPWVx8TC4ju0VLet\nbAlwhwFT6AP8uIVMGUHNw24=\n-----END CERTIFICATE-----\n",
|
||||
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcegAwIBAgIULuWnWW6kFV6QZxp2ls8r18NCjXkwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEb7+5g2YHNYwH2eQERrZc\nTmVuf/RmvzsGGMSwXmZke6vn35Ut5HME7UOOU3Muy+BSfjfpo6fjL5KOs3Ndz67d\nOKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBQ7oBzq3jpTuvntbIkzMV12Ps3O0TAf\nBgNVHSMEGDAWgBQ06uoM68kK2vnQxeLtm3jJh54bbDAKBggqhkjOPQQDAgNoADBl\nAjEA/Y7AZ4M2MYbN5Od9tREaUfFjZKoOjczfIKPjKNMjtGxTdL9WUXvAT4QDgktn\n/IJdAjAbORTkAXD5hggyG6K7p0MBz3lFZJb2l/LndZlrttJWBLP8QGrc44hrZZtK\nIq0d1dE=\n-----END CERTIFICATE-----\n",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAOlCI73pL5FOWrmsq/2sNxAwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK56Ks1xwy\nKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDftDIVOTMEv\nTPhkbQ2jggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQICMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJuMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQALRoOSvkzRa+tN5Rje9W5GiXd/qsicd9N6Gnh0SWC7VyWH+HycIqAnFCMYZznj\nCeGusomFYHE42SHWr85JDIbgzy6y3dO4i9n9w0Bx7yrXWBbDzFS8VgA3+EY4Cg7y\nmtoC3RPOJVtaXXPZ+68X804g1Mj+3vCSHj2lNPVgco0OzSIOJ/KMg1R9MLBkiKzb\nbERAGZHejY0HBhYd76IINZN8z6bBNah2UdFYxsOisLynPQUkol6gRLum024/FzVl\nCCDfnUusrkr+mAfPAvD/Vb8bhpA7jYtepuU+2mIdShj5lLlvAwZphn+ypWycuo2x\n0k9mldz2dqI9nhCiY+TTIOOR\n-----END CERTIFICATE-----\n",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAJHzR/Glt9OZYrleDuTimdEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFypDPjYw8C\nvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpVbMyLpQVh\n3xsqRB0Bo4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECATANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECZDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAr7b6Mx11TIJgVzHt6EH5qGNWeiiqoMrp8E5/k8s+jvupx1n083pZ5XOzUzND\nwEUe0W4C4FIGUHNQ4XDwqOw0yK42xqm2dvCKgsko2UvDlT0haZVtTCeWMQOe5J/J\nziaMfZwsZNfZ3aaI6XPtWDZtwY8AaNfyaeCpbtm5GcGnyNWk4jSNpbtpo6NjfXuP\nCFOm3rTjJ4zytP7Tso6iMm0L01cYFOlTmoPv4515R2r3SPnKDtijoB/D/vgxzojv\nDafgyG9afX8+laO9NbMMz4sqC1vZx3984i/sBNFiugm3Vvdq5OdJRjuGC1QILIs6\ndg/K3i8VQqRpbfaM9kIlMzZ3dQ==\n-----END CERTIFICATE-----\n",
|
||||
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed": "-----BEGIN CERTIFICATE-----\nMIICNzCCAb6gAwIBAgIUPfthO0PYGAYKs9lQ4gp1IOkOr2MwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGV2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPZPwlA3io/AX/pMwu0dNl9WMxrr\nC9j/xMLHgG72cWVzoX62REmvp9zyXQ04kHP9/sW6KnCcm5ZZ5i4YoJbDPASjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZXYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBSvX4QR4ERW5uNUaMq0yYzXCZf0ZjAfBgNVHSMEGDAW\ngBQ+3CDZiUaYh3uHP9iBR7JcFdhHBjAKBggqhkjOPQQDAgNnADBkAjACy/uJR01M\n/O9IEZyRHX1LSU28bmg542K0IX9jDRTrZXUqkDWfiZIL7xfnrRrBpHoCMFbBIaOa\nWwWtFtDg9ZqQvktsWS950u5qfW1sChgWNya/E3IMKFgtoHm2xrZsmfwLSQ==\n-----END CERTIFICATE-----\n",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAI1aSBGTnvRH13ERzdmEmukwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv\n1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSf\nHAKycO1no4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECAjANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECbjBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAHlbhZpjhkPEAQVhPHRtBDaHqiisVD20pQ1TUeSx3l62Qe7NQ0b7vWQy7DN0i\nKOu7BGpP5EnIjvXbz9FhpfPJ4FhKEt3tQ51rqXz2oD8vRaryvExWPobDUz3j7HN6\nOzBsVMIe3PD7AAkpky0x8zMSi/ep5R9OMzybmNptbtppyuB5ytgq+h7lQdRIP+CQ\nTaJX3qTgfMqzntYre5GljmEB5hU2/rSn8zoSU2APkJidlZFpghtgbD4I2VRHZvCw\nFFr/SrFM9pwrgbJNZ1MtyTSVgI5NlNb0aMfLR+/r6jJt3ROeY2yh2QQ2dx+Yqgdb\nuL5ChFWOQU+ErSDnUq7yr+pjBQ==\n-----END CERTIFICATE-----\n",
|
||||
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "-----BEGIN CERTIFICATE-----\nMIICKTCCAa+gAwIBAgIQDnYGcvFDRZ/I/gqwvAXjlDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjQwMjA4MDgx\nOTMyWhcNNDkwMjA3MDgxOTMyWjBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11\nZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3Qg\nVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARfD9w/q1cWxL6D9bMG\nH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8mWO/RwXQy10DCSxT8vSIJClHKp+Ao\n4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFwLKYf8EdDXAyjQjBAMB0GA1UdDgQW\nBBThOqshfPHjTVSu1HK3/YtZQiCQVDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/\nBAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjBhCFTkly7Yl7+s4hiw+k6ZJTXT2AiI\nrvmsmfqPr5E7ecwXb6kXA5LTFd6rjwTuV3sCMQC9w6ZSbVq1mveMO6dKFziOzClT\nKCT2RFT6yXtq6nzgRICgBmiXbJAXsmZ8yAwjyWc=\n-----END CERTIFICATE-----\n",
|
||||
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUU3mgjls/8ZJ8upmukdlSQR1IF/UwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATK3sfcOxxtM2Vdq9Qbw/GR\nNsHlJcnqINEInXO3fkWhw/FYT80hkqGk0jSN/EKib3YZJsr5GyfkYcpEiA2rwMgm\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFDggfJFz1eeToKmzFBSYkMCvpAu0MB8G\nA1UdIwQYMBaAFOIeL+ccGTkOVUtiwSAkVzUkiiUUMAoGCCqGSM49BAMCA2gAMGUC\nMDqDDOt7K313QIH0rhn/ZKnzFu8+/9W8OlTuFtDfVCTRDfRmqdyfsCfLsEk7xqHq\nuQIxAJqyLERABmU98U46OCuT5bp5h0ytuSEIGM7orTpKci5S1u1B/AOmwv162U7R\nTcVPHA==\n-----END CERTIFICATE-----\n",
|
||||
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "-----BEGIN CERTIFICATE-----\nMIIDxTCCAq2gAwIBAgIRANal72E7OQ8y75zfNJ93KEEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEaMBgGA1UEAwwRRU0gT1YgQ1MgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAS58OBjRwKmnKaq3UQ8/WLEG5uksgkkaX15dOdpGhJvuWr7PjTC/TvF9IMG\nHyZdwqWENjRzI1EENrKPBPOGI2MGeYDiUD4CUy1lRiNacWWiWaHvSW90G1/e53hy\nCOS4+u+jggEsMIIBKDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU7m9zKY1vv6ezG8OVNPYVVkR4ZrcwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwMgYDVR0gBCsw\nKTAIBgZngQwBBAEwDQYLKwYBBAGDjiEBAAEwDgYMKwYBBAGDjiEBAoFIMEMGA1Ud\nHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FBQUNlcnRpZmlj\nYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYYaHR0\ncDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBgGbZ+NDkn\n2Fr5dCVs/51+6cT5iEPP1stglX4TI2UaNh9LmPdltxXw3crV8gK5L8i+d+ScGibF\ny0obtBHNTldXDgLnAHIzY3n+pceyKj7O2ae9CYnyuu5RBifeI3XYKowWE+oXnVGF\nOy0CvCJ/JsdP6aYgoCa9hGnAYicYQQL6MOMQZ5PuhcSM8t4giIzrpgrEkUP93GYE\nemDG5Z8BYwKs80lLeQNBnwg/VHJ3iEF/o84RXYwrlrKqJ+33C094o6LNikMJCqOK\n/WYHZSR8Xyhf8GXFAj5pZuI6Bcpug2V/E7i42j8OBhc25aNYDqK+B0qmb90sjNUB\ng3tJeSIb/599\n-----END CERTIFICATE-----\n",
|
||||
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "-----BEGIN CERTIFICATE-----\nMIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG\nEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo\nbm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g\nRzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ\nTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s\nb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw\ndjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0\nWXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS\nfvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB\nzhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq\nhkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB\nCUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD\n+JbNR6iC8hZVdyR+EhCVBCyj\n-----END CERTIFICATE-----\n",
|
||||
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52": "-----BEGIN CERTIFICATE-----\nMIICQjCCAcegAwIBAgIUMH3z62Z6agEULBZqrfhpw9/ZpCAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQxWhcNMjcwNTAzMDIyNzQx\nWjAzMTEwLwYDVQQDDChkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAMPpHsQqEqIerrj/durO\nEN7gkUVJSVgIPN6+VJ2sEGXDyqXAN1fnNzGuASP/4WEr0RIhht3cYvuUYp2SYofr\nw6OBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBT8CNQz4OLVnIwrQYZBF+++9jTCvzAf\nBgNVHSMEGDAWgBSufhBcPCmWwahHJwVZ8eA91OUK8zAKBggqhkjOPQQDAgNpADBm\nAjEAp3ZLdfBvKcZxgR2p8LZaxWzP6uB7zU/EZ9BA49Ds2P5ByYkRodwK5qcwt4J/\nVL/vAjEAnnxrcrDX7SzoJxuho604GBh6eyBLJLCdmcy+qb+LA3u5oxP/OjAzCA4x\nv1kw6Tm4\n-----END CERTIFICATE-----\n",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "-----BEGIN CERTIFICATE-----\nMIIDZjCCAu2gAwIBAgIPbK2jZ/scEACIEg6iAtVoMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNjAw\nMTJaFw00MDA0MjgxODMwMDBaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVk\naHJhIEJWMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIB\nBgUrgQQAIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK\n56Ks1xwyKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDft\nDIVOTMEvTPhkbQ2jggGWMIIBkjAfBgNVHSMEGDAWgBThOqshfPHjTVSu1HK3/YtZ\nQiCQVDAdBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowHQYDVR0lBBYwFAYI\nKwYBBQUHAwEGCCsGAQUFBwMCMF0GA1UdIARWMFQwOQYLKwYBBAGDjiEBAAEwKjAo\nBggrBgEFBQcCARYcaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTANBgsrBgEE\nAYOOIQECbjAIBgZngQwBAgIwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1PVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNnADBkAjBAk+5BizdJhrED3wb7F2Gm7bWI60dlNd075nghT2TrR4NWKZtJZcbc\n28xxIY/NONICMF6mPdsjosaqmvEMWqKcBh4vsIvXSKnQj5o8zrFB4oKS+accjYTe\n8Dz3bQjauvy9uw==\n-----END CERTIFICATE-----\n",
|
||||
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb6gAwIBAgIUY1R/PHrtXShV0uhLJ5EyjsHvMi0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGR2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFpE24ACBFHQLzpg+7D767thJ3+Q\nNIoJJugUMT/OftEz6G6uDdxlS+9VfXYBDglHjJirjEPFNglOGJoQNQuiz7Gjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZHYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBT4r5v4etYNuG21BqBqs273OkcHiDAfBgNVHSMEGDAW\ngBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjOPQQDAgNpADBmAjEAlxNF+/8l\nAPpgibawACy15yn5bcvTksz2Zw0R9z9nUK9e3MSSk+hXagg7LVb7V4NtAjEA4qyR\nlaFPkaokrnTjqQieq8CgDQ6tIz3FjBvj3D7vkhKXHaml6zD2zT5ih2Y+lD2h\n-----END CERTIFICATE-----\n",
|
||||
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuCgAwIBAgIPeBiTP4+FtGGaKzX7qD0KMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2MDgy\nN1oXDTQwMDQyODE4MzAwMFowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRo\ncmEgQlYxGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUOb\nNKBDHK/+W/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jord\nd7EAZPKr5eBQo4IBizCCAYcwHwYDVR0jBBgwFoAUaHQD+IXKjVRQVx0W4MjeRQ2r\nzS0wHQYDVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMBMGA1UdJQQMMAoGCCsG\nAQUFBwMDMF4GA1UdIARXMFUwOQYLKwYBBAGDjiEBAAEwKjAoBggrBgEFBQcCARYc\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTAOBgwrBgEEAYOOIQECgUgwCAYG\nZ4EMAQQBMHQGCCsGAQUFBwEBBGgwZjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3At\nYi5lbXNpZ24uY29tMD4GCCsGAQUFBzAChjJodHRwOi8vcmVwb3NpdG9yeS5lbXNp\nZ24uY29tL2NlcnRzL0VNT1ZDU0NBRzNCLnA3YzA2BgNVHR8ELzAtMCugKaAnhiVo\ndHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3Rjc2NhZzMuY3JsMA4GA1UdDwEB/wQE\nAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMAoGCCqGSM49BAMDA2kAMGYCMQC/zr03\nYEELsNKqzUtH2dVFi4BaKFdyrr/6G1dIWp8KAVgsw2/u4lyyboUDaC9jDvICMQC9\nHTxku84tNX9dmHOtH7aC2Nu/JM0jTf6KjZAvovCJzzv1IFWSZpVLZibynMLXOrM=\n-----END CERTIFICATE-----\n",
|
||||
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "-----BEGIN CERTIFICATE-----\nMIIDwzCCAqugAwIBAgIQQeMrc6wYXWzg10gLrPhOaTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYx\nGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACID\nYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUObNKBDHK/+\nW/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jordd7EAZPKr\n5eBQo4IBLDCCASgwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD\nVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMA4GA1UdDwEB/wQEAwIBBjASBgNV\nHRMBAf8ECDAGAQH/AgEAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMDIGA1UdIAQrMCkw\nCAYGZ4EMAQQBMA0GCysGAQQBg44hAQABMA4GDCsGAQQBg44hAQKBSDBDBgNVHR8E\nPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9BQUFDZXJ0aWZpY2F0\nZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAGGGGh0dHA6\nLy9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAIh0OzWsdBYhY\n7+yxPGyosRL+uUSN6/Mrd30fBuougs5FVWQ3cRgLhImnUUgXADY9buugWhMmfT3c\nyU5T1MFXZXClCILm5mfg+oqm/ikUWJl3Ayf2GvYf2O++6vzqvkn418nI5Lpdt0O7\nLi524YS/3CZHDA+zZQ7rrHpZXgQ9Hmp8AUQ4FneaGjNanFoYxiZxwCyW5z4u7AoK\n0bmOVqKtcDFoVNz5++G9TYDJdhWOXmKKgzqRo1MK0PjazNT7MGHqknP93SqMVJ1g\nmX/qNWxYteAjlvFanQxBNX/+TRxDjuFsEHO/7BvqSpIIck0UEEfizhN8zNpWLbcl\nEqVgl0g39A==\n-----END CERTIFICATE-----\n",
|
||||
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUUYu3O8bpN1GPvCIHqWpT/FYUNDowCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASjDLpnVdmvXM6R3UB2o42a\n1GnrYfxRXo7UII36mBl9mIS7p9MkAryhPdxitFHkgTrrbypVtzpydNtJIGGGXUwE\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFEVBY1ecTN9bLMDl4ifdnN2WZL+WMB8G\nA1UdIwQYMBaAFD7cINmJRpiHe4c/2IFHslwV2EcGMAoGCCqGSM49BAMCA2gAMGUC\nMQCyvr9gktgmtsuTuFleFV+YExNNKLf3GZieuUprQcERrzh798oYKavTqZeYf9Kz\nRhECMCVs1B7KPh7/lgHRly14Z8RJA8ApR7e2cRREW5uPKFOSDlZ/RIezxsfEzsQ4\nlAODeA==\n-----END CERTIFICATE-----\n",
|
||||
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcegAwIBAgIUTMUrGzSU+0BySFdrN3ALZq9Spv8wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEglClDbotLFFeY9SpryT4\nk958tvriY2DDnaPsmjQaecRhB6+L/8hLw6FMt+HT8SLvjIZnFY+AH92cBjedcNST\nAKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBSVtGbn6xUT1iosUeaGA15Nq640aTAf\nBgNVHSMEGDAWgBSmJV6AqU2385Z8G8eR35K87kvrXzAKBggqhkjOPQQDAgNnADBk\nAjAg7b5bIK9X50QEX0U6G8fQh12BnFQ079Iluzd0Y2TJWCjtVXZi3nOciM8RzNKM\ntZECMCBf0JOyMt8RNS93Wj8kYbWkCtoKoUrqx7G3JTfGReKci+DdTvkF8/3F6doi\n9KB22Q==\n-----END CERTIFICATE-----\n",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3\nNDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZ\nfjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+\nN7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44c\nNVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR79\n4rSb9vGqxZzelQth\n-----END CERTIFICATE-----\n",
|
||||
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUOcw5N4XGLiB/3GvB05bJuGoZPR0wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRldi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQo7U02MAUO8EEMomXwIl+kSQzm\nb+nvXKIZ/hC4rZ1GpXchv4SzTrV1SB/Qg+pD6OBEXbBBnPmQNkePNQTlujqRo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGV2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUqxe+7qS+MxNyioJZhQsL5MEEw6swHwYDVR0jBBgw\nFoAUpiVegKlNt/OWfBvHkd+SvO5L618wCgYIKoZIzj0EAwIDaAAwZQIwC/CxSIov\nwcar3sV98B8cL+QuR2kkJdLdXsFKzg/HDSJ1MOjqTGm924LB6r9nl0f7AjEAzcwr\nWQOYY9eycqIz+LG2daYS6LOctj+CjoAk/CLugWPxGxp4Aerfau2P1z1h5jAA\n-----END CERTIFICATE-----\n",
|
||||
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "-----BEGIN CERTIFICATE-----\nMIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb\nMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow\nGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj\nYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL\nMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE\nBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM\nGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP\nADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua\nBtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe\n3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4\nYgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR\nrOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm\nez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU\noBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v\nQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t\nb2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF\nAAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q\nGE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz\nRt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2\nG9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi\nl2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3\nsmPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==\n-----END CERTIFICATE-----\n",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu2gAwIBAgIQAMfEHv6S2CXwXq6EGkqCwTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTYw\nMzMxWhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRVYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEJduoWmTTl9oLEFLp6lRuSHvv6pydnWigxerc/u91ppu11vwB\n9Cv7ym60C81kwHZ+Nv/QXZeoE8WqDGyVMOec9fCGZGjxOAZCCqXIUYQLHiUlQOeQ\n5LZUw4Uu1krx6GV9o4IBlTCCAZEwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFD7cINmJRpiHe4c/2IFHslwV2EcGMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBcBgNVHSAEVTBTMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAngwBwYFZ4EMAQEwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1FVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNpADBmAjEA9Jq999oaOUKhFwopw33iCmt0OHIn3gjT43GfuOHR/YckXdwoNdDA\nlIEhz0NgRO3/AjEAvY/a3OMvktIZLYjn+WLKmp0QhrIMV+e6EpS7j4GVeOKouNp6\nMN/P+ByGeiRMghzB\n-----END CERTIFICATE-----\n",
|
||||
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "-----BEGIN CERTIFICATE-----\nMIICKDCCAa2gAwIBAgIQAY/OySfBFvWcmX7cDM0uKzAKBggqhkjOPQQDAzBVMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEfMB0GA1UEAwwWZW1TaWduIFJvb3QgQ1MgQ0EgLSBHMzAeFw0yNDAyMDgwODMz\nMTRaFw00OTAyMDcwODMzMTRaMFUxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVk\naHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBD\nUyBDQSAtIEczMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAESl9kM5D0VA/Og76dz5Xz\nw1hnGQ4fSR9993/qBkkvI48pXQVKOdyHCgAZThgWejQ8OpFRiTISjpabn4tfRFOY\nuzhApR/XxGbkg9X+2cEtbyghF9CdoF91W22V/R2EVe4xo0IwQDAdBgNVHQ4EFgQU\naHQD+IXKjVRQVx0W4MjeRQ2rzS0wDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wCgYIKoZIzj0EAwMDaQAwZgIxAK9OlACrFod3UgWY1ai+DF9sGFhahU59\nrv4jJyV01QaxGn0HErZ8gjHkngqe+tcFogIxAPTdW0hBmrGjhxBWdv9mZNdVFiJ4\nlrZ3ahvimsLhTQpnDBgiGKv6Ov1Fx/CE6yjIAg==\n-----END CERTIFICATE-----\n",
|
||||
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe": "-----BEGIN CERTIFICATE-----\nMIICODCCAb6gAwIBAgIUPXjC97k7buQHZ7p5ehDU8QNyrR4wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzdaFw0yNzA1MDMwMTA0Mzda\nMC8xLTArBgNVBAMMJG92LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABA1haD+95Albk0UN6YtDCPpqevTv\nYUmEhsIVpb/hnhsbBlH0yLthSE2JLH14ywiQJXttoc/pDWC9eoICW6CJXL2jgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkb3YtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBQUhzc4s202Hn62G0srmgguYdY2MTAfBgNVHSMEGDAW\ngBSb2GVRr+fnLDetCO5pGdm/N4SuCjAKBggqhkjOPQQDAgNoADBlAjEAoJ6d4zzA\nxE349yGHHrwKZpVCC/HBFutcOpOfzQoe8ikbmaaBTdtKai5TEllwt3dBAjBeHGiX\nVheDhJfewbei99cw1MfYB5tPwdfuY5pe1MJz0Tg9zJ4JgxBvF6LTT0OOXZ4=\n-----END CERTIFICATE-----\n",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIQBfB4dS3s56G7hZXTAxZxcTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5j\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQtVugppvuCxkpGl9lgr5UekGeukfkm00MG2kioqut4abamfm0nQm4EQqoD\ns24ZiN/2NlFO4c8H9bpLP/KgZkWufTHVhmrTGqPSRusLMXfumct1ZsUWznSAimaP\nkfDEf8qjggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUpiVegKlNt/OWfBvHkd+SvO5L618wDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAIFfHiaMSjnrdZygRYV4ekRBmG7ybcFk4hGQWMMyMFi0cAEk2+dGfz5bl8IaIbLq\nz+uVJzhOgVEX7WqIfgbWfFsfPYHYjPHBmy7SlZ3BwA86IQ6qF/etpX2/qL9A5yRN\nN3nVowUpaqV6H3aCpkAh3wR2ZNkvAbLDvPz3J0l+sEWjWv+ZISOV4R+hXDTdPXV0\n1lKxnbwqEsdo/sE2QkCDKwFrZh/0uKjWspydJj+jSaDLTrGNx50Dgmn7VEcD1ZI5\nFXjknVUiO25YlmS1dtVZSBbyUKSjlrP/yDwPcZunoAsmCAAtCKsFLviLaymzvsKI\neSSDGxpSySp2VCxompGDk8g=\n-----END CERTIFICATE-----\n"
|
||||
},
|
||||
"chains": {
|
||||
"dv-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
|
||||
],
|
||||
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com": [
|
||||
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
|
||||
],
|
||||
"dv-g3b-emudhra.we-have-a-problem.com": [
|
||||
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"dv-g3b-noct-emudhra.we-have-a-problem.com": [
|
||||
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"dv-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72"
|
||||
],
|
||||
"dv-g3c-emudhra.we-have-a-problem.com": [
|
||||
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5",
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ev-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512"
|
||||
],
|
||||
"ev-g3b-emudhra.we-have-a-problem.com": [
|
||||
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ev-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f"
|
||||
],
|
||||
"ev-g3c-emudhra.we-have-a-problem.com": [
|
||||
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ov-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699"
|
||||
],
|
||||
"ov-g3b-emudhra.we-have-a-problem.com": [
|
||||
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ov-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489"
|
||||
],
|
||||
"ov-g3c-emudhra.we-have-a-problem.com": [
|
||||
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
]
|
||||
},
|
||||
"cross_signed_roots": {
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "emSign Root TLS CA - G3 (emSign ECC Root CA - G3 cross-sign)"
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
]
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
// Immutable N0 oracle copied from F1 commit 1cc56cb9; used only for differential tests.
|
||||
const _ORDINALS = ['1st', '2nd', '3rd', '4th', '5th'];
|
||||
const _DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
function _hasTimeComponent(dateStr) {
|
||||
return typeof dateStr === 'string' && /T\d{2}:\d{2}/.test(dateStr);
|
||||
}
|
||||
|
||||
function _formatDueDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d)) return '';
|
||||
const now = new Date();
|
||||
const hasTime = _hasTimeComponent(dateStr);
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const due = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const diffDays = Math.round((due - today) / 86400000);
|
||||
const timeStr = hasTime ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : '';
|
||||
if (hasTime && d < now) return 'overdue';
|
||||
if (!hasTime && diffDays < 0) return 'overdue';
|
||||
if (diffDays === 0) return hasTime ? timeStr : 'today';
|
||||
if (diffDays === 1) return hasTime ? `tmrw ${timeStr}` : 'tomorrow';
|
||||
const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
return hasTime ? `${dateLabel} ${timeStr}` : dateLabel;
|
||||
}
|
||||
|
||||
function _isDueOverdue(dateStr) {
|
||||
if (!dateStr) return false;
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d)) return false;
|
||||
if (_hasTimeComponent(dateStr)) return d < new Date();
|
||||
return d < new Date(new Date().toDateString());
|
||||
}
|
||||
|
||||
function _isDueTodayOrOverdue(dateStr) {
|
||||
if (!dateStr) return false;
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const due = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
return due <= today;
|
||||
}
|
||||
|
||||
function _laterTodayDate() {
|
||||
const now = new Date();
|
||||
const eight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); // 6pm today
|
||||
// If less than 1 hour before 6pm, push to "in 3 hours" instead
|
||||
if (eight - now < 60 * 60 * 1000) return new Date(now.getTime() + 3 * 60 * 60 * 1000);
|
||||
return eight;
|
||||
}
|
||||
|
||||
function _tomorrowDate() {
|
||||
const t = new Date();
|
||||
t.setDate(t.getDate() + 1);
|
||||
t.setHours(8, 0, 0, 0);
|
||||
return t;
|
||||
}
|
||||
|
||||
function _nextWeekDate() {
|
||||
const t = new Date();
|
||||
const daysUntilMon = (8 - t.getDay()) % 7 || 7;
|
||||
t.setDate(t.getDate() + daysUntilMon);
|
||||
t.setHours(8, 0, 0, 0);
|
||||
return t;
|
||||
}
|
||||
|
||||
function _toLocalDatetimeStr(d) {
|
||||
// Format as YYYY-MM-DDTHH:MM (local, no TZ)
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function _formatReminderTag(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d)) return '';
|
||||
const now = new Date();
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const isTomorrow = d.toDateString() === tomorrow.toDateString();
|
||||
const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
if (sameDay) return `Today, ${time}`;
|
||||
if (isTomorrow) return `Tomorrow, ${time}`;
|
||||
const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
return `${dateLabel}, ${time}`;
|
||||
}
|
||||
|
||||
function _nthWeekdayLabel(d) {
|
||||
const n = Math.ceil(d.getDate() / 7); // 1..5
|
||||
return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[d.getDay()]}`;
|
||||
}
|
||||
|
||||
function _isLastWeekdayOfMonth(d) {
|
||||
const test = new Date(d);
|
||||
test.setDate(d.getDate() + 7);
|
||||
return test.getMonth() !== d.getMonth();
|
||||
}
|
||||
|
||||
function _nthWeekdayOfMonth(year, month, weekday, n) {
|
||||
const first = new Date(year, month, 1);
|
||||
const offset = (weekday - first.getDay() + 7) % 7;
|
||||
let day = 1 + offset + (n - 1) * 7;
|
||||
// Last day of month
|
||||
const lastDay = new Date(year, month + 1, 0).getDate();
|
||||
if (day > lastDay) day -= 7;
|
||||
return new Date(year, month, day, 0, 0, 0);
|
||||
}
|
||||
|
||||
function _lastWeekdayOfMonth(year, month, weekday) {
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const back = (lastDay.getDay() - weekday + 7) % 7;
|
||||
return new Date(year, month, lastDay.getDate() - back, 0, 0, 0);
|
||||
}
|
||||
|
||||
function _snapToRepeat(currentDate, normRepeat) {
|
||||
const hh = currentDate.getHours();
|
||||
const mm = currentDate.getMinutes();
|
||||
const now = Date.now();
|
||||
const anchor = currentDate.getTime() > now ? currentDate : new Date();
|
||||
const parts = normRepeat.split(':');
|
||||
const kind = parts[0];
|
||||
if (kind === 'weekly') {
|
||||
const targetWd = parseInt(parts[1], 10);
|
||||
if (isNaN(targetWd)) return null;
|
||||
const d = new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate(), hh, mm, 0, 0);
|
||||
const delta = (targetWd - d.getDay() + 7) % 7;
|
||||
d.setDate(d.getDate() + delta);
|
||||
if (d.getTime() <= now) d.setDate(d.getDate() + 7);
|
||||
return d;
|
||||
}
|
||||
if (kind === 'monthly') {
|
||||
const sub = parts[1];
|
||||
let y = anchor.getFullYear();
|
||||
let m = anchor.getMonth();
|
||||
// Walk forward up to 14 months to find the next matching slot.
|
||||
for (let tries = 0; tries < 14; tries++) {
|
||||
let target;
|
||||
if (sub === 'day') {
|
||||
const wantDay = parseInt(parts[2], 10);
|
||||
if (isNaN(wantDay)) return null;
|
||||
const lastDay = new Date(y, m + 1, 0).getDate();
|
||||
target = new Date(y, m, Math.min(wantDay, lastDay));
|
||||
} else if (sub === 'nth') {
|
||||
const n = parseInt(parts[2], 10);
|
||||
const wd = parseInt(parts[3], 10);
|
||||
if (isNaN(n) || isNaN(wd)) return null;
|
||||
target = _nthWeekdayOfMonth(y, m, wd, n);
|
||||
} else if (sub === 'last') {
|
||||
const wd = parseInt(parts[2], 10);
|
||||
if (isNaN(wd)) return null;
|
||||
target = _lastWeekdayOfMonth(y, m, wd);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
target.setHours(hh, mm, 0, 0);
|
||||
if (target.getTime() > now && target.getTime() >= anchor.getTime()) return target;
|
||||
m++;
|
||||
if (m > 11) { m = 0; y++; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _formatRepeatLabel(repeat, originalDate) {
|
||||
if (!repeat || repeat === 'none') return '';
|
||||
const norm = _normalizeRepeat(repeat, originalDate);
|
||||
if (norm === 'daily') return 'Daily';
|
||||
if (norm === 'yearly') return 'Yearly';
|
||||
const parts = norm.split(':');
|
||||
if (parts[0] === 'weekly') {
|
||||
const wd = parseInt(parts[1], 10);
|
||||
if (isNaN(wd)) return 'Weekly';
|
||||
return `Weekly on ${_DAYS[wd]}s`;
|
||||
}
|
||||
if (parts[0] === 'monthly') {
|
||||
if (parts[1] === 'day') return `Monthly on day ${parts[2]}`;
|
||||
if (parts[1] === 'nth') {
|
||||
const n = parseInt(parts[2], 10);
|
||||
const wd = parseInt(parts[3], 10);
|
||||
return `Monthly on ${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd]}`;
|
||||
}
|
||||
if (parts[1] === 'last') {
|
||||
const wd = parseInt(parts[2], 10);
|
||||
return `Monthly on last ${_DAYS[wd]}`;
|
||||
}
|
||||
}
|
||||
return norm;
|
||||
}
|
||||
|
||||
function _normalizeRepeat(repeat, originalDate) {
|
||||
if (!repeat || repeat === 'none') return 'none';
|
||||
if (repeat === 'daily' || repeat === 'yearly') return repeat;
|
||||
if (/^(weekly|monthly):/.test(repeat)) return repeat;
|
||||
// Legacy bare values - derive params from the original date
|
||||
const wd = originalDate.getDay();
|
||||
const n = Math.ceil(originalDate.getDate() / 7);
|
||||
if (repeat === 'weekly') return `weekly:${wd}`;
|
||||
if (repeat === 'monthly') return `monthly:day:${originalDate.getDate()}`;
|
||||
if (repeat === 'monthly_nth_weekday') return `monthly:nth:${n}:${wd}`;
|
||||
if (repeat === 'monthly_last_weekday') return `monthly:last:${wd}`;
|
||||
return repeat;
|
||||
}
|
||||
|
||||
function _advanceRecurring(dateStr, repeat) {
|
||||
const orig = new Date(dateStr);
|
||||
const hh = orig.getHours();
|
||||
const mm = orig.getMinutes();
|
||||
let d = new Date(orig);
|
||||
const norm = _normalizeRepeat(repeat, orig);
|
||||
if (norm === 'none') return null;
|
||||
|
||||
function step() {
|
||||
if (norm === 'daily') {
|
||||
d.setDate(d.getDate() + 1);
|
||||
return;
|
||||
}
|
||||
if (norm === 'yearly') {
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
return;
|
||||
}
|
||||
const parts = norm.split(':');
|
||||
const kind = parts[0];
|
||||
if (kind === 'weekly') {
|
||||
// Snap to the requested weekday in the next 1-7 days
|
||||
const targetWd = parseInt(parts[1], 10);
|
||||
let delta = (targetWd - d.getDay() + 7) % 7;
|
||||
if (delta === 0) delta = 7;
|
||||
d.setDate(d.getDate() + delta);
|
||||
d.setHours(hh, mm, 0, 0);
|
||||
return;
|
||||
}
|
||||
if (kind === 'monthly') {
|
||||
const sub = parts[1];
|
||||
const ny = d.getFullYear() + (d.getMonth() === 11 ? 1 : 0);
|
||||
const nm = (d.getMonth() + 1) % 12;
|
||||
let target;
|
||||
if (sub === 'day') {
|
||||
const wantDay = parseInt(parts[2], 10);
|
||||
const lastDay = new Date(ny, nm + 1, 0).getDate();
|
||||
target = new Date(ny, nm, Math.min(wantDay, lastDay));
|
||||
} else if (sub === 'nth') {
|
||||
const n = parseInt(parts[2], 10);
|
||||
const wd = parseInt(parts[3], 10);
|
||||
target = _nthWeekdayOfMonth(ny, nm, wd, n);
|
||||
} else if (sub === 'last') {
|
||||
const wd = parseInt(parts[2], 10);
|
||||
target = _lastWeekdayOfMonth(ny, nm, wd);
|
||||
} else {
|
||||
d = null; return;
|
||||
}
|
||||
target.setHours(hh, mm, 0, 0);
|
||||
d = target;
|
||||
return;
|
||||
}
|
||||
d = null;
|
||||
}
|
||||
|
||||
step();
|
||||
if (d === null) return null;
|
||||
const now = Date.now();
|
||||
// Cap catch-up to avoid runaway on a malformed/very-old date.
|
||||
let guard = 5000;
|
||||
while (d.getTime() <= now) {
|
||||
if (--guard <= 0) return null;
|
||||
step();
|
||||
if (d === null) return null;
|
||||
}
|
||||
return _toLocalDatetimeStr(d);
|
||||
}
|
||||
+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"
|
||||
]
|
||||
}
|
||||
]
|
||||
+4429
File diff suppressed because it is too large
Load Diff
+1226
File diff suppressed because it is too large
Load Diff
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,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { allowsDependency, imports, checkArchitecture, inventory } from '../scripts/check_frontend.mjs';
|
||||
import * as source from '../frontend/src/shared/actionMenuOrder.js';
|
||||
import * as deployed from '../static/js/actionMenuOrder.js';
|
||||
|
||||
test('stateless helper preserves its public API, ordering, references and inputs', () => {
|
||||
for (const module of [source, deployed]) {
|
||||
assert.deepEqual(Object.keys(module).sort(), ['SELECT_MENU_ICON', 'actionMenuRank', 'orderActionMenuItems']);
|
||||
const callback = () => {};
|
||||
const items = ['Delete', 'Select', 'Copy', 'Rename', 'Archive', 'Open', 'Cancel'].map(label => Object.freeze({ label, action: callback }));
|
||||
Object.freeze(items);
|
||||
const sorted = module.orderActionMenuItems(items);
|
||||
assert.deepEqual(sorted.map(i => i.label), ['Open', 'Rename', 'Copy', 'Select', 'Archive', 'Delete', 'Cancel']);
|
||||
assert.ok(sorted.every(i => items.includes(i)));
|
||||
assert.deepEqual(module.orderActionMenuItems([]), []);
|
||||
assert.equal(module.actionMenuRank({ menuOrder: 12, label: 'Delete' }), 12);
|
||||
assert.equal(module.actionMenuRank({ label: 'Unknown' }), 100);
|
||||
const equalRank = [{ label: 'Open first' }, { label: 'Open second' }];
|
||||
assert.deepEqual(module.orderActionMenuItems(equalRank), equalRank);
|
||||
}
|
||||
assert.equal(source.SELECT_MENU_ICON, deployed.SELECT_MENU_ICON);
|
||||
});
|
||||
|
||||
test('dependency direction separates composition, workflow, adapters and views', () => {
|
||||
const n = name => `features/notes/${name}.js`;
|
||||
const allowed = [
|
||||
['app/bootstrap.js', n('index')],
|
||||
[n('index'), n('infrastructure/api')],
|
||||
[n('application/crud'), n('domain/recurrence')],
|
||||
[n('infrastructure/api'), 'shared/platform/http.js'],
|
||||
[n('ui/panel'), n('application/crud')],
|
||||
[n('application/reminders'), 'features/calendar/index.js'],
|
||||
];
|
||||
for (const [from, to] of allowed) assert.ok(allowsDependency(from, to), `${from} -> ${to}`);
|
||||
const forbidden = [
|
||||
[n('domain/recurrence'), n('infrastructure/api')],
|
||||
[n('application/crud'), n('ui/panel')],
|
||||
[n('application/crud'), n('infrastructure/api')],
|
||||
[n('ui/panel'), 'features/calendar/index.js'],
|
||||
[n('application/crud'), 'features/calendar/application/store.js'],
|
||||
['app/bootstrap.js', n('domain/recurrence')],
|
||||
['shared/platform/http.js', n('index')],
|
||||
[n('domain/recurrence'), 'app/bootstrap.js'],
|
||||
];
|
||||
for (const [from, to] of forbidden) assert.equal(allowsDependency(from, to), false, `${from} -> ${to}`);
|
||||
});
|
||||
|
||||
test('parser sees re-exports, multiline and dynamic imports without comment false positives', () => {
|
||||
const code = `// import './false.js';
|
||||
const text = "import './false.js'";
|
||||
import { a }\n from './a.js';
|
||||
export { b } from './b.js';
|
||||
export * from './c.js';
|
||||
const load = () => import('./d.js');`;
|
||||
assert.deepEqual(imports(code).map(i => i.specifier), ['./a.js', './b.js', './c.js', './d.js']);
|
||||
assert.throws(() => imports('const load = p => import(p);'), /computed import/);
|
||||
assert.throws(() => imports('export const now = () => Date.now();', { domain: true }), /ambient clock/);
|
||||
assert.throws(() => imports('export const load = () => fetch("/api/notes");', { domain: true }), /platform identifier/);
|
||||
assert.doesNotThrow(() => imports('export const day = value => new Date(value);', { domain: true }));
|
||||
assert.doesNotThrow(() => imports('export const copy = value => ({ document: value.document });', { domain: true }));
|
||||
});
|
||||
|
||||
function fixture(t, modules, { cache = true } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ody-frontend-gates-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const entries = Object.entries(modules).map(([name, code]) => {
|
||||
const file = path.join(root, 'frontend/src', name);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, code);
|
||||
return { source: name, deployment: `js/${name}` };
|
||||
});
|
||||
fs.mkdirSync(path.join(root, 'static'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'frontend/modules.json'), JSON.stringify({ version: 1, modules: entries }));
|
||||
const urls = cache ? entries.map(m => `/static/${m.deployment}`) : [];
|
||||
fs.writeFileSync(path.join(root, 'static/sw.js'), `const PRECACHE = ${JSON.stringify(urls)}; const PANEL_PRECACHE = [];`);
|
||||
return root;
|
||||
}
|
||||
|
||||
test('complete mapped graph and exact precache pass', t => {
|
||||
const root = fixture(t, { 'features/notes/index.js': "export { value } from './domain/value.js';", 'features/notes/domain/value.js': 'export const value = 1;' });
|
||||
assert.equal(checkArchitecture(root), 2);
|
||||
});
|
||||
|
||||
test('feature-private import fails through the full checker', t => {
|
||||
const root = fixture(t, { 'features/notes/application/a.js': "import '../../calendar/domain/b.js';", 'features/calendar/domain/b.js': 'export const value = 1;' });
|
||||
assert.throws(() => checkArchitecture(root), /forbidden dependency/);
|
||||
});
|
||||
|
||||
test('undeclared legacy coupling and alternate identities fail', t => {
|
||||
for (const specifier of ['/static/js/notes.js?v=legacy', './b.js?v=other', '/frontend/src/shared/b.js', 'some-package']) {
|
||||
const root = fixture(t, { 'shared/a.js': `import '${specifier}';`, 'shared/b.js': 'export const value = 1;' });
|
||||
assert.throws(() => checkArchitecture(root), /legacy import|one unversioned|unsupported|canonical static/);
|
||||
}
|
||||
});
|
||||
|
||||
test('a copy mapping cannot silently change relative import resolution', t => {
|
||||
const root = fixture(t, { 'shared/a.js': "import './b.js';", 'shared/b.js': 'export const value = 1;' });
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'frontend/modules.json')));
|
||||
manifest.modules[1].deployment = 'js/elsewhere/b.js';
|
||||
fs.writeFileSync(path.join(root, 'frontend/modules.json'), JSON.stringify(manifest));
|
||||
fs.writeFileSync(path.join(root, 'static/sw.js'), "const PRECACHE = ['/static/js/shared/a.js', '/static/js/elsewhere/b.js'];");
|
||||
assert.throws(() => checkArchitecture(root), /resolve differently/);
|
||||
});
|
||||
|
||||
test('new modules must join exact offline manifests and cannot introduce cycles', t => {
|
||||
const missing = fixture(t, { 'shared/a.js': 'export const a = 1;' }, { cache: false });
|
||||
assert.throws(() => checkArchitecture(missing), /absent from explicit precache/);
|
||||
const cyclic = fixture(t, { 'shared/a.js': "import './b.js';", 'shared/b.js': "import './a.js';" });
|
||||
assert.throws(() => checkArchitecture(cyclic), /dependency cycle/);
|
||||
});
|
||||
|
||||
test('legacy adapters require exact, used exceptions and reserved names cannot bypass ownership', t => {
|
||||
const root = fixture(t, { 'features/notes/infrastructure/api.js': "import '/static/js/legacy.js?v=existing';" });
|
||||
fs.mkdirSync(path.join(root, 'static/js'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'static/js/legacy.js'), 'export const legacy = true;');
|
||||
const file = path.join(root, 'frontend/modules.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(file));
|
||||
manifest.modules[0].legacyImports = [{ specifier: '/static/js/legacy.js?v=existing', reason: 'Temporary existing transport adapter; remove in N2.' }];
|
||||
fs.writeFileSync(file, JSON.stringify(manifest));
|
||||
assert.equal(checkArchitecture(root), 1);
|
||||
manifest.modules[0].legacyImports.push({ specifier: '/static/js/unused.js', reason: 'stale' });
|
||||
fs.writeFileSync(file, JSON.stringify(manifest));
|
||||
assert.throws(() => checkArchitecture(root), /unused legacy import exception/);
|
||||
assert.throws(() => allowsDependency('features/shared/index.js', 'app/bootstrap.js'), /reserved feature name/);
|
||||
});
|
||||
|
||||
test('inventory reports executable static cycles and separates dynamic edges', t => {
|
||||
const root = fixture(t, { 'shared/a.js': 'export const a = 1;' });
|
||||
fs.mkdirSync(path.join(root, 'static/js'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'static/js/a.js'), "import './b.js'; const load = () => import('./c.js');");
|
||||
fs.writeFileSync(path.join(root, 'static/js/b.js'), "import './a.js';");
|
||||
fs.writeFileSync(path.join(root, 'static/js/c.js'), 'export const c = 1;');
|
||||
const result = inventory(root);
|
||||
assert.equal(result.staticImports, 2);
|
||||
assert.equal(result.dynamicLiteralImports, 1);
|
||||
assert.deepEqual(result.staticCycles, [['js/a.js', 'js/b.js']]);
|
||||
assert.deepEqual(result.missing, []);
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
test('Gallery and Quick Edit Model Picker: identity, privacy, and synchronization', async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
|
||||
const mockModels = [
|
||||
{
|
||||
id: 'sdxl',
|
||||
label: 'Stable Diffusion XL (Primary)',
|
||||
kind: 'edit',
|
||||
provider: 'Cluster A',
|
||||
endpoint_id: 101,
|
||||
// Non-admin view: base_url omitted
|
||||
},
|
||||
{
|
||||
id: 'sdxl',
|
||||
label: 'Stable Diffusion XL (Secondary)',
|
||||
kind: 'edit',
|
||||
provider: 'Cluster B',
|
||||
endpoint_id: 202,
|
||||
// Non-admin view: base_url omitted
|
||||
},
|
||||
{
|
||||
id: 'qwen-image',
|
||||
label: 'Qwen Image Edit',
|
||||
kind: 'edit',
|
||||
provider: 'Local Seneca',
|
||||
endpoint_id: 303,
|
||||
},
|
||||
];
|
||||
|
||||
await page.route('http://gallery-sync.local/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === '/') {
|
||||
return route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: `<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="gallery-settings-container">
|
||||
<select id="gallery-image-model"></select>
|
||||
</div>
|
||||
<div id="editor-container">
|
||||
<select class="ge-ai-command-model" id="ge-ai-command-model"></select>
|
||||
<div id="ge-status-model"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/editor/image-models') {
|
||||
return route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ models: mockModels }),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/editor/agent-models') {
|
||||
return route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ models: [] }),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
|
||||
return route.fulfill({ contentType: 'text/javascript', body: source });
|
||||
} catch (err) {
|
||||
return route.abort();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('http://gallery-sync.local/');
|
||||
|
||||
// --- Test 1: Option value encoding and distinct endpoint identities ---
|
||||
const evalResult1 = await page.evaluate(async () => {
|
||||
// Set legacy localStorage value that contained a private base URL
|
||||
localStorage.setItem('ge-ai-command-model', 'http://private-cluster.internal/v1::qwen-image');
|
||||
|
||||
const { wireAIModelSelectors } = await import('/static/js/editor/ai-models.js');
|
||||
wireAIModelSelectors({ container: document.body, apiBase: '' });
|
||||
|
||||
// Run gallery initialization
|
||||
const { openGallery } = await import('/static/js/gallery.js');
|
||||
try { openGallery(); } catch (_) {}
|
||||
|
||||
// Wait a moment for network fetch
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
|
||||
const gallerySel = document.getElementById('gallery-image-model');
|
||||
const qeSel = document.getElementById('ge-ai-command-model');
|
||||
|
||||
const galleryOpts = Array.from(gallerySel.options).map((o) => ({ value: o.value, text: o.textContent }));
|
||||
const qeOpts = Array.from(qeSel.options).map((o) => ({ value: o.value, text: o.textContent }));
|
||||
|
||||
const migratedKey = localStorage.getItem('ge-ai-command-model');
|
||||
|
||||
return {
|
||||
galleryOpts,
|
||||
qeOpts,
|
||||
galleryValue: gallerySel.value,
|
||||
qeValue: qeSel.value,
|
||||
migratedKey,
|
||||
};
|
||||
});
|
||||
|
||||
// Check duplicate model id 'sdxl' produces distinguishable values with endpoint_id
|
||||
const sdxlGalleryOpts = evalResult1.galleryOpts.filter((o) => o.value.endsWith('::sdxl'));
|
||||
assert.equal(sdxlGalleryOpts.length, 2, 'Two endpoints offering sdxl produce two distinct options');
|
||||
assert.equal(sdxlGalleryOpts[0].value, '101::sdxl');
|
||||
assert.equal(sdxlGalleryOpts[1].value, '202::sdxl');
|
||||
|
||||
// Check no options contain undefined or empty prefix
|
||||
for (const opt of evalResult1.galleryOpts) {
|
||||
assert.equal(opt.value.startsWith('::'), false, 'Option value must not start with empty ::');
|
||||
assert.equal(opt.value.includes('undefined'), false, 'Option value must not include undefined');
|
||||
assert.equal(opt.value.includes('http://'), false, 'Option value must not expose raw http URL');
|
||||
}
|
||||
|
||||
// Check legacy migration: 'http://private-cluster.internal/v1::qwen-image' was migrated to '303::qwen-image'
|
||||
assert.equal(evalResult1.migratedKey, '303::qwen-image', 'Legacy key with URL migrated cleanly');
|
||||
assert.equal(evalResult1.galleryValue, '303::qwen-image', 'Gallery picker selected migrated model');
|
||||
assert.equal(evalResult1.qeValue, '303::qwen-image', 'Quick Edit picker selected migrated model');
|
||||
|
||||
// --- Test 2: Same-tab synchronization (Gallery -> Quick Edit) ---
|
||||
const evalResult2 = await page.evaluate(async () => {
|
||||
const gallerySel = document.getElementById('gallery-image-model');
|
||||
gallerySel.value = '101::sdxl';
|
||||
gallerySel.dispatchEvent(new Event('change'));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
|
||||
const qeSel = document.getElementById('ge-ai-command-model');
|
||||
const statusEl = document.getElementById('ge-status-model');
|
||||
const savedKey = localStorage.getItem('ge-ai-command-model');
|
||||
|
||||
return {
|
||||
qeValue: qeSel.value,
|
||||
statusText: statusEl ? statusEl.textContent : '',
|
||||
savedKey,
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(evalResult2.qeValue, '101::sdxl', 'Changing gallery picker updates quick-edit picker in same tab');
|
||||
assert.equal(evalResult2.savedKey, '101::sdxl', 'Changing gallery picker updates localStorage');
|
||||
|
||||
// --- Test 3: Same-tab synchronization (Quick Edit -> Gallery) ---
|
||||
const evalResult3 = await page.evaluate(async () => {
|
||||
const qeSel = document.getElementById('ge-ai-command-model');
|
||||
qeSel.value = '202::sdxl';
|
||||
qeSel.dispatchEvent(new Event('change'));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
|
||||
const gallerySel = document.getElementById('gallery-image-model');
|
||||
const savedKey = localStorage.getItem('ge-ai-command-model');
|
||||
|
||||
return {
|
||||
galleryValue: gallerySel.value,
|
||||
savedKey,
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(evalResult3.galleryValue, '202::sdxl', 'Changing quick-edit picker updates gallery picker in same tab');
|
||||
assert.equal(evalResult3.savedKey, '202::sdxl', 'Changing quick-edit picker updates localStorage');
|
||||
|
||||
// --- Test 4: Cross-tab synchronization via storage event ---
|
||||
const evalResult4 = await page.evaluate(async () => {
|
||||
// Simulate storage event from another tab
|
||||
window.dispatchEvent(new StorageEvent('storage', {
|
||||
key: 'ge-ai-command-model',
|
||||
newValue: '101::sdxl',
|
||||
}));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
|
||||
const gallerySel = document.getElementById('gallery-image-model');
|
||||
const qeSel = document.getElementById('ge-ai-command-model');
|
||||
|
||||
return {
|
||||
galleryValue: gallerySel.value,
|
||||
qeValue: qeSel.value,
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(evalResult4.galleryValue, '101::sdxl', 'Storage event updates gallery picker');
|
||||
assert.equal(evalResult4.qeValue, '101::sdxl', 'Storage event updates quick-edit picker');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('picker reconstruction owns listeners and clears invalid persisted identities', async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
let imageRequests = 0;
|
||||
await context.route('http://picker-lifecycle.local/**', async route => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<div id="ge-status-model"></div><div id="editor-container"><select id="ge-ai-command-model"></select><select id="ge-ai-inpaint"></select></div>' });
|
||||
if (path.startsWith('/api/')) {
|
||||
if (path.endsWith('/image-models')) imageRequests++;
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ models: path.endsWith('/image-models') ? [{ id: 'qwen-image', endpoint_id: 'alice', kind: 'edit', provider: 'A' }] : [], items: [], albums: [] }) });
|
||||
}
|
||||
try { return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') }); }
|
||||
catch { return route.fulfill({ status: 404, body: '' }); }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.addInitScript(() => {
|
||||
window.modelListeners = {};
|
||||
const add = window.addEventListener.bind(window);
|
||||
window.addEventListener = (type, ...args) => { if (['storage', 'ge-ai-command-model-change', 'ge:model-endpoints-updated'].includes(type)) window.modelListeners[type] = (window.modelListeners[type] || 0) + 1; return add(type, ...args); };
|
||||
window.modelEvents = 0;
|
||||
add('ge-ai-command-model-change', () => window.modelEvents++);
|
||||
});
|
||||
const init = async p => {
|
||||
await p.goto('http://picker-lifecycle.local/');
|
||||
await p.evaluate(async () => {
|
||||
window.ai = await import('/static/js/editor/ai-models.js');
|
||||
window.gallery = await import('/static/js/gallery.js');
|
||||
window.ai.wireAIModelSelectors({ container: document.getElementById('editor-container') });
|
||||
window.gallery.openGallery();
|
||||
});
|
||||
await p.waitForFunction(() => document.getElementById('gallery-image-model')?.options.length === 2 && document.getElementById('ge-ai-command-model')?.options.length > 2);
|
||||
};
|
||||
await init(page);
|
||||
const initial = await page.evaluate(() => ({ ...window.modelListeners }));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.evaluate(() => { window.gallery.closeGallery(); window.oldSelect = document.getElementById('ge-ai-command-model'); window.oldValue = window.oldSelect.value; document.getElementById('editor-container').replaceChildren(); });
|
||||
await page.waitForFunction(() => !document.getElementById('gallery-modal'));
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('editor-container').innerHTML = '<select id="ge-ai-command-model"></select><select id="ge-ai-inpaint"></select>';
|
||||
window.ai.wireAIModelSelectors({ container: document.getElementById('editor-container') });
|
||||
window.gallery.openGallery();
|
||||
});
|
||||
await page.waitForFunction(() => document.getElementById('gallery-image-model')?.options.length === 2 && document.getElementById('ge-ai-command-model')?.options.length > 2);
|
||||
}
|
||||
assert.deepEqual(await page.evaluate(() => window.modelListeners), initial, 'no new global listeners after reopen/reconstruction');
|
||||
const before = imageRequests;
|
||||
await page.evaluate(() => window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated')));
|
||||
await page.waitForTimeout(100);
|
||||
assert.equal(imageRequests - before, 1, 'only live editor refreshes; no detached control handlers');
|
||||
const p2 = await context.newPage(); await init(p2);
|
||||
await page.evaluate(() => { window.modelEvents = 0; window.footerUpdates = 0; new MutationObserver(records => window.footerUpdates += records.length).observe(document.getElementById('ge-status-model'), { childList: true }); const s = document.getElementById('ge-ai-command-model'); s.value = 'alice::qwen-image'; s.dispatchEvent(new Event('change')); });
|
||||
await p2.waitForFunction(() => document.getElementById('gallery-image-model').value === 'alice::qwen-image');
|
||||
assert.equal(await page.evaluate(() => window.modelEvents), 1);
|
||||
assert.equal(await page.evaluate(() => window.footerUpdates), 1, 'exactly one footer update');
|
||||
assert.match(await page.locator('#ge-status-model').textContent(), /Edit:.*qwen-image/);
|
||||
assert.equal(await page.evaluate(() => window.oldSelect.value), await page.evaluate(() => window.oldValue), 'global events never update detached controls');
|
||||
await init(p2);
|
||||
assert.equal(await p2.locator('#gallery-image-model').inputValue(), 'alice::qwen-image');
|
||||
for (const invalid of ['deleted::qwen-image', 'disabled::qwen-image', 'https://private.invalid/v1::missing']) {
|
||||
await page.evaluate(value => localStorage.setItem('ge-ai-command-model', value), invalid);
|
||||
await init(page);
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem('ge-ai-command-model')), null, invalid);
|
||||
assert.equal(await page.locator('#gallery-image-model').inputValue(), '');
|
||||
assert.equal(await page.locator('#ge-ai-command-model').inputValue(), '');
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
});
|
||||
@@ -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 sys.modules.copy().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,10 @@
|
||||
"""Read deployed Notes responsibilities for source guards; runtime tests execute their real modules."""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def notes_source(*modules):
|
||||
base = ROOT / "static/js/features/notes"
|
||||
paths = [base / f"{module}.js" for module in modules] if modules else sorted(base.rglob("*.js"))
|
||||
return "\n".join(path.read_text(encoding="utf-8") for path in paths)
|
||||
@@ -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,13 @@
|
||||
// Runs inside a browser via Playwright evaluate; parsing does not execute code.
|
||||
function extractThemeBootstrap(html) {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const matches = Array.from(doc.querySelectorAll('script:not([src])'))
|
||||
.map(script => script.textContent)
|
||||
.filter(code => code.includes('Apply font early'));
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Expected one early theme bootstrap, found ${matches.length}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
module.exports = { extractThemeBootstrap };
|
||||
@@ -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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user