mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-21 06:12:21 +02:00
Merge current dev into PR #6473
Preserve the contributor's original scheduler fix while integrating current dev and its narrowly scoped secret-scanning configuration.
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,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,153 @@
|
||||
# Computed-style snapshot harness
|
||||
|
||||
The app CSS is an ordered multi-file cascade. Hundreds of selectors are
|
||||
declared more than once and `!important` appears throughout, so the rendered
|
||||
result is a function of **source order**. Extracting a block into its own file,
|
||||
reordering `<link>` tags, or moving an `@media` rule can silently change which
|
||||
declaration wins, and nothing else in the suite would notice.
|
||||
|
||||
This harness makes that falsifiable. It captures `getComputedStyle` over a
|
||||
fixed element inventory, hashes the result, and compares it to a committed
|
||||
baseline. It moves no CSS itself.
|
||||
|
||||
## What it covers
|
||||
|
||||
| Dimension | Values |
|
||||
|---|---|
|
||||
| Pages | `static/index.html` (app shell, 76 elements), `static/login.html` (14), the bench (586 selectors) |
|
||||
| Viewports | 1440x900, 820x1000, 768x1024 (touch), 390x844 (touch) |
|
||||
| Themes | dark (default) and `:root.light` |
|
||||
| Density | default, `:root.density-compact`, `:root.density-spacious` |
|
||||
| Properties | 122 pinned properties per element, plus every custom property on `:root` and `body` |
|
||||
|
||||
That is 676 elements x 24 variants = 16,224 element snapshots per run, in
|
||||
about 21 seconds.
|
||||
|
||||
The **app shell** page measures real elements in the markup the server sends,
|
||||
including modals - each one revealed on its own and re-hidden straight after,
|
||||
so the measurements stay independent.
|
||||
|
||||
The **bench** page measures one synthesised element per selector, built from
|
||||
the selector itself. Its selector list is evidence-driven: every selector
|
||||
declared **more than once** in the app cascade that can be expressed as a static
|
||||
compound chain (551 of them), plus a curated set covering chat, documents,
|
||||
email, notes, calendar, settings, cookbook and gallery. Redeclared selectors
|
||||
are the ones a reorder can actually flip, so they are the ones worth benching.
|
||||
A bench element pins the cascade for that class combination; it does not pin
|
||||
the markup that the JS produces.
|
||||
|
||||
Selectors the bench grammar cannot express are the gap: selector lists
|
||||
(`a, b`), pseudo-elements, pseudo-classes, `:not()` and `:has()`. They are
|
||||
skipped rather than approximated.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `inventory.json` | The fixed inventory: properties, variants, pages, elements, bench selectors |
|
||||
| `baseline.json` | The committed digest plus per-element and per-variant hashes |
|
||||
| `capture.mjs` | Playwright capture; raw values on stdout |
|
||||
| `bench.html` | Empty page that loads the stylesheet; the capture mounts bench nodes into it |
|
||||
| `../test_css_computed_style_snapshot.py` | The regression test |
|
||||
| `../../scripts/css_snapshot.py` | Hashing, comparison, and the CLI |
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
./venv/bin/python -m pytest tests/test_css_computed_style_snapshot.py
|
||||
./venv/bin/python scripts/css_snapshot.py --check # same comparison, standalone
|
||||
./venv/bin/python scripts/css_snapshot.py --write-baseline # re-record
|
||||
```
|
||||
|
||||
The CLI serves the repository on an ephemeral port itself, so it does not need
|
||||
pytest. Under pytest the session static server is reused through
|
||||
`ODYSSEUS_TEST_STATIC_ORIGIN`.
|
||||
|
||||
`npm ci` is required: the capture drives Playwright's Chromium. Without it the
|
||||
browser tests skip.
|
||||
|
||||
## When the test fails
|
||||
|
||||
The failure names the elements and the variants whose hashes moved. To see
|
||||
which *property* moved, capture both sides and diff:
|
||||
|
||||
```bash
|
||||
./venv/bin/python scripts/css_snapshot.py --dump after.json
|
||||
git stash && ./venv/bin/python scripts/css_snapshot.py --dump before.json && git stash pop
|
||||
diff <(python -m json.tool before.json) <(python -m json.tool after.json)
|
||||
```
|
||||
|
||||
Re-record the baseline only when the change in rendered style is **intended**
|
||||
and reviewed. On a mechanical CSS extraction it never should be: an extraction
|
||||
that preserves order produces an identical digest, and one that does not has
|
||||
changed the UI.
|
||||
|
||||
## Determinism
|
||||
|
||||
The digest is only worth having if an unchanged stylesheet always produces the
|
||||
same bytes, so the capture:
|
||||
|
||||
- strips every `<script>` from the document, leaving exactly the markup the
|
||||
server sends - no app module can mutate classes underneath the measurement;
|
||||
- injects the theme and density classes into `<html>` *before* first paint
|
||||
rather than toggling them afterwards, so no CSS transition is ever
|
||||
mid-interpolation while `getComputedStyle` runs;
|
||||
- removes `autofocus` before parsing: focus states are outside this inventory,
|
||||
and the browser's asynchronous autofocus step otherwise races the capture;
|
||||
- pauses CSS animations at time zero and finishes CSS transitions before each
|
||||
measurement, including newly revealed modals and newly mounted bench nodes.
|
||||
Animation and transition declarations are still captured; the harness does
|
||||
not inject `animation: none` or `transition: none`;
|
||||
- pins Chromium's standard font preference to `Times New Roman` via CDP,
|
||||
without overriding any author declaration;
|
||||
- canonicalizes only the `BlinkMacSystemFont` family token to `"system-ui"`,
|
||||
the spelling Chromium uses for that alias on macOS. Other family names and
|
||||
their order remain significant;
|
||||
- measures the `custom-system-prompt` element's `max-height` in `lh`, as opted
|
||||
into by its inventory entry. Its authored `30lh` resolves to different pixel
|
||||
heights with different fallback fonts; the line count remains significant;
|
||||
- aborts images, fonts and media, which cost time and change nothing in the
|
||||
pinned property set;
|
||||
- hides scrollbars, so a platform's scrollbar width cannot change the width
|
||||
that percentages and `auto` resolve against;
|
||||
- pins `prefers-reduced-motion`, `forced-colors`, `prefers-color-scheme` and
|
||||
the device pixel ratio.
|
||||
|
||||
## What it does not cover
|
||||
|
||||
- **Layout geometry.** `width`, `height`, `top`/`left`/`right`/`bottom`,
|
||||
`transform` and `grid-template-*` resolve to used values that depend on text
|
||||
layout, so they are excluded rather than risk a baseline that only holds on
|
||||
one machine. A reorder that changes a size through a property in the pinned
|
||||
set is still caught; one that changes it only through an excluded property is
|
||||
not.
|
||||
- **Pseudo-class states.** `:hover`, `:focus` and `:active` are not driven.
|
||||
- **JS-applied classes.** State the app adds at runtime (collapsed sidebar,
|
||||
open panels, active tabs) is not represented beyond what the served markup
|
||||
and the bench selectors already carry.
|
||||
- **Browser upgrades and additional platforms.** The original macOS baseline
|
||||
and Linux captures were compared property by property through exact hash
|
||||
recovery; the proven platform differences are now controlled above. A new
|
||||
capture on macOS has not been performed. New browser serialization changes
|
||||
still need investigation rather than automatic baseline regeneration.
|
||||
- **The stylesheet is only one of the inputs.** `static/login.html` styles
|
||||
itself from an inline `<style>` block; it is in the inventory so the
|
||||
hand-mirrored token values there are pinned too.
|
||||
|
||||
## Adding coverage
|
||||
|
||||
Add an entry to `inventory.json` - an `{key, selector}` object under a page's
|
||||
`elements` (with `"unhide": true` if it ships hidden, `"custom": true` to
|
||||
include custom properties), or a selector string under `bench` - then
|
||||
re-record the baseline. `test_baseline_covers_every_inventory_entry` fails if
|
||||
the two go out of sync.
|
||||
|
||||
`test_capture_is_independent_of_elapsed_time_and_font_metrics` perturbs capture
|
||||
timing and font metrics, checks autofocus suppression, and proves that animation
|
||||
keyframes, metadata and relative line counts still affect measurements. The
|
||||
existing cascade-order self-test still detects a reordered declaration.
|
||||
|
||||
The PR #40 canonical baseline and its exact justification are documented in
|
||||
[`pr40-validation.md`](pr40-validation.md). All 122 properties and 676 inventory
|
||||
elements remain covered; only one element/property opts into line-relative
|
||||
measurement.
|
||||
@@ -0,0 +1,767 @@
|
||||
{
|
||||
"digest": "9868d50a542b7ad1",
|
||||
"elements": {
|
||||
"app-shell": {
|
||||
"app-loader": "f04ad5bf6312d53f",
|
||||
"attach-strip": "a6474951e2481214",
|
||||
"body": "58df17f0afa190b9",
|
||||
"chat-container": "b00a7c6be4e107b1",
|
||||
"chat-context-pill": "dd905b87245a9a71",
|
||||
"chat-history": "e65bdc51f4d1675f",
|
||||
"confirm-btn-primary": "c906e0be9fe73bef",
|
||||
"cookbook-modal": "918b17b2e2a311dc",
|
||||
"cookbook-modal-content": "73700bbe8be47774",
|
||||
"custom-preset-modal": "9345624780009d14",
|
||||
"custom-system-prompt": "3ebe8cb381e35746",
|
||||
"export-dl-btn": "d3fe28e931b66974",
|
||||
"export-dropdown-item": "d90f6f513d06a6f7",
|
||||
"export-dropdown-menu": "0793e06a6aae6a7a",
|
||||
"hamburger-btn": "34714f6eef3734d8",
|
||||
"icon-rail": "e3f72205260a4a5f",
|
||||
"icon-rail-btn": "54ffb10e056ebf44",
|
||||
"incognito-btn": "ee42bd3f6a32576b",
|
||||
"input-icon-btn": "3d1c773acd84a825",
|
||||
"memory-close-btn": "c3f4ba99098a6e4b",
|
||||
"memory-list": "da9a37a3de3c1a31",
|
||||
"memory-modal": "5efc81537eab4526",
|
||||
"memory-modal-content": "3cadc8581361436e",
|
||||
"memory-modal-header": "a8c822fe8c6d20e7",
|
||||
"memory-search-input": "8b0fbb33401750d4",
|
||||
"memory-toolbar-btn": "9f985efa7c896f90",
|
||||
"message-ghost": "ce9144a7705ee903",
|
||||
"message-textarea": "6a0ce314ee2fb9cf",
|
||||
"mobile-backdrop": "b2ed3a84835d4916",
|
||||
"mode-toggle-active": "9dfc0fe8866d86c2",
|
||||
"mode-toggle-idle": "4a221dded6bec4d6",
|
||||
"model-picker-btn": "d44aa4f424c3447e",
|
||||
"model-picker-list": "23a89bcc30d0ccee",
|
||||
"model-picker-menu": "f3320494f37c1156",
|
||||
"overflow-menu": "e40a9742168a2a8b",
|
||||
"overflow-menu-item": "1a7cad32047dc976",
|
||||
"pinned-tools-bar": "bb0f88ce29ae7637",
|
||||
"rail-new-chat": "f8f1c2f6be426ab9",
|
||||
"reasoning-effort-btn": "2a07cdd8ec2b4d26",
|
||||
"rename-session-modal": "9345624780009d14",
|
||||
"root": "820190c144fda419",
|
||||
"save-custom-preset": "443da70ef5fe9b88",
|
||||
"scroll-bottom-btn": "24602d72e2949991",
|
||||
"search-input": "53b94f3e5fdb384d",
|
||||
"search-overlay": "87d5f21c20bdde98",
|
||||
"session-list-bootstrap": "aaa28ed4edac84a9",
|
||||
"session-name-input": "c435986f102bee53",
|
||||
"settings-admin-card": "fcf3c80f20175a3c",
|
||||
"settings-btn-add": "82aeb52b9fc7de0f",
|
||||
"settings-btn-sm": "c31bcb9bdfbae10d",
|
||||
"settings-fallbacks": "e339280590935fe3",
|
||||
"settings-logs-tab": "e470140373597143",
|
||||
"settings-modal": "5efc81537eab4526",
|
||||
"settings-nav-search": "f4484b2980f2ccc5",
|
||||
"settings-row": "de568724268cec6e",
|
||||
"settings-select": "a96e8430b0ec6668",
|
||||
"settings-sidebar-toggle": "6be9025ab24bc434",
|
||||
"sidebar": "77ec42a646a8db66",
|
||||
"sidebar-brand": "d3d7ef4b21514e80",
|
||||
"sidebar-new-chat": "567fae30048b7ff3",
|
||||
"sidebar-resize-handle": "6607b819ee45409d",
|
||||
"sidebar-section": "a46f059ca4b0b4b5",
|
||||
"sidebar-section-header-btn": "eff26568c49f8b40",
|
||||
"sidebar-user-bar": "b77372dbdcb069ef",
|
||||
"theme-grid": "4d4b2aeb8952cc6f",
|
||||
"theme-io-btn": "859f222c79a8c721",
|
||||
"theme-modal": "0a65ca916656cdbb",
|
||||
"theme-popup": "3aa65e6f313bbc65",
|
||||
"theme-tabs": "0cc4987f39876dce",
|
||||
"toast": "c60c6a60d4982005",
|
||||
"tool-indicator": "0dee45eaca8b703a",
|
||||
"user-bar-avatar": "b989bbab5aab3988",
|
||||
"user-bar-settings": "2d91a65e0ebdd049",
|
||||
"welcome-screen": "7024d434f65886c9",
|
||||
"welcome-sub": "89cdafbb95b7c845",
|
||||
"welcome-tip": "875045a9d2ec1055"
|
||||
},
|
||||
"bench": {
|
||||
"#agent-indicator": "09a1b8d94b1487ee",
|
||||
"#agent-indicator.active": "67e2ad3fa48acd3b",
|
||||
"#compare-model-overlay .modal-body": "0b1ee357313551b9",
|
||||
"#compare-model-overlay .modal-content": "56883147d181bd9c",
|
||||
"#cookbook-modal .cookbook-body": "20bd8a7268cee788",
|
||||
"#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card": "48f6787a3df3f19e",
|
||||
"#cookbook-modal .modal-content": "0c4e1e686df0990e",
|
||||
"#cookbook-modal .modal-content.cookbook-modal-entering": "fba9bbbb406a70c4",
|
||||
"#cookbook-modal .modal-header": "1445de059f94beb9",
|
||||
"#cookbook-server-add.cal-add-btn-text": "36d5d25f82aad559",
|
||||
"#doc-actions-footer": "a517afe6dd10dbf3",
|
||||
"#doc-actions-footer #doc-copy-export-split": "70b267bc3460ba24",
|
||||
"#doc-actions-footer #doc-footer-copy-btn": "b339e77a5e9730c1",
|
||||
"#email-lib-compose-btn": "3b21baa4a3d6bec8",
|
||||
"#email-lib-compose-btn svg": "6decb15adb02a12b",
|
||||
"#email-lib-modal .admin-card": "fb6ad83c8330d140",
|
||||
"#email-lib-modal .doclib-grid": "99515ed7d3c12dc8",
|
||||
"#email-lib-modal .email-lib-fab.fab-revealed": "9668afab75e5e45d",
|
||||
"#email-lib-modal .email-lib-sync-status": "6d1a0374a16cf7f4",
|
||||
"#email-lib-modal .email-reader-atts": "c5d68ecda3c14bb8",
|
||||
"#email-lib-modal .email-reader-header": "85afca11c8cbd38e",
|
||||
"#email-lib-modal .modal-content": "d8b8fff38c613c95",
|
||||
"#email-lib-settings-btn": "9ae15bf04b6a22ce",
|
||||
"#email-unread-dot.sidebar-notif-dot": "c80f1e03592983b6",
|
||||
"#export-compact-btn": "7d0325a38e51134b",
|
||||
"#gallery-editor-drafts-bulk": "9d8c00a765d6689e",
|
||||
"#gallery-editor-drafts-select": "694fb627310c9295",
|
||||
"#ge-history-panel": "26ec3f6b3e6d2751",
|
||||
"#hwfit-cache-select": "62ab492dd8fad73d",
|
||||
"#memory-modal .doclib-card.skill-card.doclib-card-expanded > .doclib-card-preview": "10834da1800d529b",
|
||||
"#message": "018b0ab2128d9d34",
|
||||
"#modal-dock": "bc81bafabc09440c",
|
||||
"#mode-agent-btn": "5cccc6edda975b2a",
|
||||
"#mode-chat-btn": "77af83dd6bfcd26c",
|
||||
"#recording-indicator": "e856ac42970d1a8c",
|
||||
"#recording-indicator.error": "8a1bcc599c3c237b",
|
||||
"#recording-indicator.hidden": "c8b3476b1107b2e2",
|
||||
"#research-pane": "e465bba8b1555f51",
|
||||
"#research-pane #research-past-list .research-job-card": "721dde2eb52cb7ed",
|
||||
"#research-pane #research-past-list .research-job-header": "c4d23f4b8bc102a8",
|
||||
"#research-pane #research-past-list .research-job-more": "414c2b05ad7e45e0",
|
||||
"#research-pane .research-history-card": "e9d829f0fda26f94",
|
||||
"#research-pane .research-history-toolbar-row": "33ce111a10432f3b",
|
||||
"#research-pane .research-history-toolbar-row .memory-sort-select": "6054d616490a49eb",
|
||||
"#research-pane .research-pane-body": "7e10ca158f86d408",
|
||||
"#session-select-all-dot": "d72b24787bef1c6a",
|
||||
"#session-sort-btn": "bb0f88ce29ae7637",
|
||||
"#sessions-section": "02a1d6939b9ef639",
|
||||
"#sessions-section #session-list": "5f7dfc0825eec2ee",
|
||||
"#sessions-section .chats-manage-btn": "63fe8c42ed236f21",
|
||||
"#sidebar-backdrop": "f46fd74d98873484",
|
||||
"#stop-recording": "d5489a601cfabf69",
|
||||
"#styled-confirm-overlay": "64466ea74bcf53ed",
|
||||
"#theme-popup": "f22c8010b180b42d",
|
||||
"#theme-tab-customize .color-row": "e3721ab86ea18eef",
|
||||
"#theme-tab-customize .theme-custom": "9b8fe83f16e039ee",
|
||||
"#welcome-screen": "7024d434f65886c9",
|
||||
"#welcome-screen .welcome-name": "01cffb1d086a1ab3",
|
||||
"#welcome-screen .welcome-sub": "89cdafbb95b7c845",
|
||||
"#welcome-screen .welcome-tip": "875045a9d2ec1055",
|
||||
"#welcome-screen .welcome-version": "3a850e9283976c34",
|
||||
".admin-btn-add": "8a933cbf8ac270ce",
|
||||
".admin-btn-sm": "1419bf7301e47b66",
|
||||
".admin-card": "0ae3d86c8cfeb751",
|
||||
".admin-model-form-row input": "1d820446629f79c4",
|
||||
".admin-slider": "836a69285985c958",
|
||||
".admin-switch": "876abcc0f10c2d55",
|
||||
".agent-thread": "f6198eddb1a9969f",
|
||||
".agent-thread-cmd": "54d5ece7fa9de68c",
|
||||
".agent-thread-content": "c60aadf77a2a5883",
|
||||
".agent-thread-dot": "d59e25a8c69eeac4",
|
||||
".agent-thread-header": "a336602ef4d31c1c",
|
||||
".agent-tool-diff summary": "4128de76cb424c63",
|
||||
".agent-tool-output pre": "530700f4a0699704",
|
||||
".archive-list": "a180dd2654ef8229",
|
||||
".archive-menu-btn": "9f508f063b881978",
|
||||
".ask-user-card": "f2dee20264814c84",
|
||||
".ask-user-card-attached": "e300c7fb5bc70b1f",
|
||||
".ask-user-option": "531ea450e6af3121",
|
||||
".ask-user-option-desc": "22de16b97f42ea38",
|
||||
".ask-user-option-label": "f555c9bd471d5421",
|
||||
".ask-user-other": "0b2855d42c5d7f32",
|
||||
".ask-user-other-send": "2144fc24a8bf91c2",
|
||||
".ask-user-question": "05a91b0c244960fc",
|
||||
".attach-image-preview": "b5dd397be5ad69aa",
|
||||
".attach-ocr-btn": "b0d7e3be6781d365",
|
||||
".attach-ocr-btn svg": "8b09b0b7140aae98",
|
||||
".attach-strip": "a6474951e2481214",
|
||||
".btn-spinner": "14215771d1d171fb",
|
||||
".cal-add-label": "72fd3ef86c3bbfcb",
|
||||
".cal-add-plus": "a1b0414e63b6ac40",
|
||||
".cal-day": "4ca6a688a40d4db2",
|
||||
".cal-day-num": "a4745da9f0b703bc",
|
||||
".cal-detail-header": "c81d6378318b5cb9",
|
||||
".cal-event-item": "e631fe656b59f308",
|
||||
".cal-event-row": "8dca80590c370f70",
|
||||
".cal-filter-item": "131d083db2c9c116",
|
||||
".cal-filter-toggle": "757cfe6e18bc90eb",
|
||||
".cal-filters": "0e423e393699903b",
|
||||
".cal-form-bespoke": "91129760df1ca566",
|
||||
".cal-form-mobile-cancel": "bde3b168ed685bb0",
|
||||
".cal-grid": "92c99ce3581e92c9",
|
||||
".cal-hero-time": "7474b3c587ca5d98",
|
||||
".cal-modal-container": "3ed41f999d50bd8f",
|
||||
".cal-multiday": "bc5ae2259e9db38a",
|
||||
".cal-quickadd-row": "b90bdc13d6453de7",
|
||||
".cal-splitter": "871cd8b0eae95d23",
|
||||
".cal-splitter-grip": "73e88032078aac16",
|
||||
".cal-title": "162d427104c49b46",
|
||||
".cal-toolbar": "77d67b21b75d89fc",
|
||||
".cal-toolbar-nav": "855c82a6eb672a85",
|
||||
".cal-toolbar-right": "b6059b5f1a77c077",
|
||||
".cal-wk-block": "5763c2f0c31ff3d8",
|
||||
".cal-wk-block-name": "edc7443f13567025",
|
||||
".cal-wk-block-time": "2fe325de785343a0",
|
||||
".cal-wk-wrap": "de61e4d3f59cf949",
|
||||
".cal-wk-zoom": "e5edf7c699287e58",
|
||||
".cal-year": "76d643a419e7656e",
|
||||
".cal-year-cell": "25a1642b5b698c0b",
|
||||
".chat-container": "b00a7c6be4e107b1",
|
||||
".chat-container.welcome-active .chat-input-bar": "c6510a7083e92ce8",
|
||||
".chat-history": "4dd22e235be04a73",
|
||||
".chat-input-bar": "fa08ce1f4b0cdb46",
|
||||
".chat-meta-overlay": "fc44623332db5eac",
|
||||
".chat-meta-overlay #current-meta": "1ae4281cd2a008d4",
|
||||
".chat-top-bar": "28bc79270a878362",
|
||||
".close-btn": "9c3b6334c7b25fee",
|
||||
".compare-add-flap": "bcc20ba15ee5b0ed",
|
||||
".compare-add-flap span": "b40d213f00c806d3",
|
||||
".compare-grid": "f6f69aa41d74c616",
|
||||
".compare-grid.sequential-layout .compare-pane .pane-header": "235ba192ba9c7f3e",
|
||||
".compare-mobile-tabs": "62c5cbb34f339509",
|
||||
".compare-mode-tabs": "e2177d8b26c19145",
|
||||
".compare-pane .pane-header": "07ab4c430c694f8a",
|
||||
".compare-probe-card": "06a67cf386d5bdec",
|
||||
".compare-probe-detail": "db787590d19ef4cc",
|
||||
".compare-probe-detail-message": "57eeaebb2a9b0a4f",
|
||||
".compare-probe-list": "78deccee6c00025e",
|
||||
".compare-toggle-label": "e200b7dff04bf81b",
|
||||
".confirm-btn": "a8518c6834fb7ac0",
|
||||
".cookbook-body": "1a10500fe1e306f0",
|
||||
".cookbook-btn": "40ad2e8df76f2d9c",
|
||||
".cookbook-card-desc": "f6ef9079e5e15a2d",
|
||||
".cookbook-card-title": "3df0f721555953a0",
|
||||
".cookbook-checkbox-label": "aac8b53d01d979cf",
|
||||
".cookbook-cmd-preview": "c565845df485518e",
|
||||
".cookbook-dep-build-deps": "15280be2979ad180",
|
||||
".cookbook-dep-row": "57e3a98d97f7b9c8",
|
||||
".cookbook-dep-tag": "97d68d0162f39744",
|
||||
".cookbook-dl-btn": "cc7fe1bb0c79630a",
|
||||
".cookbook-dl-repo": "50b7fc43bce8f35c",
|
||||
".cookbook-field-input": "a12fc90dcf7e9de3",
|
||||
".cookbook-field-label": "afc196d6dccbbc6f",
|
||||
".cookbook-gpu-btn": "52b45862dd394f30",
|
||||
".cookbook-gpu-btn.active": "5bff77f9adcb7448",
|
||||
".cookbook-official-filter": "6bdea60315f7d0da",
|
||||
".cookbook-output-pre": "40fc0611f762b039",
|
||||
".cookbook-run-btn": "c816359f8655bef2",
|
||||
".cookbook-saved-host": "cf6b4983fa9bebd0",
|
||||
".cookbook-saved-name": "d434036dd310ca6a",
|
||||
".cookbook-section-header .cookbook-clear-btn": "e19b1a13059aa249",
|
||||
".cookbook-section-title": "c7c5399d03a5b9c2",
|
||||
".cookbook-serve-preset-meta": "1e2786a24b30d019",
|
||||
".cookbook-serve-preset-name": "abf2efbb491f8eb9",
|
||||
".cookbook-serve-title": "dbc48ae1de4db056",
|
||||
".cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-dot": "ff38b51b211440c0",
|
||||
".cookbook-settings-hint": "cc67f7db1e73911c",
|
||||
".cookbook-settings-input": "a6e3b4a498232fcb",
|
||||
".cookbook-settings-label": "9f811c48f43831c3",
|
||||
".cookbook-settings-stack": "eb21f8352c23e9d0",
|
||||
".cookbook-slot-btn": "185943b4d681c18e",
|
||||
".cookbook-tab": "cbac98e770f1b5c7",
|
||||
".cookbook-tab-error-dot": "083ff5e013f0f752",
|
||||
".cookbook-task-check": "203f3f00e300b69a",
|
||||
".cookbook-task-header": "17633a41a71fb15c",
|
||||
".cookbook-task-menu-btn": "fbec9d85e73f99c2",
|
||||
".cookbook-task-name": "b5e36c1e6dfc2196",
|
||||
".cookbook-task-status": "832d485fa467a2c9",
|
||||
".cookbook-task-sub": "e67555ec0e2edcfb",
|
||||
".cookbook-task-wave": "8fdc66c19c2f2169",
|
||||
".cp-popover": "a2aa1ac1ca1ba392",
|
||||
".cp-sl": "3332f2879d861c24",
|
||||
".diff-chunk-btn": "31f68158a063bfc1",
|
||||
".diff-toolbar": "28e32a3239b7b603",
|
||||
".diff-toolbar-btn": "14e8f6a294cf4088",
|
||||
".doc-action-icon-btn": "392100f636cc0f05",
|
||||
".doc-docx-paper": "c6d5777d00f18064",
|
||||
".doc-docx-preview": "a25a0a2fc4de255d",
|
||||
".doc-editor-header": "253267e87c976100",
|
||||
".doc-email-collapse-btn": "417a42c5c91f9a22",
|
||||
".doc-email-header": "0cf44218df62de2a",
|
||||
".doc-email-richbody.doc-font-m": "613a4e9d172bb44d",
|
||||
".doc-language-select": "90a29013c434753c",
|
||||
".doc-line-numbers": "a7aa45b481331f5b",
|
||||
".doc-md-toolbar": "cf5013815486fa9b",
|
||||
".doc-md-toolbar button": "7790f81a78a6e4cb",
|
||||
".doc-mobile-footer": "b2ed3a84835d4916",
|
||||
".doc-outline-item": "5e404c8d680cfc69",
|
||||
".doc-outline-menu": "f29f6f5a8e53fd57",
|
||||
".doc-overflow-item": "d10da3bab3e43ce4",
|
||||
".doc-preview-hover-edit": "b78f58de2e582325",
|
||||
".doc-rich-empty-import": "7ee18d60aed92aab",
|
||||
".doc-rich-selection-toolbar": "4f6431c0ab8b4b08",
|
||||
".doc-rich-selection-toolbar .doc-rich-selection-close": "1df07aa1a7ea60b7",
|
||||
".doc-rich-selection-toolbar button": "73704ff1c15f0013",
|
||||
".doc-rich-slash-item": "d8c5cbb3e3e3e5d6",
|
||||
".doc-rich-slash-menu": "2e31f63219c116aa",
|
||||
".doc-save-button[data-save-state=\"saving\"] .doc-save-state-saving": "84ecc420b557e35d",
|
||||
".doc-selection-clear": "b0d9428152a09e39",
|
||||
".doc-suggestion-card": "1b4f2e6dc6aa2ec6",
|
||||
".doc-suggestion-close": "8fbd4d5f6ba460b6",
|
||||
".doc-suggestion-nav-btn": "befb117a2fd3fa7d",
|
||||
".doc-tab": "8f563b5e946c20be",
|
||||
".doc-tab-bar": "0b65732476210fd4",
|
||||
".doc-tab-close": "6c1e0e8793444210",
|
||||
".doc-tab-lang": "8b0efacad728c610",
|
||||
".doc-tab-new": "def25060720b95fc",
|
||||
".doc-version-panel": "1073efb40f164f6b",
|
||||
".doclib-btn-hint": "1e7b736920f0afa7",
|
||||
".doclib-card": "6771693fee18c8b3",
|
||||
".doclib-card-action-btn": "4e2f069505c5dbb5",
|
||||
".doclib-card-expanded-actions": "33a86d25e6b02b9c",
|
||||
".doclib-card-header": "af0f97b1f3004360",
|
||||
".doclib-card-preview": "2c6e654086abaa34",
|
||||
".doclib-card.doclib-card-expanded": "1ecfff38fa7abc2c",
|
||||
".doclib-card.email-card-removing": "e379729bdba2a0eb",
|
||||
".doclib-chat-row.doclib-card-expanded": "dfd424102537f179",
|
||||
".doclib-chat-row.doclib-card-expanded .doclib-chat-preview": "dabb220508f9eae3",
|
||||
".doclib-chat-row.doclib-card-expanded .doclib-chat-preview-messages": "d9e01bfd21a07b37",
|
||||
".doclib-chip-scroll-arrow": "40757af6c9e795fb",
|
||||
".doclib-expanded-delete-btn": "bb0f88ce29ae7637",
|
||||
".doclib-grid": "34c18c37757a199f",
|
||||
".doclib-lang-chips": "6d351e1534d7b081",
|
||||
".doclib-toolbar": "dc9ec6073f2c3605",
|
||||
".dropdown": "bed9ac24c6696903",
|
||||
".dropdown-cancel-mobile": "e3b3786fda598403",
|
||||
".dropdown-divider": "5738d6943e2b555b",
|
||||
".dropdown-item": "5c8fd729a604d69a",
|
||||
".dropdown-item-compact": "984fa882578bfc56",
|
||||
".dropdown-item-compact .dropdown-icon": "b75dab733d89dbc1",
|
||||
".dropdown-item-compact .dropdown-icon svg": "b39764818e19ca4c",
|
||||
".email-attach-toggle-inline": "18470eba1fd88166",
|
||||
".email-attachment-chip.is-expanded": "833a4ff805e57c88",
|
||||
".email-attachment-open": "b2e7d5021587cf3b",
|
||||
".email-bubble": "84c884b461b7bfd6",
|
||||
".email-bubble-avatar": "642766e3dbe32509",
|
||||
".email-card-unread-dot": "33dea788cbd97824",
|
||||
".email-compose-chip .compose-chip-name": "58ca58105c731e0b",
|
||||
".email-draft-btn": "f256eb68fc8b17e3",
|
||||
".email-field": "981e2a2bef7d2401",
|
||||
".email-field #doc-email-subject": "0864bcaf3b3dffb0",
|
||||
".email-field input": "b4b0542ee0ce5b2a",
|
||||
".email-field label": "c6076082752aed3e",
|
||||
".email-filter-btn": "cc290eac8042b6f7",
|
||||
".email-filter-menu": "dc5d1dfacdd26733",
|
||||
".email-filter-picker": "9ae15bf04b6a22ce",
|
||||
".email-inline-image-actions": "891b1549d5e6a2ab",
|
||||
".email-inline-image-skeleton": "0d20c32f79dbe7f7",
|
||||
".email-odysseus-attach-list": "fc3cf3fa0fe725dd",
|
||||
".email-odysseus-attach-menu": "ca6e3ea838c14777",
|
||||
".email-quote-fold .email-fold-summary": "38395d57d7800777",
|
||||
".email-reader": "bb0f88ce29ae7637",
|
||||
".email-reader-body": "8ce048cdcb667db7",
|
||||
".email-reader-body .email-inline-image-placeholder": "deebf724fa3a2a5d",
|
||||
".email-reader-header": "066a5ffcb140bbc9",
|
||||
".email-reader-header > .email-reader-actions": "0331e25e809e0d0b",
|
||||
".email-reader-meta": "6d84db440dcf1dcf",
|
||||
".email-reader-meta .recipient-chips": "c9ceda20ba24a94b",
|
||||
".email-reader-meta > .email-reader-actions-inline": "06ecc1c60b199e44",
|
||||
".email-reader-meta-row strong": "0af50deb999967c3",
|
||||
".email-reminder-toggle-inline": "18470eba1fd88166",
|
||||
".email-search-options-menu button": "f112d02b7f4b2a3f",
|
||||
".email-search-row .memory-search-input": "f5edf94bbb3a2730",
|
||||
".email-send-btn": "8cd36039dde7b23a",
|
||||
".email-settings-auto-reply-section .email-settings-section-body": "a09c1e68706d8dc3",
|
||||
".email-settings-auto-reply-section .email-settings-section-desc": "8d8ce3cc398fc912",
|
||||
".email-settings-auto-reply-title-row": "bfc6bb8acd83b4c3",
|
||||
".email-settings-clean-btn": "398f211c2e62b7be",
|
||||
".email-settings-display-section .email-settings-section-desc": "f540b150a9ba3b16",
|
||||
".email-settings-grid": "eee5ec449a8f214e",
|
||||
".email-settings-section[open] > .email-settings-section-body": "88b6ae868ea5a387",
|
||||
".email-tags-toggle-inline": "8b5bcb3c17df370e",
|
||||
".email-undone-toggle-inline": "18470eba1fd88166",
|
||||
".export-dl-btn": "fc2a9d706e560a26",
|
||||
".export-dropdown-item": "8ec724a091aa55c8",
|
||||
".export-dropdown-item .dropdown-icon": "4e597b835e44680b",
|
||||
".export-dropdown-menu": "11a65ef24743b950",
|
||||
".gallery-album-chips": "9c85df6af61b4447",
|
||||
".gallery-albums-grid": "19900300e1bee43f",
|
||||
".gallery-chip": "3df2d7f6ea8bf569",
|
||||
".gallery-detail-body": "2ccc994e7a9fc0bc",
|
||||
".gallery-detail-image": "bb4a5fa517c1d077",
|
||||
".gallery-detail-meta-grid": "0804ee3dd5a92578",
|
||||
".gallery-detail-nav": "265fadf513a58d78",
|
||||
".gallery-detail-sidebar": "b47067b4c540d312",
|
||||
".gallery-detail-title": "3a61a73ed392c545",
|
||||
".gallery-dl-btn": "afa5e7125e483700",
|
||||
".gallery-editor-draft-delete": "501e6db6e97bd5fc",
|
||||
".gallery-editor-drafts": "e28e1aa9423d297b",
|
||||
".gallery-editor-drafts-header": "14467c19ae89fd16",
|
||||
".gallery-editor-drafts-search": "456f4e599d692064",
|
||||
".gallery-editor-drafts-title": "07556469d7e5bfcd",
|
||||
".gallery-editor-landing": "a5ae5067720f044d",
|
||||
".gallery-editor-landing-actions": "dec9a0fd207ba1bc",
|
||||
".gallery-editor-landing-actions .gallery-select-btn": "c92cc6790f517732",
|
||||
".gallery-editor-template-options": "9b8c6bae4cc423a7",
|
||||
".gallery-editor-template-picker": "0a04414510047d6f",
|
||||
".gallery-grid": "9193704237593d45",
|
||||
".gallery-search": "499b1d5db75d8637",
|
||||
".gallery-search-enter-hint": "59988183b5e2231e",
|
||||
".gallery-search-wrap": "a3a65cb89f837b1a",
|
||||
".gallery-select-btn": "6062fab15408c8d2",
|
||||
".gallery-tab": "856ba5e8322b2ac3",
|
||||
".gallery-tabs": "6e77ea3e6cf981c9",
|
||||
".gallery-toolbar": "cbee92b65715f862",
|
||||
".gallery-toolbar-break": "b2ed3a84835d4916",
|
||||
".ge-adj-icon": "405c77944c2f21c7",
|
||||
".ge-adj-popup": "e1a93233ab11f043",
|
||||
".ge-adj-row": "fed828d535ba710b",
|
||||
".ge-adj-row > label": "21bd09c7cf6167e5",
|
||||
".ge-adj-row input[type=\"range\"]": "3f1690aad291a7da",
|
||||
".ge-adj-title": "7609b8e4f862ad23",
|
||||
".ge-ai-command-collapsed": "35a892b0246538ba",
|
||||
".ge-alpha-badge": "59eb1bb7eddf6df0",
|
||||
".ge-canvas-area": "b6a7426fcd6ad230",
|
||||
".ge-canvas-prompt": "97ddee153cdaf1f2",
|
||||
".ge-canvas-prompt-footer": "153de6a15c89d60d",
|
||||
".ge-clone-hint-mobile": "fde6e603285602e2",
|
||||
".ge-controls": "004ef864b98d552f",
|
||||
".ge-edge-menu": "6734a5a4c658fb59",
|
||||
".ge-editor-body": "96e859c8c9bfe4f9",
|
||||
".ge-export-body": "9746be0da055b9cf",
|
||||
".ge-export-dialog": "07273df75fec40a5",
|
||||
".ge-export-overlay": "71118be46a543136",
|
||||
".ge-export-preview": "d15f3deb0fea3bca",
|
||||
".ge-export-preview-wrap": "022e7646bf83adb1",
|
||||
".ge-fx-menu": "34ba0908e3d27a7d",
|
||||
".ge-gradient-color-row": "ae1866697d4fb492",
|
||||
".ge-inpaint-popup": "375180aa2e257450",
|
||||
".ge-inpaint-popup-input": "82d3fa4cb683d69e",
|
||||
".ge-layer-action-flash": "3adf53342ca69b3f",
|
||||
".ge-layer-drag": "bec7d2f34c0e079b",
|
||||
".ge-layer-geometry-grid": "d478f4137955e85a",
|
||||
".ge-layer-group-row .ge-group-inline-thumb": "bb0f88ce29ae7637",
|
||||
".ge-layer-group-row .ge-layer-controls": "da1a0cafc90aff7f",
|
||||
".ge-layer-group-row .ge-layer-group-drag": "33cf63103dbea379",
|
||||
".ge-layer-group-row .ge-layer-group-name": "48642695eec50f01",
|
||||
".ge-layer-group-row .ge-layer-group-toggle": "cb04950c0a50aacb",
|
||||
".ge-layer-group-row .ge-layer-lock-btn": "bb0f88ce29ae7637",
|
||||
".ge-layer-group-row .ge-layer-opacity-row": "e697b236b3b4ba6e",
|
||||
".ge-layer-group-row .ge-layer-vis": "2e6dbbab8d823b4f",
|
||||
".ge-layer-lock-menu": "528deb6e31022e89",
|
||||
".ge-layer-lock-option": "38a3da4a8785330a",
|
||||
".ge-layers-grab": "094e764489b1b069",
|
||||
".ge-layers-header": "459d17627f2e83e6",
|
||||
".ge-layers-list": "0b4bfbd17d414d6d",
|
||||
".ge-main-canvas": "274650d62945ac12",
|
||||
".ge-mask-sub-item": "546f03f9c46eb507",
|
||||
".ge-right-panel": "3d0aed961f2df331",
|
||||
".ge-section-help": "8bd5ecd734f48ca2",
|
||||
".ge-shortcuts-grid": "84d3ab6453dab9b3",
|
||||
".ge-slider-bubble": "2bc54e40357f68cf",
|
||||
".ge-tool-btn": "fd54cca726e51429",
|
||||
".ge-tool-sep": "4ae61e4e4eddd504",
|
||||
".ge-toolbar": "ca827cf012ad8863",
|
||||
".ge-topbar": "6c39a6ade412d5fa",
|
||||
".ge-transform-field": "76a785f630632652",
|
||||
".ge-transform-field > input.ge-transform-popup-input": "b870e4e9d5fc2d91",
|
||||
".ge-transform-popup-body": "d9d70292e5be7acc",
|
||||
".ge-transform-popup-head .ge-adj-icon": "88ac29d44343e6fa",
|
||||
".ge-transform-popup-head .ge-adj-title": "1d6ca66f2c39c9ef",
|
||||
".ge-transform-popup-head .ge-head-btns": "ecd0a09cb27407d7",
|
||||
".ge-transform-popup-hint": "402aac74aea3c779",
|
||||
".ge-transform-popup-suffix": "d55491303f919f61",
|
||||
".ge-transform-quick-btn": "e74ea16e29b9b001",
|
||||
".ge-transform-spin": "22dc5f2bfab2ec9a",
|
||||
".ge-transform-spin button": "527810c9f1ac1be5",
|
||||
".ge-transform-spin button[data-spin=\"down\"]": "c0aff9fcdb755ed2",
|
||||
".ge-transform-spin button[data-spin=\"up\"]": "4fccf8fbe6c04fa9",
|
||||
".hamburger": "2b0e4a6cb3333ded",
|
||||
".hamburger span": "6e2fee4770672a20",
|
||||
".hamburger-btn": "dc0f91fb82ccd472",
|
||||
".hljs": "6ecc4f2f88eac570",
|
||||
".hwfit-cached-item .hwfit-serve-panel": "14fffc9c4952ddd5",
|
||||
".hwfit-cached-menu-btn svg": "f7ad8ff47d5e5a9e",
|
||||
".hwfit-context-calc-btn": "30ab84fd32699c1a",
|
||||
".hwfit-context-control": "31f335a5f5f7093b",
|
||||
".hwfit-context-control .hwfit-sf[data-field=\"ctx\"]": "c4367757132248ae",
|
||||
".hwfit-ctx-control": "d175d7225fe97089",
|
||||
".hwfit-ctx-control input[type=\"range\"]": "947b1970077d4f4a",
|
||||
".hwfit-ctx-control output": "9a1f476037c00dc2",
|
||||
".hwfit-ctx-control span": "66e420440d60beee",
|
||||
".hwfit-help-chip": "d51bfab71de7b80e",
|
||||
".hwfit-hw-chip-toggle": "e0e58ca582f813cd",
|
||||
".hwfit-numstep": "0ec0a0c4db53c2cd",
|
||||
".hwfit-panel-fields": "4e6d730d4a665d2e",
|
||||
".hwfit-panel-fields input[type=\"text\"]": "caeeb82204c15e3e",
|
||||
".hwfit-preset-model": "f86026efe0d3498c",
|
||||
".hwfit-sched-day-chip": "0a2f0ddb74896f9f",
|
||||
".hwfit-schedule-row": "ee03e04722554137",
|
||||
".hwfit-serve-cmd-details[open] > .hwfit-serve-cmd-summary": "4952b4e93e125c16",
|
||||
".hwfit-serve-preset-row": "c6c4d6a2f3c3e6c3",
|
||||
".hwfit-serve-row .cookbook-serve-slots": "a7be413a7490cc01",
|
||||
".hwfit-serve-row-core .hwfit-context-label": "a0b969fbae15de72",
|
||||
".hwfit-serve-topline": "d9feede09cc16f87",
|
||||
".hwfit-serve-topline .hwfit-serve-runtime-note": "15b3fef4e5ceaffc",
|
||||
".hwfit-serve-topline .hwfit-serve-runtime-text": "57c6822b0d13147d",
|
||||
".hwfit-spec-group .hwfit-spec-method": "5d1c329116272cf1",
|
||||
".icon-rail": "e3f72205260a4a5f",
|
||||
".icon-rail-btn": "a5f207c3be15401e",
|
||||
".incognito-btn": "9833a2e86fab6a94",
|
||||
".incognito-indicator": "b3945c46844c7278",
|
||||
".input-icon-btn": "2e073a8e266f201e",
|
||||
".item-drag-handle": "5f889f0059232495",
|
||||
".list-item": "0653eb1e34c6b7d1",
|
||||
".list-item .hamburger": "2092eaea70b49de6",
|
||||
".loading-dot": "686e1bb53f792ac5",
|
||||
".loading-dots": "997b347b61484fc0",
|
||||
".loading-indicator": "eb9f3c301fbb4976",
|
||||
".md-scroll-arrow svg": "6f1ae7652f2dd51a",
|
||||
".md-toolbar-items #doc-pdf-add-text-btn": "950a49c2ef935d9e",
|
||||
".md-toolbar-sep": "146c50b38436d02a",
|
||||
".md-view-toggle": "b092a4e770904171",
|
||||
".md-view-toggle .md-view-opt": "3192fcae7d1f0ab6",
|
||||
".md-view-toggle .md-view-opt.active": "3ee0bf92dfab3072",
|
||||
".memory-item": "85cccfa273eae409",
|
||||
".memory-item-actions": "8642d181c8fe3c2a",
|
||||
".memory-item-actions .memory-item-btn": "7a9214669a7cc7b6",
|
||||
".memory-item-title": "d50a89fe377bbe0d",
|
||||
".memory-menu-btn": "d7378a97a127f815",
|
||||
".memory-tab-panel[data-memory-panel=\"skills\"]": "f18376de38243808",
|
||||
".memory-toolbar": "9285742f00c21d49",
|
||||
".memory-toolbar-btn": "a67a54d4043e34ab",
|
||||
".minimize-btn": "d22769aec9c30099",
|
||||
".minimized-dock-chip": "af70d8f0eb594924",
|
||||
".minimized-dock-chip svg": "5f94d9cfc3935c34",
|
||||
".mobile-new-chat-btn": "8505c445195e7b15",
|
||||
".modal": "9345624780009d14",
|
||||
".modal-content": "e5b005cecef5d140",
|
||||
".modal-content.modal-closing": "8bdccec5cd43b27b",
|
||||
".modal-header": "2c8f003ed4b092ad",
|
||||
".mode-toggle": "43526722c32158c5",
|
||||
".mode-toggle-btn": "08df82025829cf7b",
|
||||
".mode-toggle.mode-toggle-three [data-llama-mode=\"unified\"] > span": "adf60064ba19f5de",
|
||||
".model-picker-list .model-switch-item": "9077ef9414c73080",
|
||||
".model-picker-list .mp-fav-dot": "cde6ff8162ad99db",
|
||||
".models-row": "d882bd9517efbca7",
|
||||
".msg": "e874630644527764",
|
||||
".msg .body": "454c7e06516e1201",
|
||||
".msg .role": "2aa3fc47cc98be04",
|
||||
".msg-action-btn[data-action=\"shorten\"]": "39875219ab81b49e",
|
||||
".msg-actions": "015e01ce5213f013",
|
||||
".msg-ai": "ca278b97f3b29560",
|
||||
".msg-user": "3964d2a2b7460aa6",
|
||||
".note-card": "1f346ea173c883c6",
|
||||
".note-card-colors": "329743d4521bb459",
|
||||
".note-card-copy-corner": "7759db4e507df9aa",
|
||||
".note-card-corner-copy": "0874439a31308004",
|
||||
".note-card-corner-copy svg": "c514aac06948c53e",
|
||||
".note-card-done": "c1c08dfe20a2319f",
|
||||
".note-card-edit-corner": "28358acd0d495d56",
|
||||
".note-card-image": "2d43d9998c0054e2",
|
||||
".note-check-text": "bf79d1927ab1354c",
|
||||
".note-color-blue": "a639c83faed486a5",
|
||||
".note-color-green": "2ec9b93d7e537274",
|
||||
".note-color-orange": "d6b28667f64299ed",
|
||||
".note-color-purple": "09413154ae0539b7",
|
||||
".note-color-red": "3578124008036b4c",
|
||||
".note-color-yellow": "0375be5e8a17c052",
|
||||
".note-form-actions-group": "01325dedac4f2135",
|
||||
".note-form-header": "981e2a2bef7d2401",
|
||||
".note-form-meta .note-form-label": "4523cb29977907ee",
|
||||
".note-form-remind-btn": "3d8e742995aab275",
|
||||
".note-form-type-pill": "c2e1cdc092ce35d2",
|
||||
".note-fullscreen-overlay .note-cl-add-row .note-cl-add": "6c5a9e646b44c5ad",
|
||||
".notes-header-btn-label": "6756fa87daa244e9",
|
||||
".notes-header-text-btn": "0bc6208299bf6f01",
|
||||
".notes-pane-title": "66bcc3f992df720f",
|
||||
".notes-pane.notes-view-grid .note-card": "a327d50c6b0bf852",
|
||||
".notes-pane.notes-view-grid .note-form": "f393ab7b10692ea2",
|
||||
".notes-pane.notes-view-grid .notes-labels-bar": "71aff777c51126c6",
|
||||
".notes-pane.notes-view-grid .notes-pane-body": "65a4c9d6644c1c37",
|
||||
".notes-pane.notes-view-grid .notes-quick-add": "87b5d77fec70f8bd",
|
||||
".notes-quick-add": "b56d9585019420b5",
|
||||
".notes-quick-icon": "83f7f73487cc23da",
|
||||
".notes-quick-input": "8760b2b8588274fd",
|
||||
".notes-quick-type-pill": "55c9f6527ebc8b3d",
|
||||
".notes-quick-type-pill svg": "a73e69725b9d97b6",
|
||||
".notes-quick-type-seg": "de7a4f92c908db4e",
|
||||
".pdf-export-overlay .modal-content": "3b5f35912509daf2",
|
||||
".preset-btn": "11527aa662becc84",
|
||||
".preset-btn.active": "2fc1631701e917c2",
|
||||
".preset-range": "6c05bf7f8a880758",
|
||||
".private-browser-preview-frame img": "b1866aa1b67e4626",
|
||||
".reasoning-effort-prefix": "bb0f88ce29ae7637",
|
||||
".recipient-chip": "f88380e8f4c09cc5",
|
||||
".recipient-chips": "83fad85374d3c475",
|
||||
".recording-content": "d5e0c053ceaf4f29",
|
||||
".recording-error": "5a2f9e8095e1b45e",
|
||||
".recording-icon": "cb60d94934ee919a",
|
||||
".recording-text": "58e99b27946bafd6",
|
||||
".red-text": "a93f1ed45d8646c0",
|
||||
".research-badge": "fcf661967461fc70",
|
||||
".research-cat": "a523b00cdffd4546",
|
||||
".research-category-row": "09701efa1598810c",
|
||||
".research-job-action[data-action=\"delete\"]": "d1bf97c182a9a746",
|
||||
".research-job-card": "c71a3e1a36a8c265",
|
||||
".research-new-job h2": "5e67de78656d65dc",
|
||||
".research-sb-dot": "3fd3b71cb6c521ce",
|
||||
".research-start-btn": "89df2d121b24f52b",
|
||||
".research-synapse .rs-meta": "59320709dd4454db",
|
||||
".research-synapse .rs-meta .rs-status": "2e1a6f4a3fda05dc",
|
||||
".scroll-nav-btn": "436360e8d744c897",
|
||||
".search-result-snippet": "fac8292c8f391904",
|
||||
".section": "37fc733d59519a08",
|
||||
".section-header-btn": "5d949e304c239db4",
|
||||
".section-header-flex": "bfcb656a86ca5a6a",
|
||||
".send-btn": "b29fc333cc66ed9d",
|
||||
".send-btn.anim-launch svg": "25d7e30ad49d3374",
|
||||
".session-bulk-bar": "a2833fd7ce35e639",
|
||||
".session-bulk-btn": "827a6bb9524080c9",
|
||||
".session-loading-state": "95adb7c1b1e67832",
|
||||
".session-menu-btn": "43014a6ecb68472a",
|
||||
".session-skeleton-bubble": "23878c71363d5db7",
|
||||
".session-skeleton-bubble.is-user": "b0e090855a4865be",
|
||||
".session-skeleton-line": "05960e43aaa5fa1c",
|
||||
".settings-layout": "9e37e5723ec05246",
|
||||
".settings-nav-item": "1db63b881ad2eba4",
|
||||
".settings-panels": "728f16c852c993b6",
|
||||
".settings-row": "1a84bec9dc21c226",
|
||||
".settings-select": "13a40c3922ce8015",
|
||||
".settings-sidebar": "2b39df3565e83790",
|
||||
".settings-sidebar-content": "88544f44fdb2ca73",
|
||||
".settings-sidebar-divider": "cdadb5f574b44236",
|
||||
".settings-sidebar.settings-sidebar-collapsed": "d9d8f25df95d9ad8",
|
||||
".settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content": "608e51170864586a",
|
||||
".settings-system-logs-tab": "d0d90a13ff327ace",
|
||||
".sidebar": "77ec42a646a8db66",
|
||||
".sidebar .section": "37fc733d59519a08",
|
||||
".sidebar-brand-title": "2b20f75768b50346",
|
||||
".sidebar-header": "0d58816ca65b7216",
|
||||
".sidebar-inner": "ad5e28fedbfeb5db",
|
||||
".sidebar-notif-dot": "2673e215d0cd84a6",
|
||||
".sidebar-user-bar": "b77372dbdcb069ef",
|
||||
".sidebar.hidden": "0df301b638e2f93f",
|
||||
".sidebar.right-side": "2b6bcc7d59f7b6bd",
|
||||
".skill-card > .skill-card-header": "e2408e762818d2fb",
|
||||
".skill-card-tags": "a327c85e053e6d3f",
|
||||
".skill-card-textcol": "f1059ed37c120b60",
|
||||
".skill-card.doclib-card-expanded": "b7f5d0edd75b1422",
|
||||
".skill-kebab-btn": "5cd557d02656d079",
|
||||
".spinner": "e403b24f35c58e53",
|
||||
".stop-recording-btn": "d5489a601cfabf69",
|
||||
".styled-confirm-box": "90a27b200f7179c1",
|
||||
".styled-confirm-box .modal-body p": "39733c5420c1521b",
|
||||
".styled-confirm-box .modal-footer": "0d1b29f6f7487848",
|
||||
".styled-prompt-box": "1f3925c0236d652e",
|
||||
".styled-prompt-input": "a4e24959b76553cf",
|
||||
".task-card .memory-item-actions": "33e67907b02e543c",
|
||||
".task-log-name": "f29203861c94fb42",
|
||||
".theme-grid": "0f53a4921d5a478c",
|
||||
".thumb button": "323943969cfbb194",
|
||||
".thumb-collapsed": "c967b682b6c604e4",
|
||||
".thumb.thumb-image .thumb-img": "4a4da275eee6cc70",
|
||||
".toast": "c60c6a60d4982005",
|
||||
".toast-action-hint": "13c8621842a92195",
|
||||
"a": "bb0f88ce29ae7637",
|
||||
"body": "a402cd1690151695",
|
||||
"body.notes-drag-mode .note-card": "38ee1aee83dccef6",
|
||||
"body.notes-view .chat-container": "8ba89017b94cf407",
|
||||
"button.cal-add-btn.cal-add-btn-text": "7e6817fca9a759dc",
|
||||
"button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm": "d5d12fbae3d58592",
|
||||
"button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm .cal-add-label": "9b674e66a8ae6380",
|
||||
"button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm .cal-add-plus": "69dca313be48c31e",
|
||||
"button.cal-nav.cal-toolbar-arrow": "7c252b6e2024fdb4",
|
||||
"code": "0b8a26334c2cdc21",
|
||||
"details": "1979b4f98bd52f98",
|
||||
"details a": "5a744551046b2383",
|
||||
"h2": "8a4d59fbdab8c1ad",
|
||||
"pre": "a5f2d86c696dc650",
|
||||
"pre .copy-code": "a9489a5797fcf296",
|
||||
"pre .edit-code": "a9489a5797fcf296",
|
||||
"pre .run-code": "a9489a5797fcf296",
|
||||
"table": "fc1e26a096450665",
|
||||
"td": "b782f3027093fc82",
|
||||
"th": "fd81ff4190b2f023"
|
||||
},
|
||||
"login": {
|
||||
"body": "64975bdadce9f6e5",
|
||||
"card": "5139f16a929fa893",
|
||||
"error": "3eb05963b0d792b6",
|
||||
"logo": "15751ee911f586da",
|
||||
"password": "42f0c35485f59dea",
|
||||
"pw-toggle": "9e5f0475eb7b6c57",
|
||||
"remember-dot": "62b8fa66ceaf590e",
|
||||
"remember-toggle": "87bed84dfe9061e8",
|
||||
"root": "6f8529bca11d0e80",
|
||||
"setup-note": "028127c742bf5f87",
|
||||
"submit": "5e371dbe68196bfd",
|
||||
"toggle-link": "289ae7be0ef6e564",
|
||||
"username": "42f0c35485f59dea",
|
||||
"version-label": "cc443cc7790ff3b4"
|
||||
}
|
||||
},
|
||||
"variants": {
|
||||
"app-shell": {
|
||||
"desktop-dark-comfortable": "65b23a35b5a34126",
|
||||
"desktop-dark-compact": "1bac3a0aa4467555",
|
||||
"desktop-dark-spacious": "944583b1854fecbb",
|
||||
"desktop-light-comfortable": "d279432f8c120b58",
|
||||
"desktop-light-compact": "aa768c34ba3c7abc",
|
||||
"desktop-light-spacious": "b74f53e95bfa1d82",
|
||||
"laptop-dark-comfortable": "d98d16f3d60ed275",
|
||||
"laptop-dark-compact": "c3eaf3c4278a0120",
|
||||
"laptop-dark-spacious": "a085fe0f7d9eb66c",
|
||||
"laptop-light-comfortable": "0d7c64f324bebe87",
|
||||
"laptop-light-compact": "da50bf03b0f1be58",
|
||||
"laptop-light-spacious": "a6789fd05a66bf5a",
|
||||
"phone-dark-comfortable": "21b94f88da549eb5",
|
||||
"phone-dark-compact": "b9aa7def2b85414e",
|
||||
"phone-dark-spacious": "21b94f88da549eb5",
|
||||
"phone-light-comfortable": "4a0cf45db775ea7d",
|
||||
"phone-light-compact": "f7fbcb94d99c8730",
|
||||
"phone-light-spacious": "4a0cf45db775ea7d",
|
||||
"tablet-dark-comfortable": "e18256ce58398f11",
|
||||
"tablet-dark-compact": "3cf3e52a10313d44",
|
||||
"tablet-dark-spacious": "e18256ce58398f11",
|
||||
"tablet-light-comfortable": "c2a45155c391f26c",
|
||||
"tablet-light-compact": "9fa7dc03dd02b723",
|
||||
"tablet-light-spacious": "c2a45155c391f26c"
|
||||
},
|
||||
"bench": {
|
||||
"desktop-dark-comfortable": "f9fa9d54db5ad45a",
|
||||
"desktop-dark-compact": "7603a804cb2e23a2",
|
||||
"desktop-dark-spacious": "b0c69306349fc078",
|
||||
"desktop-light-comfortable": "d3e98c4c1e90686f",
|
||||
"desktop-light-compact": "a04ae70088bc8b35",
|
||||
"desktop-light-spacious": "4aa01a125f541429",
|
||||
"laptop-dark-comfortable": "025e60ebe84e0f98",
|
||||
"laptop-dark-compact": "53d6555bb7a6b5bb",
|
||||
"laptop-dark-spacious": "c124d9ce1633d027",
|
||||
"laptop-light-comfortable": "e8328c45f5127bb0",
|
||||
"laptop-light-compact": "dc2da3534534c888",
|
||||
"laptop-light-spacious": "abe5483724c987e0",
|
||||
"phone-dark-comfortable": "67ca3c78e6b9495a",
|
||||
"phone-dark-compact": "ad6f3a9f5c8d81cd",
|
||||
"phone-dark-spacious": "1e6f66e56519ba5d",
|
||||
"phone-light-comfortable": "ef876073cc2dc45e",
|
||||
"phone-light-compact": "2ef6f5dc7a646be9",
|
||||
"phone-light-spacious": "b0d4cd9bd0361966",
|
||||
"tablet-dark-comfortable": "58922240b216e786",
|
||||
"tablet-dark-compact": "3991c22f99a394e0",
|
||||
"tablet-dark-spacious": "9848c3b7debac974",
|
||||
"tablet-light-comfortable": "12dfbad36172de20",
|
||||
"tablet-light-compact": "80d4843e622f1353",
|
||||
"tablet-light-spacious": "c420b8d46c991a44"
|
||||
},
|
||||
"login": {
|
||||
"desktop-dark-comfortable": "3cf4809db0f8e119",
|
||||
"desktop-dark-compact": "3cf4809db0f8e119",
|
||||
"desktop-dark-spacious": "3cf4809db0f8e119",
|
||||
"desktop-light-comfortable": "3cf4809db0f8e119",
|
||||
"desktop-light-compact": "3cf4809db0f8e119",
|
||||
"desktop-light-spacious": "3cf4809db0f8e119",
|
||||
"laptop-dark-comfortable": "1e6b6522bcb4a942",
|
||||
"laptop-dark-compact": "1e6b6522bcb4a942",
|
||||
"laptop-dark-spacious": "1e6b6522bcb4a942",
|
||||
"laptop-light-comfortable": "1e6b6522bcb4a942",
|
||||
"laptop-light-compact": "1e6b6522bcb4a942",
|
||||
"laptop-light-spacious": "1e6b6522bcb4a942",
|
||||
"phone-dark-comfortable": "e4b7998ae024fdaf",
|
||||
"phone-dark-compact": "e4b7998ae024fdaf",
|
||||
"phone-dark-spacious": "e4b7998ae024fdaf",
|
||||
"phone-light-comfortable": "e4b7998ae024fdaf",
|
||||
"phone-light-compact": "e4b7998ae024fdaf",
|
||||
"phone-light-spacious": "e4b7998ae024fdaf",
|
||||
"tablet-dark-comfortable": "321622b649b2f151",
|
||||
"tablet-dark-compact": "321622b649b2f151",
|
||||
"tablet-dark-spacious": "321622b649b2f151",
|
||||
"tablet-light-comfortable": "321622b649b2f151",
|
||||
"tablet-light-compact": "321622b649b2f151",
|
||||
"tablet-light-spacious": "321622b649b2f151"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>CSS snapshot bench</title>
|
||||
<!-- Replaced at capture time by whatever <link rel="stylesheet"> tags
|
||||
static/index.html ships, in shell order, so the bench keeps measuring
|
||||
the real stylesheet set after style.css is split. This fallback is only
|
||||
for opening the page by hand; it is deliberately unversioned so the
|
||||
cache-bust string has one home (tests/test_static_stylesheet_manifest.py
|
||||
pins the shipped ones). -->
|
||||
<link rel="stylesheet" href="/static/css/00-tokens.css">
|
||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css">
|
||||
<link rel="stylesheet" href="/static/css/02-compare.css">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css">
|
||||
<link rel="stylesheet" href="/static/css/04-memory.css">
|
||||
<link rel="stylesheet" href="/static/css/05-documents.css">
|
||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css">
|
||||
<link rel="stylesheet" href="/static/css/07-documents.css">
|
||||
<link rel="stylesheet" href="/static/css/08-skills.css">
|
||||
<link rel="stylesheet" href="/static/css/09-gallery.css">
|
||||
<link rel="stylesheet" href="/static/css/10-cookbook.css">
|
||||
<link rel="stylesheet" href="/static/css/11-tasks.css">
|
||||
<link rel="stylesheet" href="/static/css/12-gallery.css">
|
||||
<link rel="stylesheet" href="/static/css/13-image-editor.css">
|
||||
<link rel="stylesheet" href="/static/css/14-email.css">
|
||||
<link rel="stylesheet" href="/static/css/15-notes.css">
|
||||
<link rel="stylesheet" href="/static/css/16-calendar.css">
|
||||
<link rel="stylesheet" href="/static/css/17-research.css">
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Intentionally empty. tests/css_snapshot/capture.mjs mounts one subtree
|
||||
per bench selector from tests/css_snapshot/inventory.json, measures the
|
||||
leaf, and removes it again - so adding coverage is a one-line inventory
|
||||
change and this file never drifts from the selector list. -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,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,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { documentSource } from './helpers/document_source.mjs';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const source = documentSource();
|
||||
const start = source.indexOf(' function clearSelection(');
|
||||
const cleanup = source.slice(start, source.indexOf(' function clearSelectionAt(', start));
|
||||
assert.equal((source.match(/if \(_selections.length\) clearSelection\(\{ preserveCaret: true \}\);/g) || []).length, 2);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent('<div id="rich" contenteditable="true">before SELECT after</div><span id="doc-selection-badge"></span>');
|
||||
await page.evaluate(cleanup => {
|
||||
let _selections = [{ kind: 'rich' }];
|
||||
const _richSelectionHighlightName = 'test-selection';
|
||||
const rich = document.getElementById('rich');
|
||||
const _emailRichbodyActive = () => rich;
|
||||
const _scheduleDocumentStats = () => {};
|
||||
eval(cleanup + '\nwindow.clearPinned = clearSelection;');
|
||||
rich.addEventListener('input', () => {
|
||||
if (_selections.length) window.clearPinned({ preserveCaret: true });
|
||||
});
|
||||
rich.focus();
|
||||
const range = document.createRange();
|
||||
range.setStart(rich.firstChild, 7);
|
||||
range.setEnd(rich.firstChild, 13);
|
||||
window.getSelection().removeAllRanges();
|
||||
window.getSelection().addRange(range);
|
||||
}, cleanup);
|
||||
await page.keyboard.type('new');
|
||||
const result = await page.evaluate(() => ({
|
||||
text: document.getElementById('rich').textContent,
|
||||
offset: window.getSelection().anchorOffset,
|
||||
collapsed: window.getSelection().isCollapsed,
|
||||
ranges: window.getSelection().rangeCount,
|
||||
badge: document.getElementById('doc-selection-badge').style.display,
|
||||
}));
|
||||
assert.equal(result.text, 'before new after');
|
||||
assert.equal(result.offset, 10);
|
||||
assert.equal(result.collapsed, true);
|
||||
assert.equal(result.ranges, 1);
|
||||
assert.equal(result.badge, 'none');
|
||||
await page.evaluate(() => window.clearPinned());
|
||||
assert.equal(await page.evaluate(() => window.getSelection().rangeCount), 0);
|
||||
console.log('PASS: replacement typing preserves caret; explicit clear still removes selection.');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Browser End-to-End Tests
|
||||
|
||||
The photo editor release gate launches an isolated, authentication-disabled
|
||||
Odysseus server on port `7013` with a temporary SQLite database.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run test:photo-editor:install
|
||||
npm run test:photo-editor
|
||||
```
|
||||
|
||||
Set `PHOTO_EDITOR_E2E_PORT` to use another port. Failure screenshots and traces
|
||||
are written to `test-results/photo-editor/`. The server uses the repository
|
||||
`.venv` automatically; set `ODYSSEUS_TEST_PYTHON` to another Python executable
|
||||
when using a different environment layout.
|
||||
@@ -0,0 +1,367 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const {
|
||||
dragOnCanvas,
|
||||
encodedImagePixelDigest,
|
||||
editorState,
|
||||
flattenedPixelDigest,
|
||||
openBlankEditor,
|
||||
reopenDraft,
|
||||
waitForDraft,
|
||||
} = require('./helpers.js');
|
||||
|
||||
async function addAdjustment(page, type) {
|
||||
await page.locator('#ge-add-layer').click();
|
||||
const menu = page.locator('.ge-add-layer-menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.locator(`[data-adjustment-type="${type}"]`).click();
|
||||
await expect(page.locator('.ge-adj-popup')).toBeVisible();
|
||||
}
|
||||
|
||||
test('Levels is a retained stack layer with clipping, masks, history, and persistence', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 360, height: 260 }, 'Adjustment layers E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await dragOnCanvas(page, { x: 0.18, y: 0.25 }, { x: 0.78, y: 0.7 });
|
||||
const before = await flattenedPixelDigest(page);
|
||||
|
||||
await addAdjustment(page, 'levels');
|
||||
await page.locator('.ge-adj-channel-select').selectOption('red');
|
||||
await page.locator('.ge-adj-row input[data-key="outWhite"]').fill('128');
|
||||
await page.locator('.ge-adj-row input[data-key="outWhite"]').dispatchEvent('input');
|
||||
await page.locator('[data-adj-action="ok"]').click();
|
||||
|
||||
let current = await editorState(page);
|
||||
const levels = current.layers.find(layer => layer.kind === 'adjustment');
|
||||
expect(levels.adjustment.type).toBe('levels');
|
||||
expect(levels.adjustment.params.channels.red.outWhite).toBe(128);
|
||||
expect(await flattenedPixelDigest(page)).not.toEqual(before);
|
||||
|
||||
const row = page.locator(`.ge-layer-item[data-layer-id="${levels.id}"]`);
|
||||
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).clipped).toBe(true);
|
||||
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
|
||||
current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.id === levels.id).masks).toHaveLength(1);
|
||||
|
||||
await row.locator('.ge-layer-vis').click();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(before);
|
||||
await row.locator('.ge-layer-vis').click();
|
||||
await row.locator('.ge-layer-opacity').fill('45');
|
||||
await row.locator('.ge-layer-opacity').dispatchEvent('input');
|
||||
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBeCloseTo(.45, 2);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBe(1);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBeCloseTo(.45, 2);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
const expected = await editorState(page);
|
||||
const expectedPixels = await flattenedPixelDigest(page);
|
||||
const exportedPng = await page.evaluate(async () => {
|
||||
const editor = await import('/static/js/galleryEditor.js');
|
||||
return editor.exportPNG();
|
||||
});
|
||||
expect(await encodedImagePixelDigest(
|
||||
page,
|
||||
Buffer.from(exportedPng.split(',', 2)[1], 'base64'),
|
||||
)).toEqual(expectedPixels);
|
||||
await reopenDraft(page, draftId);
|
||||
current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.id === levels.id)).toEqual(expected.layers.find(layer => layer.id === levels.id));
|
||||
expect(await flattenedPixelDigest(page)).toEqual(expectedPixels);
|
||||
});
|
||||
|
||||
test('Curves supports editable RGB and channel points with reset and cancel', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 360, height: 260 }, 'Curves E2E');
|
||||
await addAdjustment(page, 'curves');
|
||||
const curve = page.locator('.ge-curves-canvas');
|
||||
const box = await curve.boundingBox();
|
||||
await page.mouse.click(box.x + box.width * .5, box.y + box.height * .28);
|
||||
await page.locator('.ge-adj-channel-select').selectOption('blue');
|
||||
const blueBox = await curve.boundingBox();
|
||||
await page.mouse.click(blueBox.x + blueBox.width * .4, blueBox.y + blueBox.height * .68);
|
||||
await page.locator('[data-adj-action="ok"]').click();
|
||||
|
||||
let current = await editorState(page);
|
||||
const curves = current.layers.find(layer => layer.kind === 'adjustment');
|
||||
expect(curves.adjustment.type).toBe('curves');
|
||||
expect(curves.adjustment.params.points.rgb).toHaveLength(3);
|
||||
expect(curves.adjustment.params.points.blue).toHaveLength(3);
|
||||
|
||||
const row = page.locator(`.ge-layer-item[data-layer-id="${curves.id}"]`);
|
||||
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
|
||||
await page.locator('[data-adj-action="reset"]').click();
|
||||
await page.locator('[data-adj-action="cancel"]').click();
|
||||
current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.id === curves.id).adjustment.params.points.rgb).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('color adjustment controls retain neutral reusable parameters', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 220 }, 'Color adjustments E2E');
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
const gradient = layer.ctx.createLinearGradient(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
gradient.addColorStop(0, '#b8324f');
|
||||
gradient.addColorStop(.5, '#56a56d');
|
||||
gradient.addColorStop(1, '#315fc4');
|
||||
layer.ctx.fillStyle = gradient;
|
||||
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
window.galleryEditorComposite?.();
|
||||
});
|
||||
|
||||
const cases = [
|
||||
{ type: 'exposure', key: 'exposure', value: '100', check: p => p.exposure === 1 },
|
||||
{ type: 'white-balance', key: 'temperature', value: '70', check: p => p.temperature === 70 },
|
||||
{ type: 'hue-saturation', key: 'lightness', value: '18', check: p => p.lightness === 18 },
|
||||
{ type: 'vibrance', key: 'vibrance', value: '55', check: p => p.vibrance === 55 },
|
||||
{ type: 'black-white', key: 'red', value: '72', compare: true, check: p => p.red === 72 && p.green === 59 && p.blue === 11 },
|
||||
{ type: 'shadows-highlights', key: 'shadows', value: '48', check: p => p.shadows === 48 },
|
||||
{ type: 'color-balance', key: 'shadows-r', value: '45', check: p => p.shadows.r === 45 },
|
||||
{ type: 'selective-color', key: 'cyan', value: '35', check: p => p.ranges.reds.cyan === 35 },
|
||||
];
|
||||
let priorDigest = await flattenedPixelDigest(page);
|
||||
for (const item of cases) {
|
||||
await addAdjustment(page, item.type);
|
||||
const control = page.locator(`.ge-adj-row input[data-key="${item.key}"]`);
|
||||
await control.fill(item.value);
|
||||
await control.dispatchEvent('input');
|
||||
await page.locator('[data-adj-action="ok"]').click();
|
||||
const current = await editorState(page);
|
||||
const added = current.layers.filter(layer => layer.kind === 'adjustment').at(-1);
|
||||
expect(added.adjustment.type).toBe(item.type);
|
||||
expect(item.check(added.adjustment.params)).toBe(true);
|
||||
const nextDigest = await flattenedPixelDigest(page);
|
||||
expect(nextDigest).not.toBe(priorDigest);
|
||||
if (item.compare) {
|
||||
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
|
||||
await expect(page.locator('.ge-adj-popup')).toBeVisible();
|
||||
await page.locator('[data-adj-action="compare"]').click();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(priorDigest);
|
||||
await page.locator('[data-adj-action="compare"]').click();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(nextDigest);
|
||||
await page.locator('[data-adj-action="cancel"]').click();
|
||||
}
|
||||
priorDigest = nextDigest;
|
||||
}
|
||||
|
||||
await addAdjustment(page, 'gradient-map');
|
||||
await page.locator('[data-gradient-key="shadows"]').fill('#123456');
|
||||
await page.locator('[data-gradient-key="highlights"]').fill('#f0d080');
|
||||
await page.locator('[data-gradient-reverse]').check();
|
||||
await page.locator('[data-adj-action="ok"]').click();
|
||||
const current = await editorState(page);
|
||||
const gradient = current.layers.filter(layer => layer.kind === 'adjustment').at(-1);
|
||||
expect(gradient.adjustment).toEqual({
|
||||
type: 'gradient-map',
|
||||
params: { shadows: '#123456', highlights: '#f0d080', midpoint: 50, reverse: true },
|
||||
});
|
||||
const expectedPixels = await flattenedPixelDigest(page);
|
||||
const draftId = await waitForDraft(page);
|
||||
const expectedState = await editorState(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.layers.filter(layer => layer.kind === 'adjustment')).toEqual(
|
||||
expectedState.layers.filter(layer => layer.kind === 'adjustment'),
|
||||
);
|
||||
expect(await flattenedPixelDigest(page)).toEqual(expectedPixels);
|
||||
});
|
||||
|
||||
test('adjustment presets apply through the retained popup', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 220 }, 'Adjustment presets E2E');
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
layer.ctx.fillStyle = '#5577aa';
|
||||
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
window.galleryEditorComposite?.();
|
||||
});
|
||||
const before = await flattenedPixelDigest(page);
|
||||
await addAdjustment(page, 'exposure');
|
||||
await page.locator('.ge-adj-preset-select').selectOption('Lift Exposure');
|
||||
await page.locator('[data-adj-action="ok"]').click();
|
||||
const current = await editorState(page);
|
||||
const exposure = current.layers.find(layer => layer.kind === 'adjustment');
|
||||
expect(exposure.adjustment.params.exposure).toBe(0.45);
|
||||
expect(await flattenedPixelDigest(page)).not.toEqual(before);
|
||||
});
|
||||
|
||||
test('adjustment popup keeps controls inside a narrow phone viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 700 });
|
||||
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile adjustment E2E');
|
||||
await addAdjustment(page, 'exposure');
|
||||
|
||||
const popup = await page.locator('.ge-adj-popup').boundingBox();
|
||||
expect(popup).not.toBeNull();
|
||||
expect(popup.x).toBeGreaterThanOrEqual(0);
|
||||
expect(popup.x + popup.width).toBeLessThanOrEqual(320);
|
||||
for (const row of await page.locator('.ge-adj-row').all()) {
|
||||
const box = await row.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box.x).toBeGreaterThanOrEqual(popup.x);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(popup.x + popup.width);
|
||||
}
|
||||
await expect(page.locator('.ge-adj-foot [data-adj-action="cancel"]')).toBeVisible();
|
||||
await expect(page.locator('.ge-adj-foot [data-adj-action="ok"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('gradient map controls remain usable on a narrow phone viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 700 });
|
||||
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile gradient map E2E');
|
||||
await addAdjustment(page, 'gradient-map');
|
||||
|
||||
const popup = await page.locator('.ge-adj-popup').boundingBox();
|
||||
const colors = page.locator('.ge-gradient-color-row');
|
||||
const colorBox = await colors.boundingBox();
|
||||
expect(popup).not.toBeNull();
|
||||
expect(colorBox).not.toBeNull();
|
||||
expect(colorBox.x).toBeGreaterThanOrEqual(popup.x);
|
||||
expect(colorBox.x + colorBox.width).toBeLessThanOrEqual(popup.x + popup.width);
|
||||
expect(await page.locator('.ge-gradient-color-row').evaluate(el => (
|
||||
getComputedStyle(el).gridTemplateColumns.trim().split(/\s+/).length
|
||||
))).toBe(1);
|
||||
});
|
||||
|
||||
test('all adjustment popups remain usable on a narrow phone viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 700 });
|
||||
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile adjustment matrix E2E');
|
||||
|
||||
for (const type of [
|
||||
'brightness-contrast', 'exposure', 'white-balance', 'hue-saturation',
|
||||
'vibrance', 'black-white', 'shadows-highlights', 'levels', 'curves',
|
||||
'color-balance', 'selective-color', 'gradient-map',
|
||||
]) {
|
||||
await addAdjustment(page, type);
|
||||
const popup = await page.locator('.ge-adj-popup').boundingBox();
|
||||
expect(popup, `${type} popup should be visible`).not.toBeNull();
|
||||
expect(popup.x).toBeGreaterThanOrEqual(0);
|
||||
expect(popup.x + popup.width).toBeLessThanOrEqual(320);
|
||||
|
||||
const body = page.locator('.ge-adj-body');
|
||||
expect(await body.evaluate(el => el.scrollWidth <= el.clientWidth + 1), `${type} body should not overflow horizontally`).toBe(true);
|
||||
await expect(page.locator('.ge-adj-foot [data-adj-action="cancel"]')).toBeVisible();
|
||||
await expect(page.locator('.ge-adj-foot [data-adj-action="ok"]')).toBeVisible();
|
||||
await page.locator('[data-adj-action="cancel"]').click();
|
||||
}
|
||||
});
|
||||
|
||||
test('retained Gaussian Blur survives flattening and draft reopen', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained effects E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
|
||||
const before = await flattenedPixelDigest(page);
|
||||
|
||||
await page.locator('#ge-filter-menu-btn').click();
|
||||
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
|
||||
let current = await editorState(page);
|
||||
const layer = current.layers.find(item => item.effects?.length);
|
||||
expect(layer.effects).toHaveLength(1);
|
||||
expect(layer.effects[0].type).toBe('gaussian-blur');
|
||||
expect(layer.effects[0].params.radius).toBe(14);
|
||||
const after = await flattenedPixelDigest(page);
|
||||
expect(after).not.toEqual(before);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
current = await editorState(page);
|
||||
const reopened = current.layers.find(item => item.effects?.length);
|
||||
expect(reopened.effects).toEqual(layer.effects);
|
||||
expect(await flattenedPixelDigest(page)).toEqual(after);
|
||||
|
||||
await page.locator('.ge-effect-sub-item .ge-adj-sub-name').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[data-key="radius"]').fill('22');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].params.radius).toBe(22);
|
||||
});
|
||||
|
||||
test('retained Sharpen keeps editable amount and survives draft reopen', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained sharpen E2E');
|
||||
await page.locator('#ge-filter-menu-btn').click();
|
||||
await page.locator('[data-filter-action="effect-preset-crisp-detail"]').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
expect(await page.locator('.ge-filter-row input[data-key="amount"]').inputValue()).toBe('35');
|
||||
await page.locator('.ge-filter-row input[data-key="amount"]').fill('75');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
let current = await editorState(page);
|
||||
let layer = current.layers.find(item => item.effects?.length);
|
||||
expect(layer.effects[0].type).toBe('sharpen');
|
||||
expect(layer.effects[0].params.amount).toBeCloseTo(0.75, 2);
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
current = await editorState(page);
|
||||
layer = current.layers.find(item => item.effects?.length);
|
||||
expect(layer.effects[0].type).toBe('sharpen');
|
||||
expect(layer.effects[0].params.amount).toBeCloseTo(0.75, 2);
|
||||
});
|
||||
|
||||
test('retained overlay and shadow effects keep editable metadata', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained color effects E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
|
||||
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.move(canvasBox.x + 24, canvasBox.y + 24);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(canvasBox.x + 150, canvasBox.y + 140);
|
||||
await page.mouse.up();
|
||||
await page.locator('#ge-filter-menu-btn').click();
|
||||
await page.locator('[data-filter-action="effect-color-overlay"]').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
|
||||
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
await page.locator('button[title="Add effect mask from selection"]').click();
|
||||
|
||||
await page.locator('#ge-filter-menu-btn').click();
|
||||
await page.locator('[data-filter-action="effect-drop-shadow"]').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[data-key="blur"]').fill('18');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
|
||||
let current = await editorState(page);
|
||||
let layer = current.layers.find(item => item.effects?.length);
|
||||
expect(layer.effects.map(effect => effect.type)).toEqual(['color-overlay', 'drop-shadow']);
|
||||
expect(layer.effects[0].params.color).toBe('#336699');
|
||||
expect(layer.effects[0].mask.size).toEqual([320, 220]);
|
||||
await page.locator('button[title="Hide effect mask"]').click();
|
||||
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].mask.visible).toBe(false);
|
||||
await page.locator('button[title="Remove effect mask"]').click();
|
||||
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].mask).toBeNull();
|
||||
expect(layer.effects[0].params.opacity).toBeCloseTo(0.35, 2);
|
||||
expect(layer.effects[1].params.blur).toBe(18);
|
||||
|
||||
await page.locator('.ge-effect-sub-item').nth(0).locator('.ge-layer-vis').click();
|
||||
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].visible).toBe(false);
|
||||
await page.locator('.ge-effect-sub-item').nth(1).locator('button[title="Move effect up"]').click();
|
||||
current = await editorState(page);
|
||||
layer = current.layers.find(item => item.effects?.length);
|
||||
expect(layer.effects.map(effect => effect.type)).toEqual(['drop-shadow', 'color-overlay']);
|
||||
await page.locator('.ge-effect-sub-item').nth(1).locator('button[title="Delete effect"]').click();
|
||||
expect((await editorState(page)).layers.find(item => item.effects?.length).effects).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('retained Stroke uses layer alpha and persists its controls', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
|
||||
await page.locator('#ge-filter-menu-btn').click();
|
||||
await page.locator('[data-filter-action="effect-stroke"]').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[data-key="width"]').fill('9');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
const layer = (await editorState(page)).layers.find(item => item.effects?.length);
|
||||
expect(layer.effects[0].type).toBe('stroke');
|
||||
expect(layer.effects[0].params.width).toBe(9);
|
||||
const beforeRasterize = await flattenedPixelDigest(page);
|
||||
await page.locator('.ge-effect-sub-item button[title="Rasterize effects"]').click();
|
||||
await expect.poll(async () => (await editorState(page)).layers.find(item => item.name === 'Edit').effects.length).toBe(0);
|
||||
const rasterized = (await editorState(page)).layers.find(item => item.name === 'Edit');
|
||||
expect(rasterized.effects).toHaveLength(0);
|
||||
expect(await flattenedPixelDigest(page)).toEqual(beforeRasterize);
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
expect((await editorState(page)).layers.find(item => item.name === 'Edit').effects).toHaveLength(0);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
async function addAppStyles(page) {
|
||||
const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs');
|
||||
for (const url of await stylesheetUrls()) {
|
||||
await page.addStyleTag({ url });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
test('mobile compare uses tabs to show one mounted pane at a time', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/login');
|
||||
await addAppStyles(page);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const { default: state } = await import('/static/js/compare/state.js');
|
||||
const { mountMobilePaneTabs } = await import('/static/js/compare/panes.js?v=20260903comparemodeicons1');
|
||||
document.body.innerHTML = '<main id="compare-test-host" class="chat-container compare-active"><div class="compare-grid" data-cols="3"></div></main>';
|
||||
const host = document.getElementById('compare-test-host');
|
||||
host.style.cssText = 'position:fixed;inset:0;display:flex;flex-direction:column;padding-top:12px;';
|
||||
const grid = host.querySelector('.compare-grid');
|
||||
state._blindMode = false;
|
||||
state._parallel = true;
|
||||
state._selectedModels = [
|
||||
{ name: 'Alpha model' },
|
||||
{ name: 'Beta model' },
|
||||
{ name: 'Gamma model' },
|
||||
];
|
||||
state._activeMobilePane = 0;
|
||||
state._selectedModels.forEach((model, index) => {
|
||||
const pane = document.createElement('section');
|
||||
pane.className = 'compare-pane';
|
||||
pane.dataset.pane = String(index);
|
||||
pane.innerHTML = `<header class="pane-header"><button id="cmp-title-${index}" class="pane-title-btn">${model.name}</button></header><div class="chat-history">Response ${index + 1}</div>`;
|
||||
grid.appendChild(pane);
|
||||
});
|
||||
mountMobilePaneTabs(host, grid);
|
||||
});
|
||||
|
||||
const tabs = page.locator('.compare-mobile-tab');
|
||||
const panes = page.locator('.compare-pane');
|
||||
await expect(tabs).toHaveCount(3);
|
||||
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(panes.nth(0)).toBeVisible();
|
||||
await expect(panes.nth(1)).toBeHidden();
|
||||
await expect(panes.nth(2)).toBeHidden();
|
||||
|
||||
await tabs.nth(1).click();
|
||||
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(panes.nth(0)).toBeHidden();
|
||||
await expect(panes.nth(1)).toBeVisible();
|
||||
await expect(panes.nth(2)).toBeHidden();
|
||||
|
||||
const geometry = await page.evaluate(() => ({
|
||||
grid: document.querySelector('.compare-grid').getBoundingClientRect().toJSON(),
|
||||
pane: document.querySelector('.compare-pane-mobile-active').getBoundingClientRect().toJSON(),
|
||||
visiblePaneCount: Array.from(document.querySelectorAll('.compare-pane')).filter((pane) => getComputedStyle(pane).display !== 'none').length,
|
||||
}));
|
||||
expect(geometry.visiblePaneCount).toBe(1);
|
||||
expect(Math.abs((geometry.grid.width - 16) - geometry.pane.width)).toBeLessThan(2);
|
||||
});
|
||||
|
||||
|
||||
test('mobile compare probe keeps feedback below models and actions split', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/login');
|
||||
await addAppStyles(page);
|
||||
await page.evaluate(() => {
|
||||
document.body.innerHTML = `
|
||||
<div class="compare-probe-overlay">
|
||||
<section class="compare-probe-card">
|
||||
<div class="compare-probe-title">Checking models...</div>
|
||||
<div class="compare-probe-list">
|
||||
<div class="compare-probe-row"><span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">Alpha</span></div>
|
||||
<div class="compare-probe-row fail"><span class="compare-probe-spinner fail">×</span><span class="compare-probe-name">Beta</span></div>
|
||||
</div>
|
||||
<div class="compare-probe-feedback">
|
||||
<div class="compare-probe-detail"><span class="compare-probe-detail-message">Insufficient balance</span><button class="compare-probe-action-btn"><svg></svg><span>Retry</span></button><button class="compare-probe-action-btn"><svg></svg><span>Swap</span></button></div>
|
||||
</div>
|
||||
<div class="compare-probe-footer"><button class="cmp-btn-secondary compare-probe-footer-btn">Go Back</button><button class="cmp-btn-primary compare-probe-footer-btn compare-probe-start-anyway">Start Anyway</button></div>
|
||||
</section>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
const layout = await page.evaluate(() => {
|
||||
const list = document.querySelector('.compare-probe-list').getBoundingClientRect();
|
||||
const feedback = document.querySelector('.compare-probe-feedback').getBoundingClientRect();
|
||||
const back = document.querySelector('.compare-probe-footer-btn').getBoundingClientRect();
|
||||
const start = document.querySelector('.compare-probe-start-anyway').getBoundingClientRect();
|
||||
const spinnerStyle = getComputedStyle(document.querySelector('.compare-probe-spinner'));
|
||||
const cardStyle = getComputedStyle(document.querySelector('.compare-probe-card'));
|
||||
const startStyle = getComputedStyle(document.querySelector('.compare-probe-start-anyway'));
|
||||
return {
|
||||
feedbackBelowList: feedback.top >= list.bottom,
|
||||
splitActions: back.left < start.left && start.right > 350,
|
||||
spinnerTransform: spinnerStyle.transform,
|
||||
cardRadius: cardStyle.borderRadius,
|
||||
startBackground: startStyle.backgroundColor,
|
||||
};
|
||||
});
|
||||
expect(layout.feedbackBelowList).toBe(true);
|
||||
expect(layout.splitActions).toBe(true);
|
||||
expect(layout.spinnerTransform).toContain('-2');
|
||||
expect(layout.cardRadius).toBe('8px');
|
||||
expect(layout.startBackground).not.toBe('rgba(0, 0, 0, 0)');
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor } = require('./helpers.js');
|
||||
|
||||
test('brush cursor matches the rendered brush diameter and replaces the crosshair', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 400, height: 300 });
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
state.brushSize = 80;
|
||||
});
|
||||
|
||||
const canvas = page.locator('.ge-main-canvas');
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
await page.mouse.move(canvasBox.x + canvasBox.width / 2, canvasBox.y + canvasBox.height / 2);
|
||||
|
||||
const cursor = page.locator('.ge-brush-cursor');
|
||||
await expect(cursor).toBeVisible();
|
||||
await expect(canvas).toHaveCSS('cursor', 'none');
|
||||
|
||||
const measurements = await page.evaluate(() => {
|
||||
const canvasEl = document.querySelector('.ge-main-canvas');
|
||||
const cursorEl = document.querySelector('.ge-brush-cursor');
|
||||
const canvasRect = canvasEl.getBoundingClientRect();
|
||||
const cursorRect = cursorEl.getBoundingClientRect();
|
||||
return {
|
||||
expectedWidth: 80 * canvasRect.width / canvasEl.width,
|
||||
expectedHeight: 80 * canvasRect.height / canvasEl.height,
|
||||
cursorWidth: cursorRect.width,
|
||||
cursorHeight: cursorRect.height,
|
||||
cursorZ: Number(getComputedStyle(cursorEl).zIndex),
|
||||
galleryZ: Number(getComputedStyle(document.getElementById('gallery-modal')).zIndex || 0),
|
||||
};
|
||||
});
|
||||
|
||||
expect(measurements.cursorWidth).toBeCloseTo(measurements.expectedWidth, 1);
|
||||
expect(measurements.cursorHeight).toBeCloseTo(measurements.expectedHeight, 1);
|
||||
expect(measurements.cursorZ).toBeGreaterThan(measurements.galleryZ);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
state.brushSize = 160;
|
||||
});
|
||||
await page.mouse.move(canvasBox.x + canvasBox.width / 2 + 1, canvasBox.y + canvasBox.height / 2);
|
||||
await expect.poll(async () => (await cursor.boundingBox()).width)
|
||||
.toBeCloseTo(measurements.expectedWidth * 2, 1);
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
|
||||
|
||||
async function canvasPoint(page, xRatio, yRatio) {
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
return { x: box.x + box.width * xRatio, y: box.y + box.height * yRatio };
|
||||
}
|
||||
|
||||
test('brush presets and shared stroke controls remain reusable', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await expect(page.locator('#ge-brush-spacing')).toBeVisible();
|
||||
await page.locator('#ge-brush-spacing').fill('28');
|
||||
await page.locator('#ge-brush-smoothing').fill('52');
|
||||
await page.locator('#ge-brush-blend').selectOption('multiply');
|
||||
await page.locator('.ge-pressure-option').filter({ hasText: 'Opacity' }).locator('.toggle-slider').click();
|
||||
await expect(page.locator('#ge-pressure-opacity')).toBeChecked();
|
||||
|
||||
page.once('dialog', dialog => dialog.accept('My Detail Brush'));
|
||||
await page.locator('#ge-brush-preset-save').click();
|
||||
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(1);
|
||||
|
||||
await page.locator('#ge-brush-spacing').fill('5');
|
||||
await page.locator('#ge-brush-preset').selectOption({ label: 'My Detail Brush' });
|
||||
await expect(page.locator('#ge-brush-spacing')).toHaveValue('28');
|
||||
await expect(page.locator('#ge-brush-smoothing')).toHaveValue('52');
|
||||
await expect(page.locator('#ge-brush-blend')).toHaveValue('multiply');
|
||||
await page.locator('#ge-brush-preset-delete').click();
|
||||
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('long sampled strokes stay responsive and undo atomically', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await page.locator('.ge-size-slider').fill('700');
|
||||
await page.locator('#ge-brush-spacing').fill('8');
|
||||
await page.locator('#ge-brush-smoothing').fill('65');
|
||||
const activeLayerId = (await editorState(page)).activeLayerId;
|
||||
const thumb = page.locator(`.ge-layer-item[data-layer-id="${activeLayerId}"] .ge-layer-inline-thumb`);
|
||||
const thumbBefore = await thumb.evaluate(canvas => canvas.toDataURL());
|
||||
const before = await flattenedPixelDigest(page);
|
||||
const start = await canvasPoint(page, 0.08, 0.5);
|
||||
const end = await canvasPoint(page, 0.92, 0.5);
|
||||
const startedAt = Date.now();
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(end.x, end.y, { steps: 240 });
|
||||
await page.mouse.up();
|
||||
expect(Date.now() - startedAt).toBeLessThan(5000);
|
||||
const after = await flattenedPixelDigest(page);
|
||||
expect(after).not.toEqual(before);
|
||||
await expect(thumb).toHaveAttribute('width', '68');
|
||||
expect(await thumb.evaluate(canvas => canvas.toDataURL())).not.toEqual(thumbBefore);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(before);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(after);
|
||||
});
|
||||
|
||||
test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await page.locator('.ge-color-picker').first().evaluate((input) => {
|
||||
input.value = '#33aa55';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
const sample = await canvasPoint(page, 0.3, 0.3);
|
||||
await page.mouse.click(sample.x, sample.y);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="eyedropper"]').click();
|
||||
await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
|
||||
await page.locator('#ge-eyedropper-sample').selectOption('composite');
|
||||
await page.mouse.move(sample.x, sample.y);
|
||||
await expect(page.locator('#ge-eyedropper-live-value')).toHaveText('#33aa55');
|
||||
await expect(page.locator('#ge-eyedropper-live-rgb')).toHaveText('RGB 51 170 85');
|
||||
await expect(page.locator('#ge-eyedropper-live-hsl')).toHaveText('HSL 137 54% 43%');
|
||||
await expect(page.locator('#ge-eyedropper-live-swatch')).not.toHaveClass(/empty/);
|
||||
await expect(page.locator('#ge-eyedropper-loupe')).toBeVisible();
|
||||
expect((await page.locator('#ge-eyedropper-loupe').evaluate(canvas => canvas.getContext('2d').getImageData(42, 42, 1, 1).data[3]))).toBe(255);
|
||||
await page.mouse.click(sample.x, sample.y);
|
||||
await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55');
|
||||
|
||||
for (const tool of ['dodge', 'burn']) {
|
||||
const before = await flattenedPixelDigest(page);
|
||||
await page.locator(`.ge-tool-btn[data-tool="${tool}"]`).click();
|
||||
const from = await canvasPoint(page, 0.35, tool === 'dodge' ? 0.45 : 0.6);
|
||||
const to = await canvasPoint(page, 0.65, tool === 'dodge' ? 0.45 : 0.6);
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to.x, to.y, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
expect((await editorState(page)).layers.length).toBeGreaterThan(0);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(before);
|
||||
}
|
||||
|
||||
const blemish = await canvasPoint(page, 0.72, 0.3);
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await page.locator('.ge-color-picker').first().evaluate((input) => {
|
||||
input.value = '#111111';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await page.mouse.click(blemish.x, blemish.y);
|
||||
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
|
||||
const beforeHeal = await flattenedPixelDigest(page);
|
||||
const healTarget = blemish;
|
||||
await page.mouse.move(healTarget.x, healTarget.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(healTarget.x + 50, healTarget.y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
expect(await flattenedPixelDigest(page)).not.toEqual(beforeHeal);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(beforeHeal);
|
||||
});
|
||||
|
||||
test('healing brush can use an optional sampled source', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Sampled healing E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await page.locator('.ge-size-slider').fill('420');
|
||||
await page.locator('.ge-color-picker').first().evaluate((input) => {
|
||||
input.value = '#d24b62';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
const source = await canvasPoint(page, 0.28, 0.5);
|
||||
await page.mouse.click(source.x, source.y);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
|
||||
await page.keyboard.down('Alt');
|
||||
await page.mouse.click(source.x, source.y);
|
||||
await page.keyboard.up('Alt');
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.cloneSourceX !== null && state.cloneSourceY !== null;
|
||||
})).toBe(true);
|
||||
const target = await canvasPoint(page, 0.72, 0.5);
|
||||
await page.mouse.click(target.x, target.y);
|
||||
|
||||
const result = await page.evaluate(async ({ xRatio, yRatio }) => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
const x = Math.round(layer.canvas.width * xRatio);
|
||||
const y = Math.round(layer.canvas.height * yRatio);
|
||||
const point = layer.ctx.getImageData(x, y, 1, 1).data;
|
||||
return { hasSource: !!state.cloneSourceSnapshot, pixel: [...point] };
|
||||
}, { xRatio: 0.72, yRatio: 0.5 });
|
||||
expect(result.hasSource).toBe(true);
|
||||
expect(result.pixel[0]).toBeGreaterThan(80);
|
||||
expect(result.pixel[3]).toBeGreaterThan(0);
|
||||
await expect(page.locator('#ge-clone-source-label')).toHaveText('Active layer sampled');
|
||||
await page.locator('#ge-clone-source-clear').click();
|
||||
await expect(page.locator('#ge-clone-source-label')).toHaveText('No source selected');
|
||||
});
|
||||
|
||||
test('smudge carries nearby pixels along a stroke and undoes atomically', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Smudge E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await page.locator('.ge-size-slider').fill('700');
|
||||
await page.locator('.ge-color-picker').first().evaluate((input) => {
|
||||
input.value = '#d24b62';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
const source = await canvasPoint(page, 0.28, 0.5);
|
||||
const target = await canvasPoint(page, 0.55, 0.5);
|
||||
await page.mouse.click(source.x, source.y);
|
||||
|
||||
const before = await page.evaluate(() => {
|
||||
const canvas = document.querySelector('.ge-main-canvas');
|
||||
const x = Math.round(canvas.width * 0.55);
|
||||
const y = Math.round(canvas.height * 0.5);
|
||||
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
|
||||
});
|
||||
await page.locator('.ge-tool-btn[data-tool="smudge"]').click();
|
||||
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
|
||||
await page.mouse.move(source.x, source.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(target.x, target.y, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
const after = await page.evaluate(() => {
|
||||
const canvas = document.querySelector('.ge-main-canvas');
|
||||
const x = Math.round(canvas.width * 0.55);
|
||||
const y = Math.round(canvas.height * 0.5);
|
||||
const ctx = canvas.getContext('2d');
|
||||
return [...ctx.getImageData(x, y, 1, 1).data];
|
||||
});
|
||||
expect(before[0]).toBeGreaterThan(220);
|
||||
expect(before[3]).toBe(255);
|
||||
expect(after[0]).toBeLessThan(220);
|
||||
expect(after[2]).toBeLessThan(220);
|
||||
expect(after[3]).toBeGreaterThan(0);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect(await page.evaluate(() => {
|
||||
const canvas = document.querySelector('.ge-main-canvas');
|
||||
const x = Math.round(canvas.width * 0.55);
|
||||
const y = Math.round(canvas.height * 0.5);
|
||||
return canvas.getContext('2d').getImageData(x, y, 1, 1).data[3];
|
||||
})).toBe(255);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, openBlankEditor } = require('./helpers.js');
|
||||
|
||||
async function mainCanvasHash(page) {
|
||||
return page.evaluate(() => {
|
||||
const canvas = document.querySelector('.ge-main-canvas');
|
||||
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
hash ^= data[index];
|
||||
hash = Math.imul(hash, 16777619);
|
||||
hash ^= data[index + 1];
|
||||
hash = Math.imul(hash, 16777619);
|
||||
hash ^= data[index + 2];
|
||||
hash = Math.imul(hash, 16777619);
|
||||
hash ^= data[index + 3];
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
});
|
||||
}
|
||||
|
||||
test('before compare is visual-only and toggles back to the edited document', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 180 }, 'Compare mode E2E');
|
||||
await expect(page.locator('#ge-compare-btn')).toBeVisible();
|
||||
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
|
||||
|
||||
const baselineHash = await mainCanvasHash(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
|
||||
const editedState = await editorState(page);
|
||||
const editedHash = await mainCanvasHash(page);
|
||||
expect(editedHash).not.toBe(baselineHash);
|
||||
|
||||
await page.locator('#ge-compare-btn').click();
|
||||
await expect(page.locator('#ge-compare-btn')).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(await mainCanvasHash(page)).toBe(baselineHash);
|
||||
expect((await editorState(page)).layers).toEqual(editedState.layers);
|
||||
|
||||
await page.locator('#ge-compare-btn').click();
|
||||
await expect(page.locator('#ge-compare-btn')).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(await mainCanvasHash(page)).toBe(editedHash);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
const fs = require('node:fs');
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const {
|
||||
compareExportPixels,
|
||||
dragOnCanvas,
|
||||
encodedImagePixelDigest,
|
||||
editorState,
|
||||
flattenedPixelDigest,
|
||||
openBlankEditor,
|
||||
openExportDialog,
|
||||
reopenDraft,
|
||||
waitForDraft,
|
||||
} = require('./helpers.js');
|
||||
|
||||
test('layered document survives edit, mask, transform, crop, reopen, and export', async ({ page, request }) => {
|
||||
await openBlankEditor(page);
|
||||
const initial = await editorState(page);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await dragOnCanvas(page, { x: 0.20, y: 0.30 }, { x: 0.72, y: 0.60 });
|
||||
const painted = await editorState(page);
|
||||
const paintedLayer = painted.layers.find(layer => layer.name === 'Edit');
|
||||
const initialLayer = initial.layers.find(layer => layer.name === 'Edit');
|
||||
expect(paintedLayer.pixelHash).not.toBe(initialLayer.pixelHash);
|
||||
|
||||
const editItem = page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first();
|
||||
await editItem.click();
|
||||
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
|
||||
let current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.name === 'Edit').masks).toHaveLength(1);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await expect(page.locator('#ge-transform-w')).toBeVisible();
|
||||
await page.locator('#ge-transform-w').fill('560');
|
||||
await page.locator('#ge-transform-apply').click();
|
||||
current = await editorState(page);
|
||||
const transformed = current.layers.find(layer => layer.name === 'Edit');
|
||||
expect(transformed.size[0]).toBe(560);
|
||||
expect(transformed.masks[0].size).toEqual(transformed.size);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').size[0]).toBe(640);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').size[0]).toBe(560);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="text"]').click();
|
||||
await expect(page.locator('#ge-text-section')).toBeVisible();
|
||||
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.click(canvasBox.x + canvasBox.width * 0.28, canvasBox.y + canvasBox.height * 0.22);
|
||||
await page.locator('#ge-text-content').fill('Durable photo edit');
|
||||
await page.locator('#ge-text-size').fill('42');
|
||||
await page.locator('#ge-text-size').press('Enter');
|
||||
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Durable photo edit' })).toBeVisible();
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
|
||||
await dragOnCanvas(page, { x: 0.08, y: 0.10 }, { x: 0.92, y: 0.88 });
|
||||
await expect(page.locator('.ge-crop-apply-btn')).toBeVisible();
|
||||
await page.locator('.ge-crop-apply-btn').click();
|
||||
const cropped = await editorState(page);
|
||||
expect(cropped.dimensions[0]).toBeLessThan(640);
|
||||
expect(cropped.dimensions[1]).toBeLessThan(480);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).dimensions).toEqual([640, 480]);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect((await editorState(page)).dimensions).toEqual(cropped.dimensions);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
const beforeReopen = await editorState(page);
|
||||
const beforeReopenPixels = await flattenedPixelDigest(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.dimensions).toEqual(beforeReopen.dimensions);
|
||||
expect(reopened.activeLayerId).toBe(beforeReopen.activeLayerId);
|
||||
expect(reopened.layers).toEqual(beforeReopen.layers);
|
||||
expect(await flattenedPixelDigest(page)).toEqual(beforeReopenPixels);
|
||||
|
||||
await openExportDialog(page);
|
||||
await page.locator('[data-format="png"]').click();
|
||||
await page.locator('#ge-export-width').fill('320');
|
||||
const expectedHeight = Number(await page.locator('#ge-export-height').inputValue());
|
||||
await page.locator('#ge-export-filename').fill('photo-editor-release-gate');
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.locator('.ge-export-dialog button[type="submit"]').click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('photo-editor-release-gate.png');
|
||||
const bytes = fs.readFileSync(await download.path());
|
||||
expect(bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
|
||||
expect(bytes.readUInt32BE(16)).toBe(320);
|
||||
expect(bytes.readUInt32BE(20)).toBe(expectedHeight);
|
||||
const resizedComparison = await compareExportPixels(page, bytes, { width: 320, height: expectedHeight });
|
||||
expect(resizedComparison.meanAbsoluteError).toBeLessThanOrEqual(1);
|
||||
expect(resizedComparison.maximumError).toBeLessThanOrEqual(32);
|
||||
expect(resizedComparison.changedPixelRatio).toBeLessThanOrEqual(0.08);
|
||||
|
||||
await openExportDialog(page);
|
||||
await page.locator('[data-format="png"]').click();
|
||||
await page.locator('#ge-export-filename').fill('photo-editor-native-fidelity');
|
||||
const nativeDownloadPromise = page.waitForEvent('download');
|
||||
await page.locator('.ge-export-dialog button[type="submit"]').click();
|
||||
const nativeDownload = await nativeDownloadPromise;
|
||||
const nativeBytes = fs.readFileSync(await nativeDownload.path());
|
||||
expect(await encodedImagePixelDigest(page, nativeBytes)).toEqual(beforeReopenPixels);
|
||||
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
|
||||
|
||||
test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Gesture cancellation');
|
||||
const canvas = page.locator('.ge-main-canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
|
||||
await page.mouse.move(box.x + 40, box.y + 35);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
|
||||
expect(await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { cropping: state.cropping, rect: state.cropRect };
|
||||
})).toMatchObject({ cropping: true, rect: { w: 150, h: 115 } });
|
||||
await page.locator('.ge-tool-btn[data-tool="move"]').dispatchEvent('click');
|
||||
expect(await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { tool: state.tool, cropping: state.cropping, moving: state.cropMoving, rect: state.cropRect };
|
||||
})).toEqual({ tool: 'move', cropping: false, moving: false, rect: null });
|
||||
await page.mouse.up();
|
||||
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
|
||||
const beforeMove = await editorState(page);
|
||||
const bounds = beforeMove.selection.bounds;
|
||||
await page.mouse.move(
|
||||
box.x + bounds.x + bounds.width / 2,
|
||||
box.y + bounds.y + bounds.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + bounds.x + bounds.width / 2 + 35, box.y + bounds.y + bounds.height / 2 + 20);
|
||||
expect((await editorState(page)).selection.bounds).not.toEqual(bounds);
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').dispatchEvent('click');
|
||||
const afterSwitch = await editorState(page);
|
||||
expect(afterSwitch.selection.bounds).toEqual(bounds);
|
||||
expect(afterSwitch.undo).toBe(beforeMove.undo);
|
||||
expect(await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { tool: state.tool, selectionMoving: state.selectionMoving, marqueeActive: state.marqueeActive };
|
||||
})).toEqual({ tool: 'brush', selectionMoving: false, marqueeActive: false });
|
||||
await page.mouse.up();
|
||||
expect((await editorState(page)).selection.bounds).toEqual(bounds);
|
||||
});
|
||||
|
||||
test('Escape cancels an active crop without changing the document', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Escape crop cancellation');
|
||||
const canvas = page.locator('.ge-main-canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
|
||||
await page.mouse.move(box.x + 40, box.y + 35);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.mouse.up();
|
||||
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { cropping: state.cropping, moving: state.cropMoving, rect: state.cropRect };
|
||||
})).toEqual({ cropping: false, moving: false, rect: null });
|
||||
});
|
||||
|
||||
test('touch crop and selection gestures complete through the shared lifecycle', async ({ browser, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch gestures');
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
|
||||
type,
|
||||
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
|
||||
});
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
|
||||
await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30);
|
||||
await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145);
|
||||
await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145);
|
||||
await expect(page.locator('.ge-crop-apply')).toBeVisible();
|
||||
expect(await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.cropRect;
|
||||
})).toMatchObject({ w: 155, h: 115 });
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
|
||||
await touch('touchStart', canvasBox.x + 45, canvasBox.y + 40);
|
||||
await touch('touchMove', canvasBox.x + 165, canvasBox.y + 125);
|
||||
await touch('touchEnd', canvasBox.x + 165, canvasBox.y + 125);
|
||||
expect((await editorState(page)).selection.bounds).toBeTruthy();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('touch brush paints and undoes as one stroke', async ({ browser, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch brush');
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
|
||||
type,
|
||||
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
|
||||
});
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
const before = await flattenedPixelDigest(page);
|
||||
await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5);
|
||||
await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
|
||||
await touch('touchEnd', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
|
||||
const after = await flattenedPixelDigest(page);
|
||||
expect(after).not.toEqual(before);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(before);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('touch cancellation rolls back a partial brush stroke', async ({ browser, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch cancellation');
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
const before = await flattenedPixelDigest(page);
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: canvasBox.x + 70, y: canvasBox.y + 110, id: 1 }],
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ x: canvasBox.x + 230, y: canvasBox.y + 110, id: 1 }],
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] });
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.drawing;
|
||||
})).toBe(false);
|
||||
expect(await flattenedPixelDigest(page)).toEqual(before);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('pen input completes a crop gesture', async ({ page, context, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP pen injection');
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Pen crop gesture');
|
||||
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed', pointerType: 'pen', button: 'left', buttons: 1,
|
||||
clickCount: 1, x: box.x + 30, y: box.y + 25,
|
||||
});
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseMoved', pointerType: 'pen', button: 'none', buttons: 1,
|
||||
x: box.x + 175, y: box.y + 130,
|
||||
});
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased', pointerType: 'pen', button: 'left', buttons: 0,
|
||||
clickCount: 1, x: box.x + 175, y: box.y + 130,
|
||||
});
|
||||
await expect(page.locator('.ge-crop-apply')).toBeVisible();
|
||||
expect(await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { cropping: state.cropping, rect: state.cropRect };
|
||||
})).toEqual({ cropping: false, rect: { x: 30, y: 25, w: 145, h: 105 } });
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
const fs = require('node:fs');
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const {
|
||||
compareExportPixels,
|
||||
encodedImagePixelDigest,
|
||||
editorState,
|
||||
flattenedPixelDigest,
|
||||
openBlankEditor,
|
||||
openExportDialog,
|
||||
reopenDraft,
|
||||
waitForDraft,
|
||||
} = require('./helpers.js');
|
||||
|
||||
async function seedGradient(page) {
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
const gradient = layer.ctx.createLinearGradient(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
gradient.addColorStop(0, '#d43f67');
|
||||
gradient.addColorStop(0.5, '#4aa884');
|
||||
gradient.addColorStop(1, '#3868d4');
|
||||
layer.ctx.fillStyle = gradient;
|
||||
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
window.galleryEditorComposite?.();
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadFormat(page, format, filename) {
|
||||
await openExportDialog(page);
|
||||
await page.locator(`[data-format="${format}"]`).click();
|
||||
await page.locator('#ge-export-filename').fill(filename);
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.locator('.ge-export-dialog button[type="submit"]').click();
|
||||
const download = await downloadPromise;
|
||||
return {
|
||||
name: download.suggestedFilename(),
|
||||
bytes: fs.readFileSync(await download.path()),
|
||||
};
|
||||
}
|
||||
|
||||
async function addAdjustment(page, type) {
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await expect(page.locator('.ge-add-layer-menu')).toBeVisible();
|
||||
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${type}"]`).click();
|
||||
await expect(page.locator('.ge-adj-popup')).toBeVisible();
|
||||
}
|
||||
|
||||
async function exportCurrentPng(page) {
|
||||
const dataUrl = await page.evaluate(async () => {
|
||||
const editor = await import('/static/js/galleryEditor.js');
|
||||
return editor.exportPNG();
|
||||
});
|
||||
return Buffer.from(dataUrl.split(',', 2)[1], 'base64');
|
||||
}
|
||||
|
||||
test('every retained adjustment family exports the visible composite exactly', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 128, height: 96 }, 'Adjustment export matrix');
|
||||
await seedGradient(page);
|
||||
|
||||
const cases = [
|
||||
['brightness-contrast', 'brightness', '35'],
|
||||
['exposure', 'exposure', '35'],
|
||||
['white-balance', 'temperature', '30'],
|
||||
['hue-saturation', 'hue', '30'],
|
||||
['vibrance', 'vibrance', '45'],
|
||||
['black-white', 'red', '60'],
|
||||
['shadows-highlights', 'shadows', '35'],
|
||||
['levels', 'inBlack', '20'],
|
||||
['color-balance', 'shadows-r', '35'],
|
||||
['selective-color', 'cyan', '30'],
|
||||
];
|
||||
|
||||
for (const [type, key, value] of cases) {
|
||||
await addAdjustment(page, type);
|
||||
const control = page.locator(`.ge-adj-row input[data-key="${key}"]`);
|
||||
await control.fill(value);
|
||||
await control.dispatchEvent('input');
|
||||
await page.locator('[data-adj-action="ok"]').click();
|
||||
const visibleDigest = await flattenedPixelDigest(page);
|
||||
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(visibleDigest);
|
||||
}
|
||||
|
||||
await addAdjustment(page, 'curves');
|
||||
const curve = page.locator('.ge-curves-canvas');
|
||||
const curveBox = await curve.boundingBox();
|
||||
await page.mouse.click(curveBox.x + curveBox.width * 0.5, curveBox.y + curveBox.height * 0.28);
|
||||
await page.locator('[data-adj-action="ok"]').click();
|
||||
const curveDigest = await flattenedPixelDigest(page);
|
||||
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(curveDigest);
|
||||
|
||||
await addAdjustment(page, 'gradient-map');
|
||||
await page.locator('[data-gradient-key="shadows"]').fill('#102030');
|
||||
await page.locator('[data-gradient-key="shadows"]').dispatchEvent('input');
|
||||
await page.locator('[data-adj-action="ok"]').click();
|
||||
const gradientDigest = await flattenedPixelDigest(page);
|
||||
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(gradientDigest);
|
||||
});
|
||||
|
||||
test('composited correction exports preserve pixels and format metadata', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity');
|
||||
await seedGradient(page);
|
||||
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
|
||||
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('35');
|
||||
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
|
||||
await page.locator('[data-adj-action="ok"]').click();
|
||||
const renderedPixels = await flattenedPixelDigest(page);
|
||||
|
||||
const png = await downloadFormat(page, 'png', 'correction');
|
||||
expect(png.name).toBe('correction.png');
|
||||
expect(png.bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
|
||||
expect(png.bytes.readUInt32BE(16)).toBe(160);
|
||||
expect(png.bytes.readUInt32BE(20)).toBe(120);
|
||||
expect(await encodedImagePixelDigest(page, png.bytes)).toEqual(renderedPixels);
|
||||
|
||||
const jpeg = await downloadFormat(page, 'jpeg', 'correction-jpeg');
|
||||
expect(jpeg.name).toBe('correction-jpeg.jpg');
|
||||
expect(jpeg.bytes[0]).toBe(0xff);
|
||||
expect(jpeg.bytes[1]).toBe(0xd8);
|
||||
|
||||
const webp = await downloadFormat(page, 'webp', 'correction-webp');
|
||||
expect(webp.name).toBe('correction-webp.webp');
|
||||
expect(webp.bytes.subarray(0, 4).toString('ascii')).toBe('RIFF');
|
||||
expect(webp.bytes.subarray(8, 12).toString('ascii')).toBe('WEBP');
|
||||
|
||||
const comparison = await compareExportPixels(page, png.bytes, { width: 160, height: 120 });
|
||||
expect(comparison.meanAbsoluteError).toBe(0);
|
||||
expect(comparison.maximumError).toBe(0);
|
||||
|
||||
const exposure = (await editorState(page)).layers.find(layer => layer.kind === 'adjustment');
|
||||
const exposureRow = page.locator(`.ge-layer-item[data-layer-id="${exposure.id}"]`);
|
||||
await exposureRow.locator('.ge-layer-opacity').fill('62');
|
||||
await exposureRow.locator('.ge-layer-opacity').dispatchEvent('input');
|
||||
const opacityState = await editorState(page);
|
||||
expect(opacityState.layers.find(layer => layer.id === exposure.id).opacity).toBeCloseTo(.62, 2);
|
||||
const opacityPixels = await flattenedPixelDigest(page);
|
||||
|
||||
const opacityPng = await downloadFormat(page, 'png', 'correction-opacity');
|
||||
expect(await encodedImagePixelDigest(page, opacityPng.bytes)).toEqual(opacityPixels);
|
||||
expect(await compareExportPixels(page, opacityPng.bytes, { width: 160, height: 120 })).toEqual({
|
||||
meanAbsoluteError: 0,
|
||||
maximumError: 0,
|
||||
changedPixelRatio: 0,
|
||||
});
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.layers.find(layer => layer.id === exposure.id).opacity).toBeCloseTo(.62, 2);
|
||||
expect(await flattenedPixelDigest(page)).toEqual(opacityPixels);
|
||||
});
|
||||
|
||||
test('export preview clears stale matte pixels when transparency changes', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 160, height: 120 }, 'Export preview');
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
for (const layer of state.layers) layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
layer.ctx.fillStyle = '#2671c8';
|
||||
layer.ctx.fillRect(40, 30, 80, 60);
|
||||
window.galleryEditorComposite?.();
|
||||
});
|
||||
|
||||
await openExportDialog(page);
|
||||
const previewPixel = () => page.locator('.ge-export-preview').evaluate(canvas => (
|
||||
[...canvas.getContext('2d').getImageData(0, 0, 1, 1).data]
|
||||
));
|
||||
expect(await previewPixel()).toEqual([0, 0, 0, 0]);
|
||||
|
||||
await page.locator('#ge-export-transparency').uncheck();
|
||||
await page.locator('#ge-export-matte').click();
|
||||
await page.locator('.cp-hex').fill('#d94747');
|
||||
await page.locator('.cp-hex').press('Enter');
|
||||
expect(await previewPixel()).toEqual([217, 71, 71, 255]);
|
||||
|
||||
await page.locator('#ge-export-transparency').check();
|
||||
expect(await previewPixel()).toEqual([0, 0, 0, 0]);
|
||||
await page.locator('.ge-export-close').click();
|
||||
});
|
||||
|
||||
test('closing export returns focus to the launch control', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 160, height: 120 }, 'Export focus');
|
||||
const launch = page.locator('#ge-save-menu-btn');
|
||||
await launch.focus();
|
||||
await launch.click();
|
||||
await page.locator('#ge-download').click();
|
||||
await expect(page.locator('.ge-export-dialog')).toBeVisible();
|
||||
await page.locator('.ge-export-close').click();
|
||||
await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe('ge-save-menu-btn');
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('gallery photos body uses tall viewport without cropping or stretching cards', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1200 });
|
||||
await page.route('**/api/gallery/library?**', async route => {
|
||||
const pixel = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="160" height="160"%3E%3Crect width="160" height="160" fill="%23666"/%3E%3C/svg%3E';
|
||||
const items = Array.from({ length: 60 }, (_, index) => ({
|
||||
id: `layout-${index}`,
|
||||
url: pixel,
|
||||
thumbnail_url: pixel,
|
||||
filename: `Layout ${index}.png`,
|
||||
prompt: `Layout ${index}`,
|
||||
model: 'imported',
|
||||
created_at: '2026-08-31T00:00:00Z',
|
||||
}));
|
||||
await route.fulfill({ json: { items, total: items.length, has_more: false } });
|
||||
});
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
|
||||
await page.locator('#tool-gallery-btn').click();
|
||||
await expect(page.locator('#gallery-modal')).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect(page.locator('#gallery-grid .gallery-card')).toHaveCount(61);
|
||||
await expect(page.locator('#gallery-grid')).not.toHaveClass(/gallery-just-opened/, { timeout: 2_000 });
|
||||
const layout = await page.evaluate(() => {
|
||||
const modal = document.querySelector('.gallery-modal-content').getBoundingClientRect();
|
||||
const body = document.querySelector('#gallery-modal .modal-body').getBoundingClientRect();
|
||||
const grid = document.querySelector('#gallery-grid').getBoundingClientRect();
|
||||
const upload = document.querySelector('#gallery-upload-tile').getBoundingClientRect();
|
||||
const cards = [...document.querySelectorAll('#gallery-grid .gallery-card')]
|
||||
.slice(0, 12)
|
||||
.map(card => card.getBoundingClientRect());
|
||||
return { modal, body, grid, upload, cards, viewportHeight: innerHeight };
|
||||
});
|
||||
|
||||
expect(layout.grid.height).toBeGreaterThan(layout.viewportHeight * 0.65);
|
||||
expect(layout.grid.bottom).toBeLessThanOrEqual(layout.body.bottom + 1);
|
||||
expect(Math.abs(layout.upload.width - layout.upload.height)).toBeLessThanOrEqual(2);
|
||||
for (let i = 0; i < layout.cards.length; i += 1) {
|
||||
for (let j = i + 1; j < layout.cards.length; j += 1) {
|
||||
const a = layout.cards[i];
|
||||
const b = layout.cards[j];
|
||||
const overlaps = a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||||
expect(overlaps).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const fs = require('node:fs');
|
||||
const {
|
||||
compareExportPixels,
|
||||
dragOnCanvas,
|
||||
editorState,
|
||||
flattenedPixelDigest,
|
||||
openBlankEditor,
|
||||
openExportDialog,
|
||||
reopenDraft,
|
||||
waitForDraft,
|
||||
} = require('./helpers.js');
|
||||
|
||||
async function downloadPng(page, filename) {
|
||||
await openExportDialog(page);
|
||||
await page.locator('[data-format="png"]').click();
|
||||
await page.locator('#ge-export-filename').fill(filename);
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.locator('.ge-export-dialog button[type="submit"]').click();
|
||||
const download = await downloadPromise;
|
||||
return fs.readFileSync(await download.path());
|
||||
}
|
||||
|
||||
test('group retained effects edit, toggle, reorder surface, and survive reopen', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 360, height: 240 }, 'Group effects E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
state.brushSize = 48;
|
||||
state.brushSoftness = 0;
|
||||
});
|
||||
await dragOnCanvas(page, { x: 0.25, y: 0.35 }, { x: 0.75, y: 0.65 });
|
||||
|
||||
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
|
||||
await backgroundRow.click({ modifiers: ['Control'] });
|
||||
await page.locator('#ge-group-selected').click();
|
||||
const groupId = (await editorState(page)).groups[0].id;
|
||||
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
|
||||
|
||||
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
|
||||
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
|
||||
let current = await editorState(page);
|
||||
expect(current.groups[0].effects).toHaveLength(1);
|
||||
expect(current.groups[0].effects[0].params.radius).toBe(14);
|
||||
const afterAdd = await flattenedPixelDigest(page);
|
||||
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
|
||||
|
||||
await page.locator('.ge-group-effect-sub-item .ge-adj-sub-name').click();
|
||||
await page.locator('.ge-filter-row input[data-key="radius"]').fill('22');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
current = await editorState(page);
|
||||
expect(current.groups[0].effects[0].params.radius).toBe(22);
|
||||
expect(await flattenedPixelDigest(page)).not.toEqual(afterAdd);
|
||||
|
||||
await page.locator('.ge-group-effect-sub-item .ge-layer-vis').click();
|
||||
expect((await editorState(page)).groups[0].effects[0].visible).toBe(false);
|
||||
await page.locator('.ge-group-effect-sub-item .ge-layer-vis').click();
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
const beforeReopen = await editorState(page);
|
||||
await reopenDraft(page, draftId);
|
||||
expect((await editorState(page)).groups).toEqual(beforeReopen.groups);
|
||||
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
|
||||
|
||||
await page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`).click();
|
||||
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
|
||||
test('group retained effects export the visible composite and survive reopen', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 180 }, 'Group effects export E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
state.brushSize = 64;
|
||||
state.brushSoftness = 0;
|
||||
});
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
|
||||
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
|
||||
await page.locator('#ge-group-selected').click();
|
||||
|
||||
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
|
||||
await page.locator('[data-filter-action="effect-color-overlay"]').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
|
||||
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('40');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
|
||||
const renderedPixels = await flattenedPixelDigest(page);
|
||||
const pngBytes = await downloadPng(page, 'group-effects');
|
||||
const comparison = await compareExportPixels(page, pngBytes, { width: 240, height: 180 });
|
||||
expect(comparison.meanAbsoluteError).toBe(0);
|
||||
expect(comparison.maximumError).toBe(0);
|
||||
expect(await flattenedPixelDigest(page)).toEqual(renderedPixels);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.groups[0].effects).toHaveLength(1);
|
||||
expect(reopened.groups[0].effects[0]).toMatchObject({
|
||||
type: 'color-overlay',
|
||||
params: { color: '#336699', opacity: 0.4 },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
async function compositeAlphaAtCenter(page) {
|
||||
return page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const x = Math.floor(state.imgWidth / 2);
|
||||
const y = Math.floor(state.imgHeight / 2);
|
||||
return state.documentCompositeCanvas.getContext('2d').getImageData(x, y, 1, 1).data[3];
|
||||
});
|
||||
}
|
||||
|
||||
test('group mask paints, toggles, survives history and reopen, and protects ungroup', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 420, height: 300 }, 'Group mask E2E');
|
||||
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
|
||||
await page.locator('#ge-group-selected').click();
|
||||
const groupId = (await editorState(page)).groups[0].id;
|
||||
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
|
||||
await page.locator('#ge-layer-tools .ge-group-mask-btn').click();
|
||||
|
||||
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
|
||||
await expect(page.locator('#ge-layer-tools button[title="Delete group masks before ungrouping"]')).toBeDisabled();
|
||||
let current = await editorState(page);
|
||||
expect(current.groups[0].masks).toHaveLength(1);
|
||||
expect(current.groups[0].masks[0]).toMatchObject({ mode: 'group', size: [420, 300] });
|
||||
const whiteMaskHash = current.groups[0].masks[0].pixelHash;
|
||||
expect(await compositeAlphaAtCenter(page)).toBe(255);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
state.brushSize = 90;
|
||||
state.eraserSoftness = 0;
|
||||
});
|
||||
await dragOnCanvas(page, { x: 0.46, y: 0.5 }, { x: 0.54, y: 0.5 });
|
||||
current = await editorState(page);
|
||||
const erasedMaskHash = current.groups[0].masks[0].pixelHash;
|
||||
expect(erasedMaskHash).not.toBe(whiteMaskHash);
|
||||
expect(await compositeAlphaAtCenter(page)).toBe(0);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).groups[0].masks[0].pixelHash).toBe(whiteMaskHash);
|
||||
expect(await compositeAlphaAtCenter(page)).toBe(255);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect((await editorState(page)).groups[0].masks[0].pixelHash).toBe(erasedMaskHash);
|
||||
expect(await compositeAlphaAtCenter(page)).toBe(0);
|
||||
|
||||
const maskRow = page.locator('.ge-group-mask-sub-item');
|
||||
await maskRow.locator('.ge-layer-vis').click();
|
||||
expect(await compositeAlphaAtCenter(page)).toBe(255);
|
||||
await maskRow.locator('.ge-layer-vis').click();
|
||||
expect(await compositeAlphaAtCenter(page)).toBe(0);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
const beforeReopen = await editorState(page);
|
||||
await reopenDraft(page, draftId);
|
||||
current = await editorState(page);
|
||||
expect(current.groups).toEqual(beforeReopen.groups);
|
||||
expect(await compositeAlphaAtCenter(page)).toBe(0);
|
||||
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
|
||||
|
||||
await page.locator('.ge-group-mask-sub-item button[title="Delete group mask"]').click();
|
||||
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(0);
|
||||
await groupRow.click();
|
||||
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
|
||||
expect((await editorState(page)).groups).toHaveLength(0);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
test('group drag reorders the complete subtree with undo and server persistence', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 420, height: 300 }, 'Group reorder E2E');
|
||||
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
|
||||
await page.locator('#ge-group-selected').click();
|
||||
let current = await editorState(page);
|
||||
const group = current.groups[0];
|
||||
const originalOrder = current.layers.map(layer => layer.id);
|
||||
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||
current = await editorState(page);
|
||||
const looseId = current.activeLayerId;
|
||||
expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
|
||||
|
||||
const handle = page.locator(`.ge-layer-group-row[data-group-id="${group.id}"] .ge-layer-group-drag`);
|
||||
const looseRow = page.locator(`.ge-layer-item[data-layer-id="${looseId}"]`);
|
||||
const handleBox = await handle.boundingBox();
|
||||
const looseBox = await looseRow.boundingBox();
|
||||
const dragReadiness = await page.evaluate(async ({ groupId, point }) => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const { groupSiblingUnits } = await import('/static/js/editor/layer-groups.js');
|
||||
const group = state.layerGroups.find(item => item.id === groupId);
|
||||
const hit = document.elementFromPoint(point.x, point.y);
|
||||
return {
|
||||
siblingIds: groupSiblingUnits(state, group.parentId || null).map(unit => unit.id),
|
||||
hitHandle: !!hit?.closest('.ge-layer-group-drag'),
|
||||
};
|
||||
}, { groupId: group.id, point: { x: handleBox.x + handleBox.width / 2, y: handleBox.y + handleBox.height / 2 } });
|
||||
expect(dragReadiness.hitHandle, JSON.stringify(dragReadiness)).toBe(true);
|
||||
expect(dragReadiness.siblingIds).toEqual([group.id, looseId]);
|
||||
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(looseBox.x + looseBox.width / 2, looseBox.y + 1, { steps: 8 });
|
||||
await expect(page.locator('.ge-group-drop-line')).toBeVisible();
|
||||
await page.mouse.up();
|
||||
|
||||
current = await editorState(page);
|
||||
expect(current.layers.map(layer => layer.id)).toEqual([looseId, ...originalOrder]);
|
||||
expect(current.groups[0].layerIds).toEqual(group.layerIds);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([looseId, ...originalOrder]);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
const beforeReopen = await editorState(page);
|
||||
await reopenDraft(page, draftId);
|
||||
current = await editorState(page);
|
||||
expect(current.layers.map(layer => layer.id)).toEqual(beforeReopen.layers.map(layer => layer.id));
|
||||
expect(current.groups).toEqual(beforeReopen.groups);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
|
||||
test('mobile layer drag handle supports long-press reorder', async ({ browser, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder');
|
||||
await page.locator('.ge-layers-header').click();
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
|
||||
const before = await editorState(page);
|
||||
// State is bottom-to-top; the panel is top-to-bottom.
|
||||
const dragId = before.layers[0].id;
|
||||
const targetId = before.layers[before.layers.length - 1].id;
|
||||
const bottomHandle = page.locator(`.ge-layer-item[data-layer-id="${dragId}"] .ge-layer-drag`);
|
||||
const topRow = page.locator(`.ge-layer-item[data-layer-id="${targetId}"]`);
|
||||
await bottomHandle.scrollIntoViewIfNeeded();
|
||||
const handleBox = await bottomHandle.boundingBox();
|
||||
const topBox = await topRow.boundingBox();
|
||||
expect(handleBox.width).toBeGreaterThanOrEqual(24);
|
||||
const point = { x: handleBox.x + handleBox.width / 2, y: handleBox.y + handleBox.height / 2 };
|
||||
const hitHandle = await page.evaluate(({ x, y }) => {
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
return !!hit?.closest('.ge-layer-drag');
|
||||
}, point);
|
||||
expect(hitHandle).toBe(true);
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: point.x, y: point.y, id: 1, radiusX: 8, radiusY: 8 }],
|
||||
});
|
||||
await page.waitForTimeout(450);
|
||||
await expect(page.locator(`.ge-layer-item[data-layer-id="${dragId}"]`)).toHaveClass(/dragging/);
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ x: point.x, y: topBox.y + 1, id: 1, radiusX: 8, radiusY: 8 }],
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchEnd',
|
||||
touchPoints: [],
|
||||
});
|
||||
await page.waitForTimeout(120);
|
||||
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([targetId, dragId]);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
const { expect } = require('@playwright/test');
|
||||
|
||||
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
|
||||
let lastError = null;
|
||||
for (let pageAttempt = 0; pageAttempt < 3; pageAttempt += 1) {
|
||||
try {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
|
||||
await page.evaluate(async () => {
|
||||
window.__photoEditorImport = async path => {
|
||||
let lastImportError = null;
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
const url = attempt === 0 ? path : `${path}?e2e_retry=${attempt}-${Date.now()}`;
|
||||
try { return await import(url); } catch (error) { lastImportError = error; }
|
||||
await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1)));
|
||||
}
|
||||
throw lastImportError;
|
||||
};
|
||||
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
|
||||
gallery.openGallery();
|
||||
});
|
||||
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
|
||||
await page.locator('#gallery-editor-tab').click();
|
||||
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
|
||||
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
|
||||
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
|
||||
}, { size, name });
|
||||
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await page.goto('about:blank');
|
||||
await page.waitForTimeout(250 * (pageAttempt + 1));
|
||||
}
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.layers.length;
|
||||
})).toBe(2);
|
||||
}
|
||||
|
||||
async function editorState(page) {
|
||||
return page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const hashCanvas = canvas => {
|
||||
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < data.length; index += Math.max(4, Math.floor(data.length / 20_000 / 4) * 4)) {
|
||||
const alpha = data[index + 3] / 255;
|
||||
hash ^= Math.round(data[index] * alpha);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
hash ^= Math.round(data[index + 1] * alpha);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
hash ^= Math.round(data[index + 2] * alpha);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
hash ^= data[index + 3];
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
};
|
||||
const selectionBounds = canvas => {
|
||||
if (!canvas) return null;
|
||||
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let left = canvas.width, top = canvas.height, right = -1, bottom = -1;
|
||||
for (let y = 0; y < canvas.height; y += 1) for (let x = 0; x < canvas.width; x += 1) {
|
||||
if (data[(y * canvas.width + x) * 4 + 3] < 1) continue;
|
||||
left = Math.min(left, x); top = Math.min(top, y);
|
||||
right = Math.max(right, x); bottom = Math.max(bottom, y);
|
||||
}
|
||||
return right < left ? null : { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
|
||||
};
|
||||
return {
|
||||
documentRenderReady: !!state.documentRenderReady,
|
||||
dimensions: [state.imgWidth, state.imgHeight],
|
||||
activeLayerId: state.activeLayerId,
|
||||
selectedLayerIds: [...(state.selectedLayerIds || [])],
|
||||
groups: (state.layerGroups || []).map(group => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
layerIds: [...group.layerIds],
|
||||
parentId: group.parentId || null,
|
||||
visible: group.visible !== false,
|
||||
opacity: group.opacity,
|
||||
blendMode: group.blendMode,
|
||||
locked: !!group.locked,
|
||||
collapsed: !!group.collapsed,
|
||||
activeMaskId: group.activeMaskId || null,
|
||||
effects: (group.effects || []).map(effect => ({
|
||||
id: effect.id,
|
||||
type: effect.type,
|
||||
name: effect.name,
|
||||
visible: effect.visible !== false,
|
||||
opacity: effect.opacity,
|
||||
params: JSON.parse(JSON.stringify(effect.params || {})),
|
||||
})),
|
||||
masks: (group.masks || []).map(mask => ({
|
||||
id: mask.id,
|
||||
name: mask.name,
|
||||
visible: mask.visible !== false,
|
||||
density: Number.isFinite(Number(mask.density)) ? mask.density : 1,
|
||||
feather: Number.isFinite(Number(mask.feather)) ? mask.feather : 0,
|
||||
mode: mask.mode,
|
||||
size: [mask.canvas.width, mask.canvas.height],
|
||||
pixelHash: hashCanvas(mask.canvas),
|
||||
})),
|
||||
})),
|
||||
layers: state.layers.map(layer => ({
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
kind: layer.kind || 'raster',
|
||||
effects: (layer.effects || []).map(effect => ({
|
||||
id: effect.id,
|
||||
type: effect.type,
|
||||
name: effect.name,
|
||||
visible: effect.visible !== false,
|
||||
opacity: effect.opacity,
|
||||
mask: effect.mask ? {
|
||||
visible: effect.mask.visible !== false,
|
||||
size: [effect.mask.canvas?.width || effect.mask.canvasW, effect.mask.canvas?.height || effect.mask.canvasH],
|
||||
} : null,
|
||||
params: JSON.parse(JSON.stringify(effect.params || {})),
|
||||
})),
|
||||
visible: layer.visible !== false,
|
||||
opacity: layer.opacity,
|
||||
locked: !!layer.locked,
|
||||
locks: {
|
||||
pixels: !!layer.locks?.pixels,
|
||||
transparency: !!layer.locks?.transparency,
|
||||
position: !!layer.locks?.position,
|
||||
},
|
||||
clipped: !!layer.clipped,
|
||||
size: [layer.canvas.width, layer.canvas.height],
|
||||
offset: state.layerOffsets.get(layer.id) || { x: 0, y: 0 },
|
||||
pixelHash: hashCanvas(layer.canvas),
|
||||
text: layer.text ? {
|
||||
content: layer.text.content,
|
||||
fontSize: layer.text.fontSize,
|
||||
fontFamily: layer.text.fontFamily,
|
||||
lineHeight: layer.text.lineHeight,
|
||||
letterSpacing: layer.text.letterSpacing,
|
||||
frameWidth: layer.text.frameWidth,
|
||||
frameHeight: layer.text.frameHeight,
|
||||
verticalAlign: layer.text.verticalAlign,
|
||||
autoWidth: layer.text.autoWidth,
|
||||
transform: { ...layer.text.transform },
|
||||
} : null,
|
||||
shape: layer.shape ? {
|
||||
type: layer.shape.type,
|
||||
width: layer.shape.width,
|
||||
height: layer.shape.height,
|
||||
fillColor: layer.shape.fillColor,
|
||||
fillType: layer.shape.fillType,
|
||||
gradientStart: layer.shape.gradientStart,
|
||||
gradientMid: layer.shape.gradientMid,
|
||||
gradientMidEnabled: layer.shape.gradientMidEnabled,
|
||||
gradientMidPosition: layer.shape.gradientMidPosition,
|
||||
gradientEnd: layer.shape.gradientEnd,
|
||||
gradientStops: layer.shape.gradientStops ? layer.shape.gradientStops.map(stop => ({ ...stop })) : undefined,
|
||||
gradientAngle: layer.shape.gradientAngle,
|
||||
strokeColor: layer.shape.strokeColor,
|
||||
strokeWidth: layer.shape.strokeWidth,
|
||||
cornerRadius: layer.shape.cornerRadius,
|
||||
sides: layer.shape.sides,
|
||||
transform: { ...layer.shape.transform },
|
||||
} : null,
|
||||
adjustment: layer.adjustment ? JSON.parse(JSON.stringify(layer.adjustment)) : null,
|
||||
placed: layer.kind === 'placed' && layer.placed?.sourceCanvas ? {
|
||||
sourceSize: [layer.placed.sourceCanvas.width, layer.placed.sourceCanvas.height],
|
||||
sourceName: layer.placed.sourceName,
|
||||
matrix: [...layer.placed.matrix],
|
||||
sourcePixelHash: hashCanvas(layer.placed.sourceCanvas),
|
||||
} : null,
|
||||
masks: (layer.masks || []).map(mask => ({
|
||||
id: mask.id,
|
||||
mode: mask.mode,
|
||||
space: mask.space,
|
||||
linked: mask.mode === 'layer' ? mask.linked !== false : true,
|
||||
density: Number.isFinite(Number(mask.density)) ? mask.density : 1,
|
||||
feather: Number.isFinite(Number(mask.feather)) ? mask.feather : 0,
|
||||
offset: { x: Number(mask.offset?.x) || 0, y: Number(mask.offset?.y) || 0 },
|
||||
size: [mask.canvas.width, mask.canvas.height],
|
||||
pixelHash: hashCanvas(mask.canvas),
|
||||
})),
|
||||
})),
|
||||
guides: state.guides,
|
||||
draftId: state.draftId,
|
||||
undo: state.undoStack.length,
|
||||
redo: state.redoStack.length,
|
||||
quickMaskActive: !!state.quickMaskActive,
|
||||
selection: state.wandMask ? {
|
||||
space: state.wandMaskSpace,
|
||||
source: state.selectionSource,
|
||||
size: [state.wandMask.width, state.wandMask.height],
|
||||
pixelHash: hashCanvas(state.wandMask),
|
||||
bounds: selectionBounds(state.wandMask),
|
||||
} : null,
|
||||
savedSelections: (state.savedSelections || []).map(selection => ({
|
||||
id: selection.id,
|
||||
name: selection.name,
|
||||
size: [selection.canvas.width, selection.canvas.height],
|
||||
pixelHash: hashCanvas(selection.canvas),
|
||||
bounds: selectionBounds(selection.canvas),
|
||||
})),
|
||||
lastSelection: state.lastSelection?.canvas ? {
|
||||
pixelHash: hashCanvas(state.lastSelection.canvas),
|
||||
bounds: selectionBounds(state.lastSelection.canvas),
|
||||
} : null,
|
||||
persistIdle: !state.persistTimer && !state.persistInFlight,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function dragOnCanvas(page, start, end) {
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
if (!box) throw new Error('Canvas has no visible bounds');
|
||||
await page.mouse.move(box.x + box.width * start.x, box.y + box.height * start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * end.x, box.y + box.height * end.y, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function waitForDraft(page) {
|
||||
await expect.poll(async () => {
|
||||
const current = await editorState(page);
|
||||
return !!current.draftId && current.persistIdle;
|
||||
}, { timeout: 15_000 }).toBe(true);
|
||||
return (await editorState(page)).draftId;
|
||||
}
|
||||
|
||||
async function reopenDraft(page, draftId) {
|
||||
await page.evaluate(async id => {
|
||||
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
|
||||
window.__galleryAllowCloseEditor = true;
|
||||
editor.closeEditor();
|
||||
await editor.openEditor(null, null, null, 'Reopened E2E document', id);
|
||||
}, draftId);
|
||||
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
|
||||
await expect.poll(async () => (await editorState(page)).draftId).toBe(draftId);
|
||||
await expect.poll(async () => (await editorState(page)).documentRenderReady, {
|
||||
timeout: 20_000,
|
||||
}).toBe(true);
|
||||
}
|
||||
|
||||
async function openExportDialog(page) {
|
||||
await page.locator('#ge-save-menu-btn').click();
|
||||
await page.locator('#ge-download').click();
|
||||
await expect(page.locator('.ge-export-dialog')).toBeVisible();
|
||||
}
|
||||
|
||||
async function flattenedPixelDigest(page, size = null) {
|
||||
return page.evaluate(async targetSize => {
|
||||
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
|
||||
const image = new Image();
|
||||
image.src = editor.exportPNG();
|
||||
await image.decode();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = targetSize?.width || image.naturalWidth;
|
||||
canvas.height = targetSize?.height || image.naturalHeight;
|
||||
const context = canvas.getContext('2d');
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
const raw = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const pixels = new Uint8ClampedArray(raw.length);
|
||||
for (let index = 0; index < raw.length; index += 4) {
|
||||
const alpha = raw[index + 3] / 255;
|
||||
pixels[index] = Math.round(raw[index] * alpha);
|
||||
pixels[index + 1] = Math.round(raw[index + 1] * alpha);
|
||||
pixels[index + 2] = Math.round(raw[index + 2] * alpha);
|
||||
pixels[index + 3] = raw[index + 3];
|
||||
}
|
||||
const digest = await crypto.subtle.digest('SHA-256', pixels);
|
||||
return {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
sha256: Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''),
|
||||
};
|
||||
}, size);
|
||||
}
|
||||
|
||||
async function encodedImagePixelDigest(page, bytes) {
|
||||
return page.evaluate(async base64 => {
|
||||
const binary = atob(base64);
|
||||
const encoded = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
const bitmap = await createImageBitmap(new Blob([encoded], { type: 'image/png' }));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
const raw = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const pixels = new Uint8ClampedArray(raw.length);
|
||||
for (let index = 0; index < raw.length; index += 4) {
|
||||
const alpha = raw[index + 3] / 255;
|
||||
pixels[index] = Math.round(raw[index] * alpha);
|
||||
pixels[index + 1] = Math.round(raw[index + 1] * alpha);
|
||||
pixels[index + 2] = Math.round(raw[index + 2] * alpha);
|
||||
pixels[index + 3] = raw[index + 3];
|
||||
}
|
||||
const digest = await crypto.subtle.digest('SHA-256', pixels);
|
||||
return {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
sha256: Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''),
|
||||
};
|
||||
}, bytes.toString('base64'));
|
||||
}
|
||||
|
||||
async function compareExportPixels(page, bytes, size) {
|
||||
return page.evaluate(async ({ base64, targetSize }) => {
|
||||
const decode = async source => {
|
||||
const image = new Image();
|
||||
image.src = source;
|
||||
await image.decode();
|
||||
return image;
|
||||
};
|
||||
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
|
||||
const expectedImage = await decode(editor.exportPNG());
|
||||
const binary = atob(base64);
|
||||
const actualImage = await decode(URL.createObjectURL(new Blob([
|
||||
Uint8Array.from(binary, character => character.charCodeAt(0)),
|
||||
], { type: 'image/png' })));
|
||||
const pixels = image => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = targetSize.width;
|
||||
canvas.height = targetSize.height;
|
||||
const context = canvas.getContext('2d');
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
};
|
||||
const expected = pixels(expectedImage);
|
||||
const actual = pixels(actualImage);
|
||||
URL.revokeObjectURL(actualImage.src);
|
||||
let total = 0;
|
||||
let maximum = 0;
|
||||
let changedPixels = 0;
|
||||
for (let index = 0; index < expected.length; index += 4) {
|
||||
const expectedAlpha = expected[index + 3] / 255;
|
||||
const actualAlpha = actual[index + 3] / 255;
|
||||
let pixelMaximum = Math.abs(expected[index + 3] - actual[index + 3]);
|
||||
total += pixelMaximum;
|
||||
for (let channel = 0; channel < 3; channel += 1) {
|
||||
const delta = Math.abs(
|
||||
expected[index + channel] * expectedAlpha - actual[index + channel] * actualAlpha,
|
||||
);
|
||||
total += delta;
|
||||
pixelMaximum = Math.max(pixelMaximum, delta);
|
||||
}
|
||||
maximum = Math.max(maximum, pixelMaximum);
|
||||
if (pixelMaximum > 1) changedPixels += 1;
|
||||
}
|
||||
return {
|
||||
meanAbsoluteError: total / expected.length,
|
||||
maximumError: maximum,
|
||||
changedPixelRatio: changedPixels / (expected.length / 4),
|
||||
};
|
||||
}, { base64: bytes.toString('base64'), targetSize: size });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compareExportPixels,
|
||||
dragOnCanvas,
|
||||
encodedImagePixelDigest,
|
||||
editorState,
|
||||
flattenedPixelDigest,
|
||||
openBlankEditor,
|
||||
openExportDialog,
|
||||
reopenDraft,
|
||||
waitForDraft,
|
||||
};
|
||||
@@ -0,0 +1,249 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor, editorState, dragOnCanvas } = require('./helpers');
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// The isolated editor server has no logged-in notification owner.
|
||||
await page.route('**/api/tasks/notification-logs*', route => route.fulfill({
|
||||
json: { logs: [], notifications: [] },
|
||||
}));
|
||||
});
|
||||
|
||||
test('top toolbar stays on one row and its menus remain clickable at narrow widths', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
const tip = page.getByRole('button', { name: 'Got it', exact: true });
|
||||
if (await tip.isVisible()) await tip.click();
|
||||
for (const width of [1280, 900, 600, 390]) {
|
||||
await page.setViewportSize({ width, height: 850 });
|
||||
await expect.poll(() => page.locator('.ge-topbar').evaluate(bar => {
|
||||
const left = bar.querySelector('.ge-topbar-left').getBoundingClientRect();
|
||||
const right = bar.querySelector('.ge-topbar-right').getBoundingClientRect();
|
||||
return Math.abs((left.top + left.bottom) / 2 - (right.top + right.bottom) / 2);
|
||||
})).toBeLessThan(2);
|
||||
await page.locator('#ge-save-menu-btn').scrollIntoViewIfNeeded();
|
||||
await expect(page.locator('#ge-save-menu-btn')).toHaveCount(1);
|
||||
await expect(page.locator('#ge-draft-status')).toHaveCount(1);
|
||||
const status = page.locator('#ge-save-menu-btn > #ge-draft-status');
|
||||
await expect(status).toBeVisible();
|
||||
await expect(page.locator('#ge-save-menu-btn .ge-save-state-icon:not([hidden])')).toHaveCount(1);
|
||||
await expect(status).toBeVisible();
|
||||
await expect(page.locator('.ge-topbar-right > #ge-draft-status')).toHaveCount(0);
|
||||
await page.locator('#ge-image-menu-btn').click();
|
||||
await expect(page.locator('#ge-image-menu')).toBeVisible();
|
||||
await page.locator('[data-image-action="rotate-90"]').click();
|
||||
await expect(page.locator('#ge-image-menu')).toBeHidden();
|
||||
await page.screenshot({ path: `/tmp/editor-toolbar-${width}.png` });
|
||||
}
|
||||
});
|
||||
|
||||
test('committed paint refreshes the layer thumbnail and flashes only the edited row', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
const activeId = (await editorState(page)).activeLayerId;
|
||||
const row = page.locator(`.ge-layer-item[data-layer-id="${activeId}"]`);
|
||||
const before = await row.locator('.ge-layer-inline-thumb').evaluate(canvas => canvas.toDataURL());
|
||||
await page.keyboard.press('b');
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
|
||||
await expect(row).toHaveClass(/ge-layer-action-flash/);
|
||||
expect(await row.locator('.ge-layer-inline-thumb').evaluate(canvas => canvas.toDataURL())).not.toBe(before);
|
||||
await expect(page.locator('.ge-layer-action-flash')).toHaveCount(1);
|
||||
await expect(row).not.toHaveClass(/ge-layer-action-flash/, { timeout: 2000 });
|
||||
});
|
||||
|
||||
test('filter Escape restores pixels and preserves redo history', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await page.keyboard.press('b');
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
|
||||
await dragOnCanvas(page, { x: 0.7, y: 0.2 }, { x: 0.2, y: 0.7 });
|
||||
await page.keyboard.press('Control+z');
|
||||
const before = await editorState(page);
|
||||
await page.locator('#ge-filter-menu-btn').click();
|
||||
await page.locator('[data-filter-action="blur-gaussian"]').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.ge-filter-modal')).toHaveCount(0);
|
||||
const after = await editorState(page);
|
||||
expect(after.layers).toEqual(before.layers);
|
||||
expect(after.undo).toBe(before.undo);
|
||||
expect(after.redo).toBe(before.redo);
|
||||
});
|
||||
|
||||
test('focused fields own undo, selection and clipboard without changing layers', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('[data-tool="text"]').click();
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.click(box.x + 80, box.y + 80);
|
||||
const input = page.locator('.ge-direct-text-editor');
|
||||
await input.fill('Editable text');
|
||||
await input.press('Control+Enter');
|
||||
// A tool's ordinary text input must also retain native clipboard ownership.
|
||||
await page.locator('#ge-text-frame-width').focus();
|
||||
const before = await editorState(page);
|
||||
await page.keyboard.press('Control+z');
|
||||
await page.keyboard.press('Control+j');
|
||||
await page.keyboard.press('Control+a');
|
||||
await page.keyboard.press('Control+c');
|
||||
await page.evaluate(() => {
|
||||
document.activeElement.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true }));
|
||||
});
|
||||
const after = await editorState(page);
|
||||
expect(after.layers).toEqual(before.layers);
|
||||
expect(after.undo).toBe(before.undo);
|
||||
expect(after.selection).toEqual(before.selection);
|
||||
});
|
||||
|
||||
test('tool keys never delete a selection and Ctrl J copies only selected pixels', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
layer.ctx.fillStyle = '#f00';
|
||||
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
});
|
||||
await page.keyboard.press('m');
|
||||
await expect(page.locator('[data-tool="marquee"]')).toHaveClass(/active/);
|
||||
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.4, y: 0.4 });
|
||||
const original = await editorState(page);
|
||||
await page.keyboard.press('c');
|
||||
await expect(page.locator('[data-tool="crop"]')).toHaveClass(/active/);
|
||||
await page.keyboard.press('d');
|
||||
expect((await editorState(page)).layers).toEqual(original.layers);
|
||||
expect((await editorState(page)).selection).toEqual(original.selection);
|
||||
await page.keyboard.press('Control+j');
|
||||
expect((await editorState(page)).layers).toHaveLength(original.layers.length + 1);
|
||||
const copiedAlpha = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
return [layer.ctx.getImageData(50, 50, 1, 1).data[3], layer.ctx.getImageData(250, 180, 1, 1).data[3]];
|
||||
});
|
||||
expect(copiedAlpha).toEqual([255, 0]);
|
||||
await page.keyboard.press('Control+z');
|
||||
expect((await editorState(page)).layers).toEqual(original.layers);
|
||||
await page.keyboard.press('Control+d');
|
||||
expect((await editorState(page)).selection).toBeNull();
|
||||
await page.keyboard.press('s');
|
||||
await expect(page.locator('[data-tool="clone"]')).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('Shift Alt intersects a marquee and reopening does not duplicate shortcuts', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await page.evaluate(async () => {
|
||||
const editor = await import('/static/js/galleryEditor.js');
|
||||
await editor.openEditor(null, null, { w: 320, h: 240 }, 'Reopened');
|
||||
});
|
||||
await page.keyboard.press('m');
|
||||
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.6, y: 0.6 });
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.down('Alt');
|
||||
await dragOnCanvas(page, { x: 0.4, y: 0.4 }, { x: 0.9, y: 0.9 });
|
||||
await page.keyboard.up('Alt');
|
||||
await page.keyboard.up('Shift');
|
||||
const selection = (await editorState(page)).selection.bounds;
|
||||
expect(selection.x).toBeGreaterThanOrEqual(127);
|
||||
expect(selection.width).toBeLessThanOrEqual(65);
|
||||
const before = await editorState(page);
|
||||
await page.keyboard.press('Control+j');
|
||||
expect((await editorState(page)).layers.length).toBe(before.layers.length + 1);
|
||||
});
|
||||
|
||||
test('cut removes selected pixels without creating a layer and undo restores them', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
layer.ctx.fillStyle = '#f00';
|
||||
layer.ctx.fillRect(0, 0, 320, 240);
|
||||
});
|
||||
await page.keyboard.press('m');
|
||||
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.4, y: 0.4 });
|
||||
const before = await editorState(page);
|
||||
await page.keyboard.press('Control+x');
|
||||
await expect.poll(async () => (await editorState(page)).selection).toBeNull();
|
||||
const cut = await editorState(page);
|
||||
expect(cut.layers.length).toBe(before.layers.length);
|
||||
expect(cut.layers.find(layer => layer.id === before.activeLayerId).pixelHash)
|
||||
.not.toBe(before.layers.find(layer => layer.id === before.activeLayerId).pixelHash);
|
||||
await page.keyboard.press('Control+z');
|
||||
expect((await editorState(page)).layers).toEqual(before.layers);
|
||||
expect((await editorState(page)).selection).toEqual(before.selection);
|
||||
});
|
||||
|
||||
test('selection delete and fill edit an offset mask, preserving parent pixels', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
layer.ctx.fillStyle = '#f00';
|
||||
layer.ctx.fillRect(0, 0, 320, 240);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 200; canvas.height = 160;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, 200, 160);
|
||||
layer.masks = [{ id: 'test-mask', name: 'Mask', mode: 'layer', space: 'layer',
|
||||
canvas, ctx, offset: { x: 10, y: 10 }, visible: true, linked: true }];
|
||||
layer.activeMaskId = 'test-mask';
|
||||
state.layerOffsets.set(layer.id, { x: 20, y: 10 });
|
||||
state.color = '#ffffff';
|
||||
});
|
||||
await page.keyboard.press('m');
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
|
||||
const before = await editorState(page);
|
||||
const parentBefore = before.layers.find(layer => layer.id === before.activeLayerId);
|
||||
await page.keyboard.press('Delete');
|
||||
await expect.poll(async () => (await editorState(page)).selection).toBeNull();
|
||||
const erased = (await editorState(page)).layers.find(layer => layer.id === before.activeLayerId);
|
||||
expect(erased.pixelHash).toBe(parentBefore.pixelHash);
|
||||
expect(erased.masks[0].pixelHash).not.toBe(parentBefore.masks[0].pixelHash);
|
||||
await page.locator('#ge-image-menu-btn').click();
|
||||
await page.locator('[data-image-action="fill"]').click();
|
||||
await expect.poll(async () => (await editorState(page)).layers.find(layer => layer.id === before.activeLayerId).masks[0].pixelHash)
|
||||
.toBe(parentBefore.masks[0].pixelHash);
|
||||
expect((await editorState(page)).layers.find(layer => layer.id === before.activeLayerId).pixelHash).toBe(parentBefore.pixelHash);
|
||||
await page.keyboard.press('Control+c');
|
||||
const clipboard = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { pixel: [...state.internalClipboard.getContext('2d').getImageData(50, 50, 1, 1).data], offset: state.internalClipboardOffset };
|
||||
});
|
||||
expect(clipboard).toEqual({ pixel: [255, 255, 255, 255], offset: { x: 30, y: 20 } });
|
||||
await page.evaluate(() => document.body.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true })));
|
||||
const pasted = await editorState(page);
|
||||
expect(pasted.layers).toHaveLength(before.layers.length + 1);
|
||||
expect(pasted.layers.find(layer => layer.id === pasted.activeLayerId).offset).toEqual({ x: 30, y: 20 });
|
||||
await page.keyboard.press('Control+z');
|
||||
expect((await editorState(page)).layers).toHaveLength(before.layers.length);
|
||||
});
|
||||
|
||||
test('focus loss releases a held brush and does not resume it on return', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await page.keyboard.press('b');
|
||||
const before = await editorState(page);
|
||||
const canvas = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.move(canvas.x + 50, canvas.y + 50);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(canvas.x + 90, canvas.y + 70, { steps: 5 });
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('blur')));
|
||||
expect(await page.evaluate(async () => (await import('/static/js/editor/state.js')).state.drawing)).toBe(false);
|
||||
const released = await editorState(page);
|
||||
await page.mouse.move(canvas.x + 150, canvas.y + 100, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
expect((await editorState(page)).layers).toEqual(released.layers);
|
||||
expect((await editorState(page)).undo).toBe(before.undo + 1);
|
||||
await page.keyboard.press('Control+z');
|
||||
expect((await editorState(page)).layers).toEqual(before.layers);
|
||||
});
|
||||
|
||||
test('switching away from a held brush ends one stroke and desktop reselect keeps controls open', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 });
|
||||
await page.keyboard.press('b');
|
||||
await page.keyboard.press('b');
|
||||
await expect(page.locator('.ge-controls')).not.toHaveClass(/dismissed/);
|
||||
const before = await editorState(page);
|
||||
const canvas = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.move(canvas.x + 50, canvas.y + 50);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(canvas.x + 90, canvas.y + 70, { steps: 5 });
|
||||
await page.keyboard.press('v');
|
||||
expect(await page.evaluate(async () => (await import('/static/js/editor/state.js')).state.drawing)).toBe(false);
|
||||
await page.mouse.up();
|
||||
expect((await editorState(page)).undo).toBe(before.undo + 1);
|
||||
await page.keyboard.press('Control+z');
|
||||
expect((await editorState(page)).layers).toEqual(before.layers);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
async function sampleComposite(page) {
|
||||
return page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const ctx = state.documentCompositeCanvas.getContext('2d');
|
||||
return {
|
||||
left: [...ctx.getImageData(40, 50, 1, 1).data],
|
||||
right: [...ctx.getImageData(160, 50, 1, 1).data],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test('clipping mask uses base alpha and survives undo, redo, and server reopen', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 200, height: 100 }, 'Clipping E2E');
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const base = state.layers.find(layer => layer.name === 'Background');
|
||||
const color = state.layers.find(layer => layer.name === 'Edit');
|
||||
for (const layer of [base, color]) layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
base.ctx.fillStyle = '#ff0000';
|
||||
base.ctx.fillRect(0, 0, 100, 100);
|
||||
color.ctx.fillStyle = '#0000ff';
|
||||
color.ctx.fillRect(0, 0, 200, 100);
|
||||
});
|
||||
|
||||
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
|
||||
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
|
||||
await expect(editRow).toHaveClass(/clipped/);
|
||||
await expect(editRow.locator('.ge-layer-clipped-marker')).toBeVisible();
|
||||
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
|
||||
let pixels = await sampleComposite(page);
|
||||
expect(pixels.left).toEqual([0, 0, 255, 255]);
|
||||
expect(pixels.right[3]).toBe(0);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await sampleComposite(page)).right).toEqual([0, 0, 255, 255]);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect((await sampleComposite(page)).right[3]).toBe(0);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
|
||||
expect((await sampleComposite(page)).right[3]).toBe(0);
|
||||
await expect(page.locator('.ge-layer-item.clipped .ge-layer-clipped-marker')).toBeVisible();
|
||||
|
||||
await page.locator('#ge-layer-tools button[title="Merge down into layer below"]').click();
|
||||
expect((await editorState(page)).layers).toHaveLength(1);
|
||||
expect((await sampleComposite(page)).right[3]).toBe(0);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
|
||||
|
||||
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
|
||||
expect((await sampleComposite(page)).right).toEqual([0, 0, 255, 255]);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
test('independent layer locks enforce operations and survive undo and reopen', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 360, height: 260 }, 'Layer locks E2E');
|
||||
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
|
||||
const lockButton = editRow.locator('.ge-layer-lock-btn');
|
||||
const opacityRow = editRow.locator('.ge-layer-opacity-row');
|
||||
const lockBox = await lockButton.boundingBox();
|
||||
const opacityBox = await opacityRow.boundingBox();
|
||||
expect(lockBox).toBeTruthy();
|
||||
expect(opacityBox).toBeTruthy();
|
||||
expect(lockBox.y + lockBox.height).toBeLessThanOrEqual(opacityBox.y + 1);
|
||||
|
||||
const toggleLock = async type => {
|
||||
await lockButton.click();
|
||||
const menu = page.locator('#ge-layer-lock-menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.locator(`[data-lock-type="${type}"]`).click();
|
||||
};
|
||||
|
||||
await toggleLock('position');
|
||||
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').locks.position).toBe(true);
|
||||
await expect(lockButton).toHaveClass(/active/);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="move"]').click();
|
||||
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.55, y: 0.50 });
|
||||
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 0, y: 0 });
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').locks.position).toBe(false);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').locks.position).toBe(true);
|
||||
await toggleLock('position');
|
||||
|
||||
await toggleLock('pixels');
|
||||
const emptyHash = (await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash;
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.58, y: 0.50 });
|
||||
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash).toBe(emptyHash);
|
||||
await toggleLock('pixels');
|
||||
|
||||
await toggleLock('transparency');
|
||||
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.58, y: 0.50 });
|
||||
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash).toBe(emptyHash);
|
||||
await toggleLock('transparency');
|
||||
|
||||
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.58, y: 0.50 });
|
||||
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash).not.toBe(emptyHash);
|
||||
|
||||
await toggleLock('pixels');
|
||||
await toggleLock('transparency');
|
||||
await toggleLock('position');
|
||||
const beforeReopen = await editorState(page);
|
||||
const expectedLocks = beforeReopen.layers.find(layer => layer.name === 'Edit').locks;
|
||||
expect(expectedLocks).toEqual({ pixels: true, transparency: true, position: true });
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.layers.find(layer => layer.name === 'Edit').locks).toEqual(expectedLocks);
|
||||
await expect(page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first().locator('.ge-layer-lock-btn')).toHaveClass(/active/);
|
||||
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
|
||||
|
||||
test('layer mask link controls independent movement and survives reopen', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Linked layer mask');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.35 }, { x: 0.75, y: 0.6 });
|
||||
|
||||
let current = await editorState(page);
|
||||
const layerId = current.activeLayerId;
|
||||
const layerRow = page.locator(`.ge-layer-item[data-layer-id="${layerId}"]`);
|
||||
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
|
||||
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
|
||||
await expect(maskRow).toBeVisible();
|
||||
|
||||
current = await editorState(page);
|
||||
const originalLayer = current.layers.find(layer => layer.id === layerId);
|
||||
expect(originalLayer.masks[0].linked).toBe(true);
|
||||
expect(originalLayer.masks[0].offset).toEqual({ x: 0, y: 0 });
|
||||
|
||||
const originalMaskHash = originalLayer.masks[0].pixelHash;
|
||||
await maskRow.locator('button[title="Invert mask"]').click();
|
||||
current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.id === layerId).masks[0].pixelHash)
|
||||
.not.toBe(originalMaskHash);
|
||||
await page.locator('#ge-undo').click();
|
||||
current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.id === layerId).masks[0].pixelHash)
|
||||
.toBe(originalMaskHash);
|
||||
|
||||
await maskRow.locator('details.ge-mask-properties > summary').click();
|
||||
await maskRow.locator('input.ge-mask-density').fill('50');
|
||||
await maskRow.locator('input.ge-mask-density').dispatchEvent('change');
|
||||
current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.id === layerId).masks[0].density).toBe(0.5);
|
||||
await maskRow.locator('input.ge-mask-feather').fill('12');
|
||||
await maskRow.locator('input.ge-mask-feather').dispatchEvent('change');
|
||||
current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.id === layerId).masks[0].feather).toBe(12);
|
||||
await page.locator('#ge-undo').click();
|
||||
current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.id === layerId).masks[0].density).toBe(1);
|
||||
expect(current.layers.find(layer => layer.id === layerId).masks[0].feather).toBe(0);
|
||||
|
||||
await maskRow.locator('button[title="Inspect mask"]').click();
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.maskInspectMode;
|
||||
})).toBe(true);
|
||||
const inspectedPixel = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return [...state.mainCtx.getImageData(Math.floor(state.imgWidth / 2), Math.floor(state.imgHeight / 2), 1, 1).data];
|
||||
});
|
||||
expect(inspectedPixel[0]).toBeGreaterThan(200);
|
||||
expect(inspectedPixel[1]).toBeGreaterThan(200);
|
||||
expect(inspectedPixel[2]).toBeGreaterThan(200);
|
||||
await page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' })
|
||||
.locator('button[title="Inspect mask"]').click();
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.maskInspectMode;
|
||||
})).toBe(false);
|
||||
|
||||
await maskRow.locator('.ge-mask-link-btn').click();
|
||||
await expect(maskRow.locator('.ge-mask-link-btn')).toHaveAttribute('aria-pressed', 'false');
|
||||
await page.locator('.ge-tool-btn[data-tool="move"]').click();
|
||||
await dragOnCanvas(page, { x: 0.45, y: 0.45 }, { x: 0.55, y: 0.5 });
|
||||
|
||||
current = await editorState(page);
|
||||
let layer = current.layers.find(item => item.id === layerId);
|
||||
expect(layer.offset).toEqual(originalLayer.offset);
|
||||
expect(layer.pixelHash).toBe(originalLayer.pixelHash);
|
||||
expect(layer.masks[0].linked).toBe(false);
|
||||
expect(layer.masks[0].offset.x).toBeGreaterThan(0);
|
||||
expect(layer.masks[0].offset.y).toBeGreaterThan(0);
|
||||
const fixedDocumentMask = {
|
||||
x: layer.offset.x + layer.masks[0].offset.x,
|
||||
y: layer.offset.y + layer.masks[0].offset.y,
|
||||
};
|
||||
|
||||
await layerRow.click();
|
||||
await dragOnCanvas(page, { x: 0.4, y: 0.4 }, { x: 0.5, y: 0.45 });
|
||||
current = await editorState(page);
|
||||
layer = current.layers.find(item => item.id === layerId);
|
||||
expect(layer.offset.x).toBeGreaterThan(originalLayer.offset.x);
|
||||
expect(layer.offset.y).toBeGreaterThan(originalLayer.offset.y);
|
||||
expect({
|
||||
x: layer.offset.x + layer.masks[0].offset.x,
|
||||
y: layer.offset.y + layer.masks[0].offset.y,
|
||||
}).toEqual(fixedDocumentMask);
|
||||
|
||||
const beforeTransform = layer;
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await page.locator('#ge-transform-w').fill('280');
|
||||
await page.locator('#ge-transform-w').dispatchEvent('input');
|
||||
await page.locator('#ge-transform-apply').click();
|
||||
current = await editorState(page);
|
||||
layer = current.layers.find(item => item.id === layerId);
|
||||
expect(layer.size[0]).toBe(280);
|
||||
expect({
|
||||
x: layer.offset.x + layer.masks[0].offset.x,
|
||||
y: layer.offset.y + layer.masks[0].offset.y,
|
||||
}).toEqual(fixedDocumentMask);
|
||||
await page.locator('#ge-undo').click();
|
||||
await expect.poll(async () => {
|
||||
const state = await editorState(page);
|
||||
return state.layers.find(item => item.id === layerId).size[0];
|
||||
}).toBe(beforeTransform.size[0]);
|
||||
|
||||
await maskRow.locator('.ge-mask-link-btn').click();
|
||||
current = await editorState(page);
|
||||
const relinked = current.layers.find(item => item.id === layerId);
|
||||
expect(relinked.masks[0].linked).toBe(true);
|
||||
const linkedRelativeOffset = { ...relinked.masks[0].offset };
|
||||
await layerRow.click();
|
||||
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.45, y: 0.35 });
|
||||
current = await editorState(page);
|
||||
layer = current.layers.find(item => item.id === layerId);
|
||||
expect(layer.masks[0].offset).toEqual(linkedRelativeOffset);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
await expect.poll(async () => {
|
||||
const state = await editorState(page);
|
||||
return state.layers.find(item => item.id === layerId).offset.x;
|
||||
}).toBe(relinked.offset.x);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
const beforeReopen = await editorState(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.layers.find(item => item.id === layerId)).toEqual(
|
||||
beforeReopen.layers.find(item => item.id === layerId),
|
||||
);
|
||||
const maskRowAfterReopen = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
|
||||
await maskRowAfterReopen.locator('details.ge-mask-properties > summary').click();
|
||||
await maskRowAfterReopen.locator('button[title="Bake this mask into the layer and remove the mask"]').click();
|
||||
await expect.poll(async () => (await editorState(page)).layers.find(item => item.id === layerId).masks).toHaveLength(0);
|
||||
await page.locator('#ge-undo').click();
|
||||
await expect.poll(async () => (await editorState(page)).layers.find(item => item.id === layerId).masks).toHaveLength(1);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden', async ({ page }) => {
|
||||
// Notification polling requires login even in the auth-disabled test server.
|
||||
await page.route('**/api/tasks/notification-logs*', route => route.fulfill({ json: { logs: [] } }));
|
||||
await openBlankEditor(page, { width: 240, height: 160 }, 'Thumbnail selection');
|
||||
const id = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
layer.ctx.fillStyle = '#ff0000';
|
||||
layer.ctx.fillRect(30, 25, 60, 40);
|
||||
state.wandMaskVisible = false;
|
||||
return layer.id;
|
||||
});
|
||||
await page.locator(`.ge-layer-item[data-layer-id="${id}"] .ge-layer-inline-thumb`).click({ modifiers: ['Control'] });
|
||||
await expect.poll(() => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const canvas = state.selectionOverlay;
|
||||
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
return state.wandMaskVisible && canvas.style.display !== 'none' && pixels.some((value, index) => index % 4 === 3 && value > 0);
|
||||
})).toBe(true);
|
||||
await expect.poll(() => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const ctx = state.wandMask.getContext('2d');
|
||||
return [ctx.getImageData(40, 35, 1, 1).data[3], ctx.getImageData(0, 0, 1, 1).data[3]];
|
||||
})).toEqual([255, 0]);
|
||||
});
|
||||
|
||||
test('selected layers align to the canvas and undo as one operation', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Alignment E2E');
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const edit = state.layers.find(layer => layer.name === 'Edit');
|
||||
const added = state.layers.find(layer => layer.id === state.activeLayerId);
|
||||
edit.canvas.width = 100;
|
||||
edit.canvas.height = 80;
|
||||
added.canvas.width = 50;
|
||||
added.canvas.height = 40;
|
||||
state.layerOffsets.set(edit.id, { x: 12, y: 18 });
|
||||
state.layerOffsets.set(added.id, { x: 190, y: 150 });
|
||||
});
|
||||
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
|
||||
await editRow.click({ modifiers: ['Control'] });
|
||||
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
|
||||
await page.locator('#ge-selected-align').click();
|
||||
await page.locator('#ge-layer-align-menu button').filter({ hasText: 'Align center' }).click();
|
||||
|
||||
let current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 110, y: 18 });
|
||||
expect(current.layers.find(layer => layer.name !== 'Background' && layer.name !== 'Edit').offset)
|
||||
.toEqual({ x: 135, y: 150 });
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 12, y: 18 });
|
||||
expect(current.layers.find(layer => layer.name !== 'Background' && layer.name !== 'Edit').offset)
|
||||
.toEqual({ x: 190, y: 150 });
|
||||
});
|
||||
|
||||
test('layer rename cancels on Escape without committing the draft name', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 160 }, 'Rename cancel E2E');
|
||||
const row = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
|
||||
const rowId = await row.getAttribute('data-layer-id');
|
||||
const name = row.locator('.ge-layer-name');
|
||||
const original = await name.textContent();
|
||||
await name.dblclick();
|
||||
const stableRow = page.locator(`.ge-layer-item[data-layer-id="${rowId}"]`);
|
||||
const input = stableRow.locator('.ge-layer-name-input');
|
||||
await input.fill('Temporary name');
|
||||
await input.press('Escape');
|
||||
await expect(stableRow.locator('.ge-layer-name')).toHaveText(original);
|
||||
await expect(stableRow.locator('.ge-layer-name-input')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('layer rows can be selected with Enter and Space', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard layer selection E2E');
|
||||
const background = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
|
||||
const edit = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
|
||||
|
||||
await background.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(background).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(edit).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
await edit.focus();
|
||||
await page.keyboard.press('Space');
|
||||
await expect(edit).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(background).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
test('group rows can be selected with the keyboard', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard group selection E2E');
|
||||
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
|
||||
await page.locator('#ge-group-selected').click();
|
||||
|
||||
const group = page.locator('.ge-layer-group-row').first();
|
||||
await group.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(group).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.activeGroupId;
|
||||
})).toBe(await group.getAttribute('data-group-id'));
|
||||
});
|
||||
|
||||
test('Delete removes the selected layer and undo restores it', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard delete E2E');
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||
const addedId = (await editorState(page)).activeLayerId;
|
||||
await page.locator(`.ge-layer-item[data-layer-id="${addedId}"]`).click();
|
||||
await page.keyboard.press('Backspace');
|
||||
expect((await editorState(page)).layers.some(layer => layer.id === addedId)).toBe(false);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).layers.some(layer => layer.id === addedId)).toBe(true);
|
||||
});
|
||||
|
||||
test('Ctrl/Cmd+J duplicates the active layer through the layer panel path', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard duplicate E2E');
|
||||
const before = await editorState(page);
|
||||
const activeId = before.activeLayerId;
|
||||
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+j' : 'Control+j');
|
||||
const after = await editorState(page);
|
||||
expect(after.layers).toHaveLength(before.layers.length + 1);
|
||||
expect(after.activeLayerId).not.toBe(activeId);
|
||||
expect(after.layers.find(layer => layer.id === after.activeLayerId).name).toContain('copy');
|
||||
});
|
||||
|
||||
test('move tool can auto-select the topmost visible layer under the pointer', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Auto-select E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
|
||||
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.75 });
|
||||
const shape = (await editorState(page)).layers.find(layer => layer.kind === 'shape');
|
||||
expect(shape).toBeTruthy();
|
||||
|
||||
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click();
|
||||
await page.locator('.ge-tool-btn[data-tool="move"]').click();
|
||||
await expect(page.locator('.ge-auto-select-option')).toBeVisible();
|
||||
await page.locator('.ge-auto-select-option').click();
|
||||
await expect(page.locator('#ge-auto-select-layer')).toBeChecked();
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await expect(page.locator('.ge-auto-select-option')).toBeHidden();
|
||||
await page.locator('.ge-tool-btn[data-tool="move"]').click();
|
||||
await expect(page.locator('.ge-auto-select-option')).toBeVisible();
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.click(box.x + box.width * 0.5, box.y + box.height * 0.5);
|
||||
await expect.poll(async () => (await editorState(page)).activeLayerId).toBe(shape.id);
|
||||
expect((await editorState(page)).selectedLayerIds).toEqual([shape.id]);
|
||||
});
|
||||
|
||||
test('multi-selected layers move together and support history-backed bulk actions', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
|
||||
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
|
||||
|
||||
await backgroundRow.click({ modifiers: ['Control'] });
|
||||
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
|
||||
expect((await editorState(page)).selectedLayerIds).toHaveLength(2);
|
||||
await expect(page.locator('#ge-layer-selection-bar')).toBeVisible();
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="move"]').click();
|
||||
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.52, y: 0.50 });
|
||||
let current = await editorState(page);
|
||||
const edit = current.layers.find(layer => layer.name === 'Edit');
|
||||
const background = current.layers.find(layer => layer.name === 'Background');
|
||||
expect(edit.offset).toEqual(background.offset);
|
||||
expect(edit.offset).not.toEqual({ x: 0, y: 0 });
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 0, y: 0 });
|
||||
expect(current.layers.find(layer => layer.name === 'Background').offset).toEqual({ x: 0, y: 0 });
|
||||
expect(current.selectedLayerIds).toHaveLength(2);
|
||||
await page.locator('#ge-redo').click();
|
||||
|
||||
await page.locator('#ge-selected-visibility').click();
|
||||
current = await editorState(page);
|
||||
expect(current.layers.every(layer => layer.visible === false)).toBe(true);
|
||||
await page.locator('#ge-undo').click();
|
||||
current = await editorState(page);
|
||||
expect(current.layers.every(layer => layer.visible !== false)).toBe(true);
|
||||
|
||||
await page.locator('#ge-selected-lock').click();
|
||||
current = await editorState(page);
|
||||
expect(current.layers.every(layer => layer.locked)).toBe(true);
|
||||
await page.locator('#ge-undo').click();
|
||||
|
||||
await editRow.click();
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||
const addedId = (await editorState(page)).activeLayerId;
|
||||
await editRow.click({ modifiers: ['Control'] });
|
||||
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
|
||||
await page.locator('#ge-selected-delete').click();
|
||||
current = await editorState(page);
|
||||
expect(current.layers).toHaveLength(1);
|
||||
expect(current.layers[0].name).toBe('Background');
|
||||
expect(current.layers.some(layer => layer.id === addedId)).toBe(false);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
current = await editorState(page);
|
||||
expect(current.layers).toHaveLength(3);
|
||||
expect(current.selectedLayerIds).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('layer groups composite, lock, collapse, undo, and survive server reopen', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 480, height: 320 }, 'Grouped E2E');
|
||||
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
|
||||
await backgroundRow.click({ modifiers: ['Control'] });
|
||||
await page.locator('#ge-group-selected').click();
|
||||
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
|
||||
await expect(page.locator('.ge-layer-group-row .ge-group-inline-thumb')).toHaveCount(1);
|
||||
let current = await editorState(page);
|
||||
expect(current.groups).toHaveLength(1);
|
||||
expect(current.groups[0].layerIds).toHaveLength(2);
|
||||
|
||||
const groupName = page.locator('.ge-layer-group-name');
|
||||
await groupName.dblclick();
|
||||
await page.locator('.ge-layer-group-row input.ge-layer-name-input').fill('Hero group');
|
||||
await page.locator('.ge-layer-group-row input.ge-layer-name-input').press('Enter');
|
||||
await page.locator('.ge-layer-group-row .ge-layer-opacity').fill('55');
|
||||
await page.locator('.ge-layer-group-toggle').click();
|
||||
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(0);
|
||||
current = await editorState(page);
|
||||
expect(current.groups[0]).toMatchObject({ name: 'Hero group', opacity: 0.55, collapsed: true });
|
||||
|
||||
await page.locator('.ge-layer-group-row .ge-layer-lock-btn').click();
|
||||
const beforeLockNudge = await editorState(page);
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect((await editorState(page)).layers.map(layer => layer.offset)).toEqual(beforeLockNudge.layers.map(layer => layer.offset));
|
||||
await page.locator('.ge-layer-group-row .ge-layer-lock-btn').click();
|
||||
|
||||
await page.locator('.ge-layer-group-row').click();
|
||||
await page.locator('.ge-tool-btn[data-tool="move"]').click();
|
||||
await dragOnCanvas(page, { x: 0.40, y: 0.40 }, { x: 0.52, y: 0.48 });
|
||||
current = await editorState(page);
|
||||
expect(current.layers[0].offset).toEqual(current.layers[1].offset);
|
||||
expect(current.layers[0].offset).not.toEqual({ x: 0, y: 0 });
|
||||
|
||||
await page.locator('.ge-layer-group-row .ge-layer-vis').click();
|
||||
current = await editorState(page);
|
||||
expect(current.groups[0].visible).toBe(false);
|
||||
expect(current.layers.every(layer => layer.visible)).toBe(true);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).groups[0].visible).toBe(true);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
const beforeReopen = await editorState(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.groups).toEqual(beforeReopen.groups);
|
||||
expect(reopened.layers.map(layer => layer.offset)).toEqual(beforeReopen.layers.map(layer => layer.offset));
|
||||
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
|
||||
await expect(page.locator('.ge-layer-group-row .ge-group-inline-thumb')).toHaveCount(1);
|
||||
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(0);
|
||||
|
||||
await page.locator('.ge-layer-group-row').click();
|
||||
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
|
||||
expect((await editorState(page)).groups).toHaveLength(0);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).groups).toHaveLength(1);
|
||||
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
|
||||
test('nested groups preserve hierarchy, ancestor locks, collapse, and server reopen', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 420, height: 300 }, 'Nested groups E2E');
|
||||
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
|
||||
await backgroundRow.click({ modifiers: ['Control'] });
|
||||
await page.locator('#ge-group-selected').click();
|
||||
let current = await editorState(page);
|
||||
const innerId = current.groups[0].id;
|
||||
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||
current = await editorState(page);
|
||||
const looseId = current.activeLayerId;
|
||||
await page.locator(`.ge-layer-group-row[data-group-id="${innerId}"]`).click();
|
||||
await page.locator(`.ge-layer-item[data-layer-id="${looseId}"]`).click({ modifiers: ['Control'] });
|
||||
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(3);
|
||||
await page.locator('#ge-group-selected').click();
|
||||
|
||||
current = await editorState(page);
|
||||
expect(current.groups).toHaveLength(2);
|
||||
const inner = current.groups.find(group => group.id === innerId);
|
||||
const outer = current.groups.find(group => group.id !== innerId);
|
||||
expect(inner.parentId).toBe(outer.id);
|
||||
expect(outer.layerIds).toEqual([looseId]);
|
||||
expect(inner.layerIds).toHaveLength(2);
|
||||
await expect(page.locator('.ge-layer-group-row')).toHaveCount(2);
|
||||
await expect(page.locator('.ge-layer-group-row .ge-group-inline-thumb')).toHaveCount(2);
|
||||
await expect(page.locator(`.ge-layer-group-row[data-group-id="${inner.id}"]`)).toHaveCSS('--group-depth', '1');
|
||||
|
||||
const outerRow = page.locator(`.ge-layer-group-row[data-group-id="${outer.id}"]`);
|
||||
await outerRow.locator('.ge-layer-group-toggle').click();
|
||||
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
|
||||
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(0);
|
||||
await outerRow.locator('.ge-layer-group-toggle').click();
|
||||
await expect(page.locator('.ge-layer-group-row')).toHaveCount(2);
|
||||
|
||||
await outerRow.locator('.ge-layer-lock-btn').click();
|
||||
await page.locator(`.ge-layer-item[data-layer-id="${inner.layerIds[0]}"]`).click();
|
||||
const beforeNudge = await editorState(page);
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect((await editorState(page)).layers.map(layer => layer.offset)).toEqual(beforeNudge.layers.map(layer => layer.offset));
|
||||
await outerRow.locator('.ge-layer-lock-btn').click();
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
const beforeReopen = await editorState(page);
|
||||
await reopenDraft(page, draftId);
|
||||
current = await editorState(page);
|
||||
expect(current.groups).toEqual(beforeReopen.groups);
|
||||
await expect(page.locator('.ge-layer-group-row')).toHaveCount(2);
|
||||
|
||||
await page.locator(`.ge-layer-group-row[data-group-id="${outer.id}"]`).click();
|
||||
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
|
||||
current = await editorState(page);
|
||||
expect(current.groups).toHaveLength(1);
|
||||
expect(current.groups[0]).toMatchObject({ id: inner.id, parentId: null });
|
||||
expect(current.groups[0].layerIds).toHaveLength(2);
|
||||
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(2);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).groups).toEqual(beforeReopen.groups);
|
||||
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
|
||||
|
||||
async function seedVisibleLayers(page) {
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const background = state.layers.find(layer => layer.name === 'Background');
|
||||
const edit = state.layers.find(layer => layer.name === 'Edit');
|
||||
background.ctx.fillStyle = '#26354f';
|
||||
background.ctx.fillRect(0, 0, background.canvas.width, background.canvas.height);
|
||||
edit.ctx.fillStyle = '#cf5b4a';
|
||||
edit.ctx.fillRect(48, 32, edit.canvas.width - 96, edit.canvas.height - 64);
|
||||
window.galleryEditorComposite?.();
|
||||
});
|
||||
}
|
||||
|
||||
test('Merge all preserves retained effects and adjustment output', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 220 }, 'Merge fidelity E2E');
|
||||
await seedVisibleLayers(page);
|
||||
|
||||
await page.locator('#ge-filter-menu-btn').click();
|
||||
await page.locator('[data-filter-action="effect-color-overlay"]').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[type="color"]').fill('#f0c04a');
|
||||
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
|
||||
await expect(page.locator('.ge-adj-popup')).toBeVisible();
|
||||
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('45');
|
||||
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
|
||||
await page.locator('[data-adj-action="ok"]').click();
|
||||
|
||||
const before = await flattenedPixelDigest(page);
|
||||
const beforeState = await editorState(page);
|
||||
expect(beforeState.layers.some(layer => layer.effects.length)).toBe(true);
|
||||
expect(beforeState.layers.some(layer => layer.kind === 'adjustment')).toBe(true);
|
||||
|
||||
await page.locator('#ge-merge-all').click();
|
||||
await expect.poll(async () => (await editorState(page)).layers.length).toBe(1);
|
||||
const afterState = await editorState(page);
|
||||
expect(afterState.layers[0].effects).toHaveLength(0);
|
||||
expect(afterState.layers).toEqual([
|
||||
expect.objectContaining({ kind: 'raster', effects: [] }),
|
||||
]);
|
||||
expect(await flattenedPixelDigest(page)).toEqual(before);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const fs = require('node:fs');
|
||||
const { editorState, openBlankEditor, waitForDraft } = require('./helpers');
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
|
||||
test('mobile tool and layer sheets do not overlap mask controls', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile layer sheet');
|
||||
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
|
||||
|
||||
const controls = page.locator('.ge-controls');
|
||||
const layerSheet = page.locator('.ge-right-panel');
|
||||
await expect(controls).toHaveClass(/dismissed/);
|
||||
await expect(layerSheet).not.toHaveClass(/minimized/);
|
||||
|
||||
await page.locator('.ge-layers-title').click();
|
||||
await expect(layerSheet).toHaveClass(/expanded/);
|
||||
|
||||
const layerId = (await editorState(page)).activeLayerId;
|
||||
const layerRow = page.locator(`.ge-layer-item[data-layer-id="${layerId}"]`);
|
||||
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
|
||||
|
||||
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
|
||||
const maskName = maskRow.locator('.ge-layer-name');
|
||||
const maskThumb = maskRow.locator('.ge-mask-inline-thumb');
|
||||
await expect(maskRow).toBeVisible();
|
||||
await expect(maskName).toContainText('Layer Mask');
|
||||
await expect(maskThumb).toBeVisible();
|
||||
const rowBox = await maskRow.boundingBox();
|
||||
const nameBox = await maskName.boundingBox();
|
||||
const linkBox = await maskRow.locator('.ge-mask-link-btn').boundingBox();
|
||||
expect(rowBox).toBeTruthy();
|
||||
expect(nameBox.width).toBeGreaterThan(80);
|
||||
expect(rowBox.x).toBeGreaterThanOrEqual(0);
|
||||
expect(rowBox.x + rowBox.width).toBeLessThanOrEqual(390);
|
||||
expect(nameBox.x).toBeGreaterThanOrEqual(0);
|
||||
expect(nameBox.x + nameBox.width).toBeLessThanOrEqual(linkBox.x);
|
||||
expect(await page.locator('.ge-layers-list').evaluate(list => list.scrollLeft)).toBe(0);
|
||||
await maskRow.locator('.ge-mask-link-btn').click();
|
||||
await expect(maskRow.locator('.ge-mask-link-btn')).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await expect(controls).not.toHaveClass(/dismissed/);
|
||||
await expect(layerSheet).toHaveClass(/minimized/);
|
||||
const controlsBox = await controls.boundingBox();
|
||||
const sheetBox = await layerSheet.boundingBox();
|
||||
expect(controlsBox.y).toBeLessThan(sheetBox.y + sheetBox.height);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
|
||||
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
|
||||
await expect(controls).toHaveClass(/dismissed/);
|
||||
});
|
||||
|
||||
test('mobile group rows keep their preview and touch controls inside the viewport', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile group preview');
|
||||
await page.locator('.ge-layers-title').click();
|
||||
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
|
||||
await backgroundRow.click({ modifiers: ['Control'] });
|
||||
await page.locator('#ge-group-selected').click();
|
||||
|
||||
const groupRow = page.locator('.ge-layer-group-row').first();
|
||||
await expect(groupRow).toBeVisible();
|
||||
await expect(groupRow.locator('.ge-group-inline-thumb')).toBeVisible();
|
||||
const rowBox = await groupRow.boundingBox();
|
||||
const thumbBox = await groupRow.locator('.ge-group-inline-thumb').boundingBox();
|
||||
const toggleBox = await groupRow.locator('.ge-layer-group-toggle').boundingBox();
|
||||
const visibilityBox = await groupRow.locator('.ge-layer-vis').boundingBox();
|
||||
expect(rowBox).toBeTruthy();
|
||||
for (const box of [thumbBox, toggleBox, visibilityBox]) {
|
||||
expect(box).toBeTruthy();
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(390);
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile touch mask editing supports undo and persists through reload', async ({ page, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile mask persistence');
|
||||
await page.locator('.ge-layers-title').click();
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
|
||||
|
||||
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
|
||||
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
|
||||
await expect(maskRow).toBeVisible();
|
||||
const before = (await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash;
|
||||
|
||||
// A new layer mask is fully white (revealed), so Brush would paint white
|
||||
// onto white. Eraser makes the first touch edit observable by hiding pixels.
|
||||
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
|
||||
// Collapse the bottom-sheet controls so the full canvas is available to the
|
||||
// touch gesture, matching the canvas-first mobile editing mode.
|
||||
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
|
||||
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const visibleCanvasPoint = await page.evaluate(() => {
|
||||
const canvas = document.querySelector('.ge-main-canvas');
|
||||
const rect = canvas?.getBoundingClientRect();
|
||||
if (!canvas || !rect) return null;
|
||||
for (let y = rect.top + 8; y < rect.bottom - 8; y += 8) {
|
||||
for (let x = rect.left + 8; x < rect.right - 8; x += 8) {
|
||||
if (document.elementFromPoint(x, y) === canvas) return { x, y };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
expect(visibleCanvasPoint).toBeTruthy();
|
||||
const x1 = visibleCanvasPoint.x;
|
||||
const x2 = Math.min(canvasBox.x + canvasBox.width - 8, x1 + canvasBox.width * 0.35);
|
||||
const y = visibleCanvasPoint.y;
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: x1, y, id: 1, radiusX: 6, radiusY: 6 }],
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ x: x2, y, id: 1, radiusX: 6, radiusY: 6 }],
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
|
||||
const painted = await editorState(page);
|
||||
const paintedLayer = painted.layers.find(layer => layer.masks.length);
|
||||
expect(paintedLayer.masks[0].pixelHash).not.toBe(before);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
|
||||
.toBe(before);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
|
||||
.toBe(paintedLayer.masks[0].pixelHash);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.ge-main-canvas')).toBeVisible({ timeout: 20_000 });
|
||||
await expect.poll(async () => {
|
||||
const state = await editorState(page);
|
||||
return state.layers.find(layer => layer.masks.length)?.masks[0]?.pixelHash;
|
||||
}).toBe(paintedLayer.masks[0].pixelHash);
|
||||
await page.request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
|
||||
test('mobile export dialog stays usable and downloads the requested PNG', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile export');
|
||||
await page.locator('#ge-save-menu-btn').click();
|
||||
await page.locator('#ge-download').click();
|
||||
|
||||
const dialog = page.locator('.ge-export-dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
const dialogBox = await dialog.boundingBox();
|
||||
expect(dialogBox).toBeTruthy();
|
||||
expect(dialogBox.x).toBeGreaterThanOrEqual(0);
|
||||
expect(dialogBox.y).toBeGreaterThanOrEqual(0);
|
||||
expect(dialogBox.x + dialogBox.width).toBeLessThanOrEqual(390);
|
||||
expect(dialogBox.y + dialogBox.height).toBeLessThanOrEqual(844);
|
||||
|
||||
await page.locator('#ge-export-width').fill('160');
|
||||
await page.locator('#ge-export-filename').fill('mobile-export');
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await dialog.locator('button[type="submit"]').click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('mobile-export.png');
|
||||
const bytes = fs.readFileSync(await download.path());
|
||||
expect(bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
|
||||
expect(bytes.readUInt32BE(16)).toBe(160);
|
||||
expect(bytes.readUInt32BE(20)).toBe(120);
|
||||
});
|
||||
|
||||
test('mobile touch moves an unlinked layer mask without moving its layer', async ({ page, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile mask movement');
|
||||
await page.locator('.ge-layers-title').click();
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
|
||||
const layerId = (await editorState(page)).activeLayerId;
|
||||
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
|
||||
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
|
||||
await expect(maskRow).toBeVisible();
|
||||
await maskRow.locator('.ge-mask-link-btn').click();
|
||||
await page.locator('.ge-tool-btn[data-tool="move"]').click();
|
||||
await page.locator('.ge-tool-btn[data-tool="move"]').click();
|
||||
|
||||
const point = await page.evaluate(() => {
|
||||
const target = document.querySelector('.ge-main-canvas');
|
||||
const rect = target?.getBoundingClientRect();
|
||||
if (!target || !rect) return null;
|
||||
for (let y = rect.top + 8; y < rect.bottom - 8; y += 8) {
|
||||
for (let x = rect.left + 8; x < rect.right - 8; x += 8) {
|
||||
if (document.elementFromPoint(x, y) === target) return { x, y };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
expect(point).toBeTruthy();
|
||||
const before = await editorState(page);
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: point.x, y: point.y, id: 1, radiusX: 6, radiusY: 6 }],
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ x: point.x + 24, y: point.y + 16, id: 1, radiusX: 6, radiusY: 6 }],
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
|
||||
const moved = await editorState(page);
|
||||
const beforeLayer = before.layers.find(layer => layer.id === layerId);
|
||||
const movedLayer = moved.layers.find(layer => layer.id === layerId);
|
||||
expect(movedLayer.offset).toEqual(beforeLayer.offset);
|
||||
expect(movedLayer.masks[0].offset.x).not.toBe(beforeLayer.masks[0].offset.x);
|
||||
expect(movedLayer.masks[0].offset.y).not.toBe(beforeLayer.masks[0].offset.y);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.id === layerId).masks[0].offset)
|
||||
.toEqual(beforeLayer.masks[0].offset);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const fs = require('node:fs/promises');
|
||||
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
function selectionBounds(layers) {
|
||||
const left = Math.min(...layers.map(layer => layer.offset.x));
|
||||
const top = Math.min(...layers.map(layer => layer.offset.y));
|
||||
const right = Math.max(...layers.map(layer => layer.offset.x + layer.size[0]));
|
||||
const bottom = Math.max(...layers.map(layer => layer.offset.y + layer.size[1]));
|
||||
return { left, top, right, bottom, width: right - left, height: bottom - top, centerX: (left + right) / 2, centerY: (top + bottom) / 2 };
|
||||
}
|
||||
|
||||
test('shared transform preserves relative layout, masks, retained text, undo, cancel, and reopen', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 400, height: 300 }, 'Multi-transform E2E');
|
||||
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
|
||||
await editRow.click();
|
||||
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="text"]').click();
|
||||
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.click(canvasBox.x + canvasBox.width * 0.35, canvasBox.y + canvasBox.height * 0.25);
|
||||
await page.locator('#ge-text-content').fill('Transform together');
|
||||
await page.locator('#ge-text-size').fill('30');
|
||||
await page.locator('#ge-text-size').press('Enter');
|
||||
|
||||
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
|
||||
await editRow.click();
|
||||
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
|
||||
await editRow.click();
|
||||
await backgroundRow.click({ modifiers: ['Control'] });
|
||||
await page.locator('#ge-group-selected').click();
|
||||
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
|
||||
|
||||
await page.locator('#ge-select-all-layers').click();
|
||||
const initial = await editorState(page);
|
||||
expect(initial.selectedLayerIds).toHaveLength(3);
|
||||
const bounds = selectionBounds(initial.layers);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await expect(page.locator('.ge-transform-popup .ge-adj-title')).toHaveText('Transform 3 layers');
|
||||
expect(Number(await page.locator('#ge-transform-w').inputValue())).toBe(bounds.width);
|
||||
expect(Number(await page.locator('#ge-transform-h').inputValue())).toBe(bounds.height);
|
||||
await page.locator('#ge-transform-w').fill(String(bounds.width * 2));
|
||||
expect(Number(await page.locator('#ge-transform-h').inputValue())).toBe(bounds.height * 2);
|
||||
await page.locator('#ge-transform-apply').click();
|
||||
|
||||
const transformed = await editorState(page);
|
||||
const initialByName = new Map(initial.layers.map(layer => [layer.name, layer]));
|
||||
for (const layer of transformed.layers.filter(layer => layer.kind === 'raster')) {
|
||||
const before = initialByName.get(layer.name);
|
||||
expect(layer.size).toEqual([before.size[0] * 2, before.size[1] * 2]);
|
||||
const expectedX = Math.round(bounds.centerX + (before.offset.x + before.size[0] / 2 - bounds.centerX) * 2 - layer.size[0] / 2);
|
||||
const expectedY = Math.round(bounds.centerY + (before.offset.y + before.size[1] / 2 - bounds.centerY) * 2 - layer.size[1] / 2);
|
||||
expect(layer.offset).toEqual({ x: expectedX, y: expectedY });
|
||||
}
|
||||
const transformedEdit = transformed.layers.find(layer => layer.name === 'Edit');
|
||||
expect(transformedEdit.clipped).toBe(true);
|
||||
expect(transformed.groups).toEqual(initial.groups);
|
||||
expect(transformed.layers.map(layer => layer.id)).toEqual(initial.layers.map(layer => layer.id));
|
||||
expect(transformedEdit.masks[0].size).toEqual(transformedEdit.size);
|
||||
const beforeText = initial.layers.find(layer => layer.kind === 'text');
|
||||
const afterText = transformed.layers.find(layer => layer.kind === 'text');
|
||||
expect(afterText.text.content).toBe('Transform together');
|
||||
expect(afterText.text.transform.scaleX).toBeCloseTo(beforeText.text.transform.scaleX * 2, 5);
|
||||
expect(afterText.text.transform.scaleY).toBeCloseTo(beforeText.text.transform.scaleY * 2, 5);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
const undone = await editorState(page);
|
||||
expect(undone.layers).toEqual(initial.layers);
|
||||
expect(undone.selectedLayerIds).toEqual(initial.selectedLayerIds);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect((await editorState(page)).layers).toEqual(transformed.layers);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
const secondWidth = await page.locator('#ge-transform-w').inputValue();
|
||||
await page.locator('#ge-transform-rot-90').click();
|
||||
await expect(page.locator('#ge-transform-rot')).toHaveValue('90');
|
||||
await expect.poll(async () => {
|
||||
const edit = (await editorState(page)).layers.find(layer => layer.name === 'Edit');
|
||||
return edit.size;
|
||||
}).toEqual([transformedEdit.size[1], transformedEdit.size[0]]);
|
||||
await page.locator('#ge-transform-flip-h').click();
|
||||
await expect(page.locator('#ge-transform-w')).toHaveValue(`-${secondWidth}`);
|
||||
await page.locator('#ge-transform-cancel-btn').click();
|
||||
const cancelled = await editorState(page);
|
||||
expect(cancelled.layers).toEqual(transformed.layers);
|
||||
expect(cancelled.groups).toEqual(transformed.groups);
|
||||
expect(cancelled.redo).toBe(0);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await page.locator('#ge-transform-rot-90').click();
|
||||
await expect(page.locator('#ge-transform-rot')).toHaveValue('90');
|
||||
await page.locator('#ge-transform-flip-h').click();
|
||||
await page.locator('#ge-transform-apply').click();
|
||||
const finalTransform = await editorState(page);
|
||||
const finalText = finalTransform.layers.find(layer => layer.kind === 'text');
|
||||
expect(finalText.text.content).toBe('Transform together');
|
||||
expect(finalText.text.transform.rotation).toBeCloseTo(afterText.text.transform.rotation + 90, 5);
|
||||
expect(finalText.text.transform.flipH).toBe(!afterText.text.transform.flipH);
|
||||
expect(finalTransform.groups).toEqual(transformed.groups);
|
||||
expect(finalTransform.layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.locator('#ge-save-menu-btn').click();
|
||||
await page.locator('#ge-save-project').click();
|
||||
const download = await downloadPromise;
|
||||
const projectBuffer = await fs.readFile(await download.path());
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).layers).toEqual(transformed.layers);
|
||||
await page.locator('#ge-save-menu-btn').click();
|
||||
const chooserPromise = page.waitForEvent('filechooser');
|
||||
await page.locator('#ge-load-project').click();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles({
|
||||
name: 'transform-roundtrip.geproj.json',
|
||||
mimeType: 'application/json',
|
||||
buffer: projectBuffer,
|
||||
});
|
||||
await expect.poll(async () => (await editorState(page)).layers).toEqual(finalTransform.layers);
|
||||
expect((await editorState(page)).groups).toEqual(finalTransform.groups);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
expect((await editorState(page)).layers).toEqual(finalTransform.layers);
|
||||
expect((await editorState(page)).groups).toEqual(finalTransform.groups);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { editorState, flattenedPixelDigest, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
async function patternedPng(page, width, height, colors) {
|
||||
const base64 = await page.evaluate(({ width: w, height: h, colors: palette }) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const context = canvas.getContext('2d');
|
||||
for (let y = 0; y < h; y += 1) for (let x = 0; x < w; x += 1) {
|
||||
context.fillStyle = palette[(x + y * w) % palette.length];
|
||||
context.fillRect(x, y, 1, 1);
|
||||
}
|
||||
return canvas.toDataURL('image/png').split(',')[1];
|
||||
}, { width, height, colors });
|
||||
return Buffer.from(base64, 'base64');
|
||||
}
|
||||
|
||||
test('placed image transforms from source, replaces in place, rasterizes, and reopens', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 300, height: 200 }, 'Placed layers E2E');
|
||||
const firstImage = await patternedPng(page, 40, 20, ['#f44336', '#4caf50', '#2196f3', '#ffeb3b']);
|
||||
const chooserPromise = page.waitForEvent('filechooser');
|
||||
await page.locator('#ge-import-topbar').click();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles({ name: 'first-pattern.png', mimeType: 'image/png', buffer: firstImage });
|
||||
|
||||
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
|
||||
const imported = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
|
||||
expect(imported.placed.sourceSize).toEqual([40, 20]);
|
||||
const sourceHash = imported.placed.sourcePixelHash;
|
||||
|
||||
const row = page.locator(`.ge-layer-item[data-layer-id="${imported.id}"]`);
|
||||
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
|
||||
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth / 2)));
|
||||
await page.locator('#ge-transform-apply').click();
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await page.locator('#ge-transform-w').fill(String(originalWidth));
|
||||
await page.locator('#ge-transform-apply').click();
|
||||
|
||||
const restoredSize = (await editorState(page)).layers.find(layer => layer.id === imported.id);
|
||||
expect(restoredSize.placed.sourcePixelHash).toBe(sourceHash);
|
||||
expect(restoredSize.size[0]).toBe(originalWidth);
|
||||
expect(restoredSize.masks).toHaveLength(1);
|
||||
const beforeReplaceFrame = { size: restoredSize.size, offset: restoredSize.offset };
|
||||
const beforeReplaceMask = restoredSize.masks[0];
|
||||
|
||||
const replacement = await patternedPng(page, 20, 40, ['#111111', '#f8f8f8', '#ff00aa']);
|
||||
const replaceChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.locator('#ge-layer-tools button[title="Replace placed image"]').click();
|
||||
const replaceChooser = await replaceChooserPromise;
|
||||
await replaceChooser.setFiles({ name: 'replacement.png', mimeType: 'image/png', buffer: replacement });
|
||||
await expect.poll(async () => {
|
||||
const layer = (await editorState(page)).layers.find(item => item.id === imported.id);
|
||||
return layer.placed?.sourceName;
|
||||
}).toBe('replacement.png');
|
||||
const replaced = (await editorState(page)).layers.find(layer => layer.id === imported.id);
|
||||
expect(replaced.size).toEqual(beforeReplaceFrame.size);
|
||||
expect(replaced.offset).toEqual(beforeReplaceFrame.offset);
|
||||
expect(replaced.placed.sourcePixelHash).not.toBe(sourceHash);
|
||||
expect(replaced.masks[0]).toEqual(beforeReplaceMask);
|
||||
|
||||
const beforeRasterize = await flattenedPixelDigest(page);
|
||||
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
|
||||
const rasterized = (await editorState(page)).layers.find(layer => layer.id === imported.id);
|
||||
expect(rasterized.kind).toBe('raster');
|
||||
expect(rasterized.placed).toBeNull();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(beforeRasterize);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
await expect.poll(async () => {
|
||||
const layer = (await editorState(page)).layers.find(item => item.id === imported.id);
|
||||
return layer.kind;
|
||||
}).toBe('placed');
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = (await editorState(page)).layers.find(layer => layer.id === imported.id);
|
||||
expect(reopened.kind).toBe('placed');
|
||||
expect(reopened.placed.sourceName).toBe('replacement.png');
|
||||
expect(reopened.masks[0]).toEqual(beforeReplaceMask);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
|
||||
test('raster layers can be converted to editable sources without changing pixels', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 180, height: 120 }, 'Editable source E2E');
|
||||
const image = await patternedPng(page, 36, 24, ['#ef5350', '#42a5f5', '#66bb6a']);
|
||||
const chooserPromise = page.waitForEvent('filechooser');
|
||||
await page.locator('#ge-import-topbar').click();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles({ name: 'editable-source.png', mimeType: 'image/png', buffer: image });
|
||||
|
||||
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
|
||||
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
|
||||
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
|
||||
const raster = (await editorState(page)).layers.find(layer => layer.id === placed.id);
|
||||
const before = { pixelHash: raster.pixelHash, size: raster.size, offset: raster.offset };
|
||||
|
||||
await page.locator('#ge-layer-tools button[title="Convert to editable source"]').click();
|
||||
await expect.poll(async () => (await editorState(page)).layers.find(layer => layer.id === placed.id).kind).toBe('placed');
|
||||
const converted = (await editorState(page)).layers.find(layer => layer.id === placed.id);
|
||||
expect(converted.placed.sourceSize).toEqual(before.size);
|
||||
expect(converted.placed.sourcePixelHash).toBe(before.pixelHash);
|
||||
expect(converted.size).toEqual(before.size);
|
||||
expect(converted.offset).toEqual(before.offset);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = (await editorState(page)).layers.find(layer => layer.id === placed.id);
|
||||
expect(reopened.kind).toBe('placed');
|
||||
expect(reopened.placed.sourcePixelHash).toBe(before.pixelHash);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
|
||||
test('external clipboard images use the editable source import path', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 180, height: 120 }, 'Clipboard source E2E');
|
||||
const image = await patternedPng(page, 28, 18, ['#ef5350', '#42a5f5', '#66bb6a']);
|
||||
await page.evaluate(base64 => {
|
||||
const bytes = Uint8Array.from(atob(base64), char => char.charCodeAt(0));
|
||||
const file = new File([bytes], 'clipboard.png', { type: 'image/png' });
|
||||
const data = new DataTransfer();
|
||||
data.items.add(file);
|
||||
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true }));
|
||||
}, image.toString('base64'));
|
||||
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
|
||||
const pasted = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
|
||||
expect(pasted.placed.sourceName).toBe('Pasted image');
|
||||
expect(pasted.placed.sourceSize).toEqual([28, 18]);
|
||||
const draftId = await waitForDraft(page);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
|
||||
test('duplicating a placed layer copies its source independently', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 180, height: 120 }, 'Duplicate source E2E');
|
||||
const image = await patternedPng(page, 28, 18, ['#ef5350', '#42a5f5', '#66bb6a']);
|
||||
const chooserPromise = page.waitForEvent('filechooser');
|
||||
await page.locator('#ge-import-topbar').click();
|
||||
await (await chooserPromise).setFiles({ name: 'duplicate-source.png', mimeType: 'image/png', buffer: image });
|
||||
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
|
||||
const original = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
|
||||
await page.locator(`.ge-layer-item[data-layer-id="${original.id}"]`).click();
|
||||
await page.locator('#ge-layer-tools button[title="Duplicate layer"]').click();
|
||||
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(2);
|
||||
const sourceOwnership = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const placed = state.layers.filter(layer => layer.kind === 'placed');
|
||||
return {
|
||||
count: placed.length,
|
||||
sameSource: placed[0].placed.sourceCanvas === placed[1].placed.sourceCanvas,
|
||||
sizes: placed.map(layer => [layer.placed.sourceCanvas.width, layer.placed.sourceCanvas.height]),
|
||||
};
|
||||
});
|
||||
expect(sourceOwnership).toEqual({ count: 2, sameSource: false, sizes: [[28, 18], [28, 18]] });
|
||||
const draftId = await waitForDraft(page);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
|
||||
test('raster transforms preserve source pixels by default and can opt out', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 180, height: 120 }, 'Non-destructive transform E2E');
|
||||
const image = await patternedPng(page, 42, 28, ['#ff7043', '#26a69a', '#5c6bc0']);
|
||||
const chooserPromise = page.waitForEvent('filechooser');
|
||||
await page.locator('#ge-import-topbar').click();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles({ name: 'transform-source.png', mimeType: 'image/png', buffer: image });
|
||||
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
|
||||
|
||||
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
|
||||
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
|
||||
const rasterBeforeTransform = (await editorState(page)).layers.find(layer => layer.id === placed.id);
|
||||
const sourceHash = rasterBeforeTransform.pixelHash;
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await expect(page.locator('#ge-transform-preserve-source')).toBeChecked();
|
||||
await page.locator('#ge-transform-w').fill('84');
|
||||
await page.locator('#ge-transform-apply').click();
|
||||
const preserved = (await editorState(page)).layers.find(layer => layer.id === placed.id);
|
||||
expect(preserved.kind).toBe('placed');
|
||||
expect(preserved.placed.sourceSize).toEqual([42, 28]);
|
||||
expect(preserved.placed.sourcePixelHash).toBe(sourceHash);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
|
||||
test('raster transforms can explicitly replace source pixels', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 180, height: 120 }, 'Destructive transform E2E');
|
||||
const image = await patternedPng(page, 42, 28, ['#ff7043', '#26a69a', '#5c6bc0']);
|
||||
const chooserPromise = page.waitForEvent('filechooser');
|
||||
await page.locator('#ge-import-topbar').click();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles({ name: 'destructive-transform.png', mimeType: 'image/png', buffer: image });
|
||||
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
|
||||
|
||||
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
|
||||
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await expect(page.locator('#ge-transform-preserve-source')).toBeChecked();
|
||||
await page.locator('#ge-transform-preserve-source').uncheck();
|
||||
await page.locator('#ge-transform-w').fill('60');
|
||||
await page.locator('#ge-transform-apply').click();
|
||||
const destructive = (await editorState(page)).layers.find(layer => layer.id === placed.id);
|
||||
expect(destructive.kind).toBe('raster');
|
||||
expect(destructive.placed).toBeNull();
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { editorState, openBlankEditor } = require('./helpers.js');
|
||||
|
||||
async function currentPng(page) {
|
||||
return page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.layers[0].canvas.toDataURL('image/png');
|
||||
});
|
||||
}
|
||||
|
||||
async function loadProject(page, project) {
|
||||
await page.locator('#ge-save-menu-btn').click();
|
||||
const chooserPromise = page.waitForEvent('filechooser');
|
||||
await page.locator('#ge-load-project').click();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles({
|
||||
name: 'recovery.geproj.json',
|
||||
mimeType: 'application/json',
|
||||
buffer: Buffer.from(JSON.stringify(project)),
|
||||
});
|
||||
}
|
||||
|
||||
test('mixed-corrupt project recovers valid layers and remains undoable', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Recovery E2E');
|
||||
const before = await editorState(page);
|
||||
const png = await currentPng(page);
|
||||
await loadProject(page, {
|
||||
type: 'odysseus-gallery-editor-project',
|
||||
v: 5,
|
||||
imgWidth: 160,
|
||||
imgHeight: 120,
|
||||
activeLayerId: 'broken',
|
||||
nextLayerId: 3,
|
||||
view: {},
|
||||
layers: [
|
||||
{ id: 'good', name: 'Recovered photo', canvasW: 160, canvasH: 120, dataUrl: png, offset: { x: 0, y: 0 }, masks: [] },
|
||||
{ id: 'broken', name: 'Broken pixels', canvasW: 160, canvasH: 120, dataUrl: 'data:image/png;base64,AAAA', offset: { x: 0, y: 0 }, masks: [] },
|
||||
],
|
||||
});
|
||||
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name)).toEqual(['Recovered photo']);
|
||||
await expect(page.locator('#toast')).toContainText('Broken pixels was skipped');
|
||||
await page.locator('#ge-undo').click();
|
||||
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name)).toEqual(before.layers.map(layer => layer.name));
|
||||
expect((await editorState(page)).dimensions).toEqual(before.dimensions);
|
||||
});
|
||||
|
||||
test('fully corrupt project leaves the open document unchanged', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Recovery E2E');
|
||||
const before = await editorState(page);
|
||||
await loadProject(page, {
|
||||
type: 'odysseus-gallery-editor-project',
|
||||
v: 5,
|
||||
imgWidth: 160,
|
||||
imgHeight: 120,
|
||||
activeLayerId: 'broken',
|
||||
view: {},
|
||||
layers: [
|
||||
{ id: 'broken', name: 'Broken pixels', canvasW: 160, canvasH: 120, dataUrl: 'data:image/png;base64,AAAA', offset: { x: 0, y: 0 }, masks: [] },
|
||||
],
|
||||
});
|
||||
await expect(page.locator('#toast')).toContainText('No recoverable layers could be decoded');
|
||||
await expect.poll(async () => await editorState(page)).toEqual(before);
|
||||
});
|
||||
|
||||
test('active editor reopens after a browser refresh', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Refresh recovery');
|
||||
await expect.poll(async () => (await editorState(page)).draftId).not.toBeNull();
|
||||
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('#gallery-modal')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('#gallery-editor-tab')).toHaveClass(/active/);
|
||||
await expect(page.locator('.ge-main-canvas')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
|
||||
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name))
|
||||
.toEqual(['Background', 'Edit']);
|
||||
});
|
||||
|
||||
test('active editor reopens after a mobile browser refresh', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile refresh recovery');
|
||||
await expect.poll(async () => (await editorState(page)).draftId).not.toBeNull();
|
||||
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('#gallery-modal')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('#gallery-editor-tab')).toHaveClass(/active/);
|
||||
await expect(page.locator('.ge-main-canvas')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
|
||||
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name))
|
||||
.toEqual(['Background', 'Edit']);
|
||||
});
|
||||
|
||||
test('new project size dialog cancels cleanly with Escape', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
|
||||
await page.locator('#tool-gallery-btn').click();
|
||||
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
|
||||
await page.locator('#gallery-editor-tab').click();
|
||||
await page.locator('#gallery-editor-new').click();
|
||||
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
|
||||
await page.locator('#ge-canvas-prompt-w').press('Escape');
|
||||
await expect(page.locator('#ge-canvas-size-overlay')).toBeHidden();
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.editorOpen;
|
||||
})).toBe(false);
|
||||
await expect(page.locator('#gallery-editor-new')).toBeVisible();
|
||||
});
|
||||
|
||||
test('editor topbar uses uppercase action labels', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Topbar labels');
|
||||
|
||||
await expect(page.locator('#ge-view-menu-btn')).toHaveText(/VIEW/);
|
||||
await expect(page.locator('#ge-image-menu-btn')).toHaveText(/IMAGE/);
|
||||
await expect(page.locator('#ge-selection-menu-btn')).toHaveText(/SELECT/);
|
||||
await expect(page.locator('#ge-filter-menu-btn')).toHaveText(/FILTER/);
|
||||
await expect(page.locator('#ge-import-topbar')).toHaveText(/IMPORT/);
|
||||
await expect(page.locator('#ge-save-menu-btn')).toHaveText(/SAVE/);
|
||||
});
|
||||
|
||||
test('canvas size anchor keeps the composition centered', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Canvas anchor E2E');
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.name === 'Edit');
|
||||
state.layerOffsets.set(layer.id, { x: 10, y: 5 });
|
||||
});
|
||||
await page.locator('#ge-image-menu-btn').click();
|
||||
await page.locator('[data-image-action="canvas-size"]').click();
|
||||
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
|
||||
await page.locator('#ge-canvas-prompt-lock').uncheck();
|
||||
await page.locator('#ge-canvas-prompt-w').fill('420');
|
||||
await page.locator('#ge-canvas-prompt-h').fill('340');
|
||||
await page.locator('.ge-canvas-anchor').nth(4).click();
|
||||
await page.locator('#ge-canvas-prompt-ok').click();
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.name === 'Edit');
|
||||
return { dimensions: [state.imgWidth, state.imgHeight], offset: state.layerOffsets.get(layer.id) };
|
||||
})).toEqual({ dimensions: [420, 340], offset: { x: 60, y: 55 } });
|
||||
});
|
||||
|
||||
test('image size supports percentage resampling with locked proportions', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Image size E2E');
|
||||
await page.locator('#ge-image-menu-btn').click();
|
||||
await page.locator('[data-image-action="image-size"]').click();
|
||||
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
|
||||
await expect(page.locator('#ge-canvas-prompt-units')).toHaveValue('px');
|
||||
await page.locator('#ge-canvas-prompt-units').selectOption('percent');
|
||||
await page.locator('#ge-canvas-prompt-w').fill('50');
|
||||
await expect(page.locator('#ge-canvas-prompt-h')).toHaveValue('50');
|
||||
await page.locator('#ge-canvas-prompt-interpolation').selectOption('medium');
|
||||
await page.locator('#ge-canvas-prompt-ok').click();
|
||||
await expect.poll(async () => (await editorState(page)).dimensions).toEqual([160, 120]);
|
||||
|
||||
await page.locator('#ge-image-menu-btn').click();
|
||||
await page.locator('[data-image-action="image-size"]').click();
|
||||
await expect(page.locator('#ge-canvas-prompt-units')).toHaveValue('px');
|
||||
await expect(page.locator('#ge-canvas-prompt-w')).toHaveValue('160');
|
||||
await expect(page.locator('#ge-canvas-prompt-h')).toHaveValue('120');
|
||||
await page.locator('#ge-canvas-prompt-cancel').click();
|
||||
});
|
||||
|
||||
test('canvas size supports percentage bounds with an anchor', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Canvas percentage E2E');
|
||||
await page.locator('#ge-image-menu-btn').click();
|
||||
await page.locator('[data-image-action="canvas-size"]').click();
|
||||
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
|
||||
await page.locator('#ge-canvas-prompt-units').selectOption('percent');
|
||||
await page.locator('#ge-canvas-prompt-w').fill('125');
|
||||
await expect(page.locator('#ge-canvas-prompt-h')).toHaveValue('125');
|
||||
await page.locator('.ge-canvas-anchor').nth(4).click();
|
||||
await page.locator('#ge-canvas-prompt-ok').click();
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.name === 'Edit');
|
||||
return { dimensions: [state.imgWidth, state.imgHeight], offset: state.layerOffsets.get(layer.id) };
|
||||
})).toEqual({ dimensions: [400, 300], offset: { x: 40, y: 30 } });
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor, editorState } = require('./helpers.js');
|
||||
|
||||
test('paint tools offer rasterization, cancel preserves text, Enter accepts and undo restores it', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="text"]').click();
|
||||
const canvas = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.click(canvas.x + 80, canvas.y + 80);
|
||||
const text = page.locator('.ge-direct-text-editor');
|
||||
await text.fill('Keep editable');
|
||||
await text.press('Control+Enter');
|
||||
const retained = (await editorState(page)).layers.find(layer => layer.kind === 'text');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await expect(page.locator('#styled-confirm-ok')).toHaveText('Rasterize');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
||||
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await expect(page.locator('#styled-confirm-ok')).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
||||
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('raster');
|
||||
await page.keyboard.press('Control+z');
|
||||
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
|
||||
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
|
||||
await expect(page.locator('#styled-confirm-ok')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
|
||||
|
||||
test('active editor draft reopens after a hard refresh', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 180 }, 'Refresh recovery E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
|
||||
const before = await editorState(page);
|
||||
const draftId = await waitForDraft(page);
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('#gallery-editor-tab')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('.gallery-editor')).toBeVisible({ timeout: 20_000 });
|
||||
await expect.poll(async () => (await editorState(page)).draftId, { timeout: 20_000 }).toBe(draftId);
|
||||
await expect.poll(async () => (await editorState(page)).documentRenderReady, { timeout: 20_000 }).toBe(true);
|
||||
|
||||
const after = await editorState(page);
|
||||
expect(after.layers).toEqual(before.layers);
|
||||
expect(after.dimensions).toEqual(before.dimensions);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
|
||||
|
||||
test('selection boundary animates, moves independently, and supports Quick Mask', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection workflow');
|
||||
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
|
||||
|
||||
const initial = await editorState(page);
|
||||
expect(initial.selection?.space).toBe('document');
|
||||
expect(initial.selection?.bounds).toBeTruthy();
|
||||
const layerBefore = initial.layers.find(layer => layer.id === initial.activeLayerId);
|
||||
|
||||
const overlayHash = () => page.locator('.ge-selection-overlay').evaluate(canvas => {
|
||||
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i += 4) if (data[i + 3]) hash = ((hash * 33) ^ data[i]) >>> 0;
|
||||
return hash;
|
||||
});
|
||||
const firstOverlay = await overlayHash();
|
||||
expect(firstOverlay).not.toBe(0);
|
||||
await expect.poll(overlayHash, { timeout: 1200 }).not.toBe(firstOverlay);
|
||||
|
||||
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.45, y: 0.42 });
|
||||
const moved = await editorState(page);
|
||||
expect(moved.selection.bounds.x).toBeGreaterThan(initial.selection.bounds.x);
|
||||
expect(moved.selection.bounds.y).toBeGreaterThan(initial.selection.bounds.y);
|
||||
const layerAfterMove = moved.layers.find(layer => layer.id === moved.activeLayerId);
|
||||
expect(layerAfterMove.offset).toEqual(layerBefore.offset);
|
||||
expect(layerAfterMove.pixelHash).toBe(layerBefore.pixelHash);
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect.poll(async () => (await editorState(page)).selection.bounds.x).toBe(moved.selection.bounds.x + 1);
|
||||
|
||||
await page.keyboard.press('q');
|
||||
await expect(page.locator('#ge-quick-mask-bar')).toBeVisible();
|
||||
expect((await editorState(page)).quickMaskActive).toBe(true);
|
||||
const beforePaint = (await editorState(page)).selection.pixelHash;
|
||||
await dragOnCanvas(page, { x: 0.72, y: 0.72 }, { x: 0.78, y: 0.72 });
|
||||
await expect.poll(async () => (await editorState(page)).selection.pixelHash).not.toBe(beforePaint);
|
||||
|
||||
await page.locator('.ge-quick-mask-done').click();
|
||||
await expect(page.locator('#ge-quick-mask-bar')).toBeHidden();
|
||||
expect((await editorState(page)).quickMaskActive).toBe(false);
|
||||
});
|
||||
|
||||
test('marquee supports exact fixed-size and fixed-ratio geometry', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Precise marquee');
|
||||
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
|
||||
|
||||
await page.locator('#ge-marquee-constraint').selectOption('size');
|
||||
await page.locator('#ge-marquee-width').fill('80');
|
||||
await page.locator('#ge-marquee-width').blur();
|
||||
await page.locator('#ge-marquee-height').fill('60');
|
||||
await page.locator('#ge-marquee-height').blur();
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height * 0.75);
|
||||
await expect.poll(async () => (await editorState(page)).selection?.bounds).toEqual({ x: 240, y: 180, width: 80, height: 60 });
|
||||
|
||||
await page.locator('#ge-marquee-clear').click();
|
||||
await page.locator('#ge-marquee-constraint').selectOption('ratio');
|
||||
await page.locator('#ge-marquee-width').fill('4');
|
||||
await page.locator('#ge-marquee-width').blur();
|
||||
await page.locator('#ge-marquee-height').fill('3');
|
||||
await page.locator('#ge-marquee-height').blur();
|
||||
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.6, y: 0.3 });
|
||||
const ratioBounds = (await editorState(page)).selection.bounds;
|
||||
expect(ratioBounds.width).toBe(160);
|
||||
expect(ratioBounds.height).toBe(120);
|
||||
expect(ratioBounds.width / ratioBounds.height).toBeCloseTo(4 / 3, 5);
|
||||
});
|
||||
|
||||
test('transform selection moves, scales, rotates, cancels, and preserves layer pixels', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Transform selection');
|
||||
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.5, y: 0.55 });
|
||||
const original = await editorState(page);
|
||||
const originalLayer = original.layers.find(layer => layer.id === original.activeLayerId);
|
||||
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('[data-selection-action="transform"]').click();
|
||||
await expect(page.locator('.ge-transform-popup')).toBeVisible();
|
||||
await expect(page.locator('.ge-transform-popup .ge-adj-title')).toHaveText('Transform Selection');
|
||||
await dragOnCanvas(page, { x: 0.35, y: 0.4 }, { x: 0.45, y: 0.47 });
|
||||
const moved = await editorState(page);
|
||||
expect(moved.selection.bounds.x).toBeGreaterThan(original.selection.bounds.x);
|
||||
expect(moved.selection.bounds.y).toBeGreaterThan(original.selection.bounds.y);
|
||||
await page.locator('#ge-transform-w').fill('140');
|
||||
await page.locator('#ge-transform-w').dispatchEvent('input');
|
||||
await page.locator('#ge-transform-rot').fill('25');
|
||||
await page.locator('#ge-transform-rot').dispatchEvent('input');
|
||||
await page.locator('#ge-transform-apply').click();
|
||||
|
||||
let transformed = await editorState(page);
|
||||
const transformedLayer = transformed.layers.find(layer => layer.id === transformed.activeLayerId);
|
||||
expect(transformed.selection.pixelHash).not.toBe(original.selection.pixelHash);
|
||||
expect(transformed.selection.bounds.width).toBeGreaterThan(original.selection.bounds.width);
|
||||
expect(transformedLayer.offset).toEqual(originalLayer.offset);
|
||||
expect(transformedLayer.pixelHash).toBe(originalLayer.pixelHash);
|
||||
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect.poll(async () => (await editorState(page)).selection.pixelHash).toBe(original.selection.pixelHash);
|
||||
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('[data-selection-action="transform"]').click();
|
||||
await page.locator('#ge-transform-h').fill('40');
|
||||
await page.locator('#ge-transform-h').dispatchEvent('input');
|
||||
expect((await editorState(page)).selection.pixelHash).not.toBe(original.selection.pixelHash);
|
||||
await page.locator('#ge-transform-cancel-btn').click();
|
||||
transformed = await editorState(page);
|
||||
expect(transformed.selection.pixelHash).toBe(original.selection.pixelHash);
|
||||
expect(transformed.redo).toBe(0);
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForTimeout(250);
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('[data-selection-action="transform"]').click();
|
||||
const mobilePopup = await page.locator('.ge-transform-popup').boundingBox();
|
||||
const mobileTitle = await page.locator('.ge-transform-popup .ge-adj-title').boundingBox();
|
||||
expect(mobilePopup.x).toBeGreaterThanOrEqual(0);
|
||||
expect(mobilePopup.y).toBeGreaterThanOrEqual(0);
|
||||
expect(mobilePopup.x + mobilePopup.width).toBeLessThanOrEqual(390);
|
||||
expect(mobilePopup.y + mobilePopup.height).toBeLessThanOrEqual(844);
|
||||
expect(mobileTitle.x).toBeGreaterThanOrEqual(mobilePopup.x);
|
||||
expect(mobileTitle.x + mobileTitle.width).toBeLessThanOrEqual(mobilePopup.x + mobilePopup.width);
|
||||
await page.locator('#ge-transform-cancel-btn').click();
|
||||
});
|
||||
|
||||
test('named selections support reselect, load, delete, and server-draft reopen', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Saved selections');
|
||||
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
|
||||
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.5, y: 0.6 });
|
||||
const original = (await editorState(page)).selection;
|
||||
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('#ge-selection-name').fill('Subject');
|
||||
await page.locator('[data-selection-action="save"]').click();
|
||||
let current = await editorState(page);
|
||||
expect(current.savedSelections).toHaveLength(1);
|
||||
expect(current.savedSelections[0].name).toBe('Subject');
|
||||
expect(current.savedSelections[0].pixelHash).toBe(original.pixelHash);
|
||||
|
||||
await page.locator('[data-selection-action="deselect"]').click();
|
||||
current = await editorState(page);
|
||||
expect(current.selection).toBeNull();
|
||||
expect(current.lastSelection.pixelHash).toBe(original.pixelHash);
|
||||
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('[data-selection-action="reselect"]').click();
|
||||
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
|
||||
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('[data-selection-action="deselect"]').click();
|
||||
await dragOnCanvas(page, { x: 0.62, y: 0.15 }, { x: 0.88, y: 0.4 });
|
||||
expect((await editorState(page)).selection.pixelHash).not.toBe(original.pixelHash);
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('.ge-saved-selection-load', { hasText: 'Subject' }).click();
|
||||
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
current = await editorState(page);
|
||||
expect(current.savedSelections).toHaveLength(1);
|
||||
expect(current.savedSelections[0].pixelHash).toBe(original.pixelHash);
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('.ge-saved-selection-load', { hasText: 'Subject' }).click();
|
||||
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
|
||||
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('.ge-saved-selection-delete').click();
|
||||
expect((await editorState(page)).savedSelections).toHaveLength(0);
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.locator('#ge-selection-menu')).toBeHidden();
|
||||
await page.waitForTimeout(250);
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await expect(page.locator('#ge-selection-menu')).toBeVisible();
|
||||
const mobileMenu = await page.locator('#ge-selection-menu').boundingBox();
|
||||
expect(mobileMenu.x).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileMenu.x + mobileMenu.width).toBeLessThanOrEqual(390);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
|
||||
|
||||
async function openRefine(page) {
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('[data-selection-action="refine"]').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
}
|
||||
|
||||
async function drawLasso(page, points) {
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
const point = ([x, y]) => ({ x: box.x + box.width * x, y: box.y + box.height * y });
|
||||
const first = point(points[0]);
|
||||
await page.mouse.move(first.x, first.y);
|
||||
await page.mouse.down();
|
||||
for (const item of points.slice(1)) {
|
||||
const next = point(item);
|
||||
await page.mouse.move(next.x, next.y, { steps: 3 });
|
||||
}
|
||||
await page.mouse.move(first.x, first.y, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test('selection clipboard copy and paste creates an undoable independent layer', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection clipboard');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
|
||||
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
|
||||
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
|
||||
|
||||
const before = await editorState(page);
|
||||
await page.keyboard.press('Control+c');
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new Event('paste', { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Pasted Selection' })).toBeVisible();
|
||||
const pasted = await editorState(page);
|
||||
expect(pasted.layers).toHaveLength(before.layers.length + 1);
|
||||
const pastedLayer = pasted.layers.at(-1);
|
||||
expect(pasted.activeLayerId).toBe(pastedLayer.id);
|
||||
expect(pastedLayer.kind).toBe('placed');
|
||||
expect(pastedLayer.placed.sourceSize).toEqual(pastedLayer.size);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length);
|
||||
await page.locator('#ge-redo').click();
|
||||
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length + 1);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
const reopenedLayer = reopened.layers.at(-1);
|
||||
expect(reopenedLayer.kind).toBe('placed');
|
||||
expect(reopenedLayer.placed.sourceSize).toEqual(pastedLayer.size);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
|
||||
test('cut selection is one undoable move from source to new layer', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection cut');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
|
||||
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
|
||||
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
|
||||
|
||||
const before = await editorState(page);
|
||||
const sourceSignature = () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.name === 'Edit');
|
||||
const data = layer.canvas.getContext('2d').getImageData(0, 0, layer.canvas.width, layer.canvas.height).data;
|
||||
let hash = 2166136261;
|
||||
for (const value of data) {
|
||||
hash ^= value;
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
});
|
||||
const beforeSource = await sourceSignature();
|
||||
await page.keyboard.press('Control+x');
|
||||
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Wand copy' })).toBeVisible();
|
||||
const cut = await editorState(page);
|
||||
expect(cut.layers).toHaveLength(before.layers.length + 1);
|
||||
expect(await sourceSignature()).not.toBe(beforeSource);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length);
|
||||
expect(await sourceSignature()).toBe(beforeSource);
|
||||
});
|
||||
|
||||
test('completed lasso copy preserves the source layer coordinate space', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Offset selection copy');
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
state.layerOffsets.set(state.activeLayerId, { x: 32, y: 24 });
|
||||
window.galleryEditorComposite?.();
|
||||
});
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await dragOnCanvas(page, { x: 0.28, y: 0.3 }, { x: 0.62, y: 0.58 });
|
||||
await page.locator('.ge-tool-btn[data-tool="lasso"]').click();
|
||||
await drawLasso(page, [[0.26, 0.26], [0.68, 0.26], [0.68, 0.64], [0.26, 0.64]]);
|
||||
|
||||
const before = await editorState(page);
|
||||
const source = before.layers.find(layer => layer.id === before.activeLayerId);
|
||||
await page.locator('#ge-lasso-copy').click();
|
||||
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Wand copy' })).toBeVisible();
|
||||
const copied = await editorState(page);
|
||||
const copy = copied.layers.find(layer => layer.name === 'Wand copy');
|
||||
expect(copy.size).toEqual(source.size);
|
||||
expect(copy.offset).toEqual(source.offset);
|
||||
});
|
||||
|
||||
test('selection refine previews safely and layer masks round-trip to selection', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection refinement');
|
||||
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
|
||||
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.55, y: 0.55 });
|
||||
const original = (await editorState(page)).selection;
|
||||
|
||||
await openRefine(page);
|
||||
const expand = page.locator('.ge-filter-modal input[data-key="expand"]');
|
||||
await expand.fill('12');
|
||||
await expand.dispatchEvent('input');
|
||||
await expect.poll(async () => (await editorState(page)).selection.bounds.width)
|
||||
.toBeGreaterThan(original.bounds.width);
|
||||
await page.locator('.ge-filter-modal [data-action="cancel"]').click();
|
||||
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
|
||||
|
||||
await openRefine(page);
|
||||
const appliedExpand = page.locator('.ge-filter-modal input[data-key="expand"]');
|
||||
await appliedExpand.fill('12');
|
||||
await appliedExpand.dispatchEvent('input');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
const refined = (await editorState(page)).selection;
|
||||
expect(refined.bounds.width).toBeGreaterThan(original.bounds.width);
|
||||
expect(refined.pixelHash).not.toBe(original.pixelHash);
|
||||
|
||||
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
|
||||
let current = await editorState(page);
|
||||
const active = current.layers.find(layer => layer.id === current.activeLayerId);
|
||||
expect(active.masks).toHaveLength(1);
|
||||
expect(active.masks[0].mode).toBe('layer');
|
||||
expect(active.masks[0].pixelHash).toBe(refined.pixelHash);
|
||||
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('[data-selection-action="deselect"]').click();
|
||||
expect((await editorState(page)).selection).toBeNull();
|
||||
await page.getByRole('button', { name: 'Load mask as selection' }).click();
|
||||
current = await editorState(page);
|
||||
expect(current.selection.pixelHash).toBe(refined.pixelHash);
|
||||
});
|
||||
|
||||
test('lasso uses the shared replace, add, subtract, and intersect modes', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Lasso combine modes');
|
||||
await page.locator('.ge-tool-btn[data-tool="lasso"]').click();
|
||||
await drawLasso(page, [[0.1, 0.2], [0.35, 0.2], [0.35, 0.55], [0.1, 0.55]]);
|
||||
const first = (await editorState(page)).selection;
|
||||
expect(first.source).toBe('lasso');
|
||||
|
||||
await page.locator('#ge-lasso-section [data-wand-mode="add"]').click();
|
||||
await drawLasso(page, [[0.55, 0.2], [0.85, 0.2], [0.85, 0.55], [0.55, 0.55]]);
|
||||
const added = (await editorState(page)).selection;
|
||||
expect(added.bounds.width).toBeGreaterThan(first.bounds.width);
|
||||
|
||||
await page.locator('#ge-lasso-section [data-wand-mode="subtract"]').click();
|
||||
await drawLasso(page, [[0.05, 0.15], [0.4, 0.15], [0.4, 0.6], [0.05, 0.6]]);
|
||||
const subtracted = (await editorState(page)).selection;
|
||||
expect(subtracted.bounds.x).toBeGreaterThan(first.bounds.x);
|
||||
|
||||
await page.locator('#ge-lasso-section [data-wand-mode="intersect"]').click();
|
||||
await drawLasso(page, [[0.65, 0.25], [0.78, 0.25], [0.78, 0.48], [0.65, 0.48]]);
|
||||
const intersected = (await editorState(page)).selection;
|
||||
expect(intersected.bounds.width).toBeLessThan(subtracted.bounds.width);
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
test('text edits directly on canvas and remains retained after reopen', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="text"]').click();
|
||||
await page.locator('#ge-text-frame-width').fill('260');
|
||||
const canvas = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.click(canvas.x + canvas.width * 0.2, canvas.y + canvas.height * 0.2);
|
||||
const editor = page.locator('.ge-direct-text-editor');
|
||||
await expect(editor).toBeVisible();
|
||||
await editor.fill('Editable canvas title');
|
||||
await editor.press('Control+Enter');
|
||||
await expect(editor).toHaveCount(0);
|
||||
|
||||
await page.locator('#ge-text-letter-spacing').fill('3.5');
|
||||
await page.locator('#ge-text-line-height').fill('1.4');
|
||||
await page.locator('#ge-text-font').selectOption('Georgia');
|
||||
await page.locator('.ge-text-auto-width-option').click();
|
||||
await expect(page.locator('#ge-text-auto-width')).toBeChecked();
|
||||
await expect(page.locator('#ge-text-frame-width')).toBeDisabled();
|
||||
await page.locator('#ge-text-frame-height').fill('180');
|
||||
await page.locator('#ge-text-vertical-align').selectOption('bottom');
|
||||
const before = await editorState(page);
|
||||
const textLayer = before.layers.find(layer => layer.kind === 'text');
|
||||
expect(textLayer.text).toMatchObject({
|
||||
content: 'Editable canvas title',
|
||||
fontFamily: 'Georgia',
|
||||
lineHeight: 1.4,
|
||||
letterSpacing: 3.5,
|
||||
autoWidth: true,
|
||||
frameHeight: 180,
|
||||
verticalAlign: 'bottom',
|
||||
});
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.layers.find(layer => layer.kind === 'text').text).toEqual(textLayer.text);
|
||||
});
|
||||
|
||||
test('text paragraph controls remain usable in a narrow phone viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 700 });
|
||||
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile text layout E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="text"]').click();
|
||||
const section = page.locator('#ge-text-section');
|
||||
await expect(section).toBeVisible();
|
||||
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
|
||||
await expect(page.locator('#ge-text-frame-height')).toBeVisible();
|
||||
await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
|
||||
await expect(page.locator('#ge-shape-section')).toBeVisible();
|
||||
|
||||
const types = ['rectangle', 'ellipse', 'line', 'polygon'];
|
||||
for (let index = 0; index < types.length; index += 1) {
|
||||
const type = types[index];
|
||||
await page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first().click();
|
||||
await page.locator(`[data-shape-type="${type}"]`).click();
|
||||
await dragOnCanvas(
|
||||
page,
|
||||
{ x: 0.12 + index * 0.18, y: 0.2 },
|
||||
{ x: 0.25 + index * 0.18, y: 0.42 },
|
||||
);
|
||||
}
|
||||
|
||||
let current = await editorState(page);
|
||||
const shapes = current.layers.filter(layer => layer.kind === 'shape');
|
||||
expect(shapes.map(layer => layer.shape.type)).toEqual(types);
|
||||
await page.locator('#ge-shape-sides').fill('7');
|
||||
await page.locator('#ge-shape-stroke-width').fill('6');
|
||||
await page.locator('#ge-shape-radius').fill('14');
|
||||
current = await editorState(page);
|
||||
const polygon = current.layers.find(layer => layer.kind === 'shape' && layer.shape.type === 'polygon');
|
||||
expect(polygon.shape).toMatchObject({ sides: 7, strokeWidth: 6, cornerRadius: 14 });
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
|
||||
await page.locator('#ge-transform-w').fill(String(originalWidth + 40));
|
||||
await page.locator('#ge-transform-apply').click();
|
||||
current = await editorState(page);
|
||||
const transformed = current.layers.find(layer => layer.id === polygon.id);
|
||||
expect(transformed.kind).toBe('shape');
|
||||
expect(transformed.shape.transform.scaleX).toBeGreaterThan(1);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
current = await editorState(page);
|
||||
expect(current.layers.filter(layer => layer.kind === 'shape').map(layer => layer.shape.type)).toEqual(types);
|
||||
});
|
||||
|
||||
test('gradient shape fill remains editable after reopening the project', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
|
||||
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeHidden();
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.65, y: 0.55 });
|
||||
await page.locator('#ge-shape-gradient-angle').evaluate((angle) => {
|
||||
const section = angle.closest('#ge-shape-section');
|
||||
section.querySelector('#ge-shape-fill-type').value = 'linear-gradient';
|
||||
section.querySelector('#ge-shape-gradient-start').value = '#ff0000';
|
||||
section.querySelector('#ge-shape-gradient-mid').value = '#00ff00';
|
||||
section.querySelector('#ge-shape-gradient-mid-enabled').checked = true;
|
||||
section.querySelector('#ge-shape-gradient-mid-position').value = '42';
|
||||
section.querySelector('#ge-shape-gradient-end').value = '#0000ff';
|
||||
section.querySelector('#ge-shape-fill-type').dispatchEvent(new Event('change', { bubbles: true }));
|
||||
angle.value = '35';
|
||||
angle.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible();
|
||||
|
||||
const before = await editorState(page);
|
||||
const shape = before.layers.find(layer => layer.kind === 'shape');
|
||||
expect(shape.shape).toMatchObject({
|
||||
fillType: 'linear-gradient',
|
||||
gradientStart: '#ff0000',
|
||||
gradientMid: '#00ff00',
|
||||
gradientMidEnabled: true,
|
||||
gradientMidPosition: 42,
|
||||
gradientEnd: '#0000ff',
|
||||
gradientAngle: 35,
|
||||
});
|
||||
expect(page.locator('.ge-layer-inline-thumb')).toHaveCount(before.layers.length);
|
||||
const thumbnailColorRange = await page.locator('.ge-layer-inline-thumb').evaluateAll((canvases) => canvases.map(canvas => {
|
||||
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
return {
|
||||
red: Math.max(...Array.from(pixels).filter((_, index) => index % 4 === 0)),
|
||||
blue: Math.max(...Array.from(pixels).filter((_, index) => index % 4 === 2)),
|
||||
};
|
||||
}));
|
||||
expect(thumbnailColorRange.some(({ red, blue }) => red > 180 && blue > 180)).toBe(true);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.layers.find(layer => layer.id === shape.id).shape).toMatchObject(shape.shape);
|
||||
});
|
||||
|
||||
test('shape gradients retain added stops through editing and reopen', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
|
||||
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
|
||||
await page.locator('#ge-shape-fill-type').selectOption('linear-gradient');
|
||||
await page.evaluate(() => {
|
||||
for (const [id, value] of [['ge-shape-gradient-start', '#ff0000'], ['ge-shape-gradient-end', '#0000ff']]) {
|
||||
const input = document.getElementById(id);
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
await page.locator('#ge-shape-gradient-add-stop').click();
|
||||
await expect(page.locator('[data-gradient-extra-stop]')).toHaveCount(1);
|
||||
await page.evaluate(() => {
|
||||
const color = document.querySelector('[data-gradient-stop-color]');
|
||||
color.value = '#00ff00';
|
||||
color.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const position = document.querySelector('[data-gradient-stop-position]');
|
||||
position.value = '50';
|
||||
position.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await page.locator('#ge-shape-gradient-add-stop').click();
|
||||
await expect(page.locator('[data-gradient-extra-stop]')).toHaveCount(2);
|
||||
await page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll('[data-gradient-extra-stop]')];
|
||||
const setRow = (row, colorValue, positionValue) => {
|
||||
const color = row.querySelector('[data-gradient-stop-color]');
|
||||
color.value = colorValue;
|
||||
color.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const position = row.querySelector('[data-gradient-stop-position]');
|
||||
position.value = positionValue;
|
||||
position.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
};
|
||||
setRow(rows[0], '#00ff00', '50');
|
||||
setRow(rows[1], '#ffff00', '75');
|
||||
});
|
||||
|
||||
let current = await editorState(page);
|
||||
const shape = current.layers.find(layer => layer.kind === 'shape');
|
||||
expect(shape.shape.gradientStops).toEqual([
|
||||
{ position: 0, color: '#ff0000' },
|
||||
{ position: 50, color: '#00ff00' },
|
||||
{ position: 75, color: '#ffff00' },
|
||||
{ position: 100, color: '#0000ff' },
|
||||
]);
|
||||
|
||||
await page.locator('[data-gradient-extra-stop]').first().locator('[data-gradient-stop-remove]').click();
|
||||
current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.id === shape.id).shape.gradientStops).toEqual([
|
||||
{ position: 0, color: '#ff0000' },
|
||||
{ position: 75, color: '#ffff00' },
|
||||
{ position: 100, color: '#0000ff' },
|
||||
]);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.layers.find(layer => layer.id === shape.id).shape.gradientStops).toEqual([
|
||||
{ position: 0, color: '#ff0000' },
|
||||
{ position: 75, color: '#ffff00' },
|
||||
{ position: 100, color: '#0000ff' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('shape gradient stop normalization keeps imported endpoints bounded', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 180 }, 'Gradient stop bounds E2E');
|
||||
const result = await page.evaluate(async () => {
|
||||
const { MAX_GRADIENT_STOPS, normalizeGradientStops } = await import('/static/js/editor/gradient-stops.js');
|
||||
const stops = normalizeGradientStops(Array.from({ length: 30 }, (_, index) => ({
|
||||
position: index * 3,
|
||||
color: `#${String(index).padStart(6, '0')}`,
|
||||
})));
|
||||
return { max: MAX_GRADIENT_STOPS, stops };
|
||||
});
|
||||
expect(result.stops).toHaveLength(result.max);
|
||||
expect(result.stops[0].position).toBe(0);
|
||||
expect(result.stops.at(-1).position).toBe(100);
|
||||
});
|
||||
|
||||
test('gradient tool paints a reversible drag on the active layer', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Gradient tool E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
|
||||
await expect(page.locator('#ge-gradient-section')).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (const [id, value] of [['ge-gradient-start', '#ff0000'], ['ge-gradient-end', '#0000ff']]) {
|
||||
const input = document.getElementById(id);
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
const midpoint = document.getElementById('ge-gradient-mid');
|
||||
midpoint.value = '#00ff00';
|
||||
midpoint.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const enabled = document.getElementById('ge-gradient-mid-enabled');
|
||||
enabled.checked = true;
|
||||
enabled.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
await page.locator('#ge-gradient-add-stop').click();
|
||||
await page.evaluate(() => {
|
||||
const row = document.querySelector('#ge-gradient-extra-stops [data-gradient-extra-stop]');
|
||||
const color = row.querySelector('[data-gradient-stop-color]');
|
||||
color.value = '#ffff00';
|
||||
color.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const position = row.querySelector('[data-gradient-stop-position]');
|
||||
position.value = '25';
|
||||
position.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.move(box.x + 8, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width - 8, box.y + box.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(async () => (await editorState(page)).layers.at(-1).effects?.length || 0).toBe(1);
|
||||
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
|
||||
|
||||
const samples = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
const rendered = state.documentCompositeCanvas;
|
||||
const ctx = rendered.getContext('2d');
|
||||
const y = Math.floor(rendered.height / 2);
|
||||
return [
|
||||
ctx.getImageData(4, y, 1, 1).data,
|
||||
ctx.getImageData(Math.floor(rendered.width / 2), y, 1, 1).data,
|
||||
ctx.getImageData(rendered.width - 5, y, 1, 1).data,
|
||||
];
|
||||
});
|
||||
expect(samples[0][0]).toBeGreaterThan(220);
|
||||
expect(samples[0][2]).toBeLessThan(40);
|
||||
expect(samples[1][1]).toBeGreaterThan(180);
|
||||
expect(samples[1][0]).toBeLessThan(80);
|
||||
expect(samples[1][2]).toBeLessThan(80);
|
||||
expect(samples[2][2]).toBeGreaterThan(220);
|
||||
expect(samples[2][0]).toBeLessThan(40);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
const reopenedGradient = reopened.layers.find(layer => layer.effects?.some(effect => effect.type === 'linear-gradient'));
|
||||
expect(reopenedGradient).toBeTruthy();
|
||||
expect(reopenedGradient.effects.find(effect => effect.type === 'linear-gradient').params.stops).toEqual([
|
||||
{ position: 0, color: '#ff0000', alpha: 1 },
|
||||
{ position: 25, color: '#ffff00', alpha: 1 },
|
||||
{ position: 50, color: '#00ff00', alpha: 1 },
|
||||
{ position: 100, color: '#0000ff', alpha: 1 },
|
||||
]);
|
||||
|
||||
await page.locator('.ge-effect-sub-item .ge-adj-sub-name').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await expect(page.locator('.ge-filter-row input[data-key="stopColor0"]')).toHaveValue('#ffff00');
|
||||
await expect(page.locator('.ge-filter-row input[data-key="stopPosition0"]')).toHaveValue('25');
|
||||
await expect(page.locator('.ge-filter-row input[data-key="stopColor1"]')).toHaveValue('#00ff00');
|
||||
await page.locator('.ge-filter-row input[data-key="stopColor0"]').fill('#ffff00');
|
||||
await page.locator('.ge-filter-row input[data-key="stopPosition0"]').fill('30');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
const edited = (await editorState(page)).layers
|
||||
.flatMap(layer => layer.effects || [])
|
||||
.find(effect => effect.type === 'linear-gradient');
|
||||
expect(edited.params.stops).toEqual([
|
||||
{ position: 0, color: '#ff0000', alpha: 1 },
|
||||
{ position: 30, color: '#ffff00', alpha: 1 },
|
||||
{ position: 50, color: '#00ff00', alpha: 1 },
|
||||
{ position: 100, color: '#0000ff', alpha: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('radial gradient remains retained and survives reopen', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Radial gradient E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
|
||||
await page.locator('#ge-gradient-type').selectOption('radial-gradient');
|
||||
await page.evaluate(() => {
|
||||
const start = document.getElementById('ge-gradient-start');
|
||||
start.value = '#ffffff';
|
||||
start.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const end = document.getElementById('ge-gradient-end');
|
||||
end.value = '#000000';
|
||||
end.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await dragOnCanvas(page, { x: 0.5, y: 0.5 }, { x: 0.85, y: 0.5 });
|
||||
let current = await editorState(page);
|
||||
const gradient = current.layers.flatMap(layer => layer.effects || []).find(effect => effect.type === 'radial-gradient');
|
||||
expect(gradient).toBeTruthy();
|
||||
expect(gradient.params.stops).toEqual([
|
||||
{ position: 0, color: '#ffffff', alpha: 1 },
|
||||
{ position: 100, color: '#000000', alpha: 1 },
|
||||
]);
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
current = await editorState(page);
|
||||
expect(current.layers.flatMap(layer => layer.effects || []).some(effect => effect.type === 'radial-gradient')).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor } = require('./helpers');
|
||||
|
||||
test('transform frame exposes eight accurate handles and supports edge resize', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform frame');
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await expect(page.locator('.ge-transform-popup')).toBeVisible();
|
||||
|
||||
const frame = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
|
||||
const { getHandleAt } = await import('/static/js/editor/tools/transform-handles.js');
|
||||
const geometry = transformFrameGeometry({
|
||||
centerX: state.transformCenter.x,
|
||||
centerY: state.transformCenter.y,
|
||||
width: state.transformPendingW,
|
||||
height: state.transformPendingH,
|
||||
rotation: state.transformPendingRot,
|
||||
}, { zoom: state.zoom });
|
||||
const canvasRect = state.mainCanvas.getBoundingClientRect();
|
||||
const right = geometry.resizeHandles.find(handle => handle.id === 'r');
|
||||
return {
|
||||
width: state.transformPendingW,
|
||||
ids: geometry.resizeHandles.map(handle => getHandleAt(handle.x, handle.y)),
|
||||
rightClient: {
|
||||
x: canvasRect.left + right.x * canvasRect.width / state.mainCanvas.width,
|
||||
y: canvasRect.top + right.y * canvasRect.height / state.mainCanvas.height,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
expect(frame.ids).toEqual(['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l']);
|
||||
await page.mouse.move(frame.rightClient.x, frame.rightClient.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(frame.rightClient.x + 36, frame.rightClient.y, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformPendingW;
|
||||
})).toBeGreaterThan(frame.width);
|
||||
});
|
||||
|
||||
test('rotated edge resize follows the frame axis and keeps its opposite edge anchored', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Rotated transform frame');
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await page.locator('#ge-transform-rot').fill('90');
|
||||
|
||||
const frame = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
|
||||
const geometry = transformFrameGeometry({
|
||||
centerX: state.transformCenter.x,
|
||||
centerY: state.transformCenter.y,
|
||||
width: state.transformPendingW,
|
||||
height: state.transformPendingH,
|
||||
rotation: state.transformPendingRot,
|
||||
}, { zoom: state.zoom });
|
||||
const right = geometry.resizeHandles.find(handle => handle.id === 'r');
|
||||
const canvasRect = state.mainCanvas.getBoundingClientRect();
|
||||
return {
|
||||
width: state.transformPendingW,
|
||||
center: { ...state.transformCenter },
|
||||
rightClient: {
|
||||
x: canvasRect.left + right.x * canvasRect.width / state.mainCanvas.width,
|
||||
y: canvasRect.top + right.y * canvasRect.height / state.mainCanvas.height,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await page.mouse.move(frame.rightClient.x, frame.rightClient.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(frame.rightClient.x, frame.rightClient.y + 36, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
|
||||
const after = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { width: state.transformPendingW, center: { ...state.transformCenter } };
|
||||
});
|
||||
expect(after.width).toBeGreaterThan(frame.width);
|
||||
expect(after.center.x).toBeCloseTo(frame.center.x, 4);
|
||||
expect(after.center.y).toBeGreaterThan(frame.center.y);
|
||||
});
|
||||
|
||||
test('rotated frame moves only from its visible interior and supports keyboard nudging', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform interaction');
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await page.locator('#ge-transform-rot').fill('45');
|
||||
|
||||
const frame = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
state.snapEnabled = false;
|
||||
const rect = state.mainCanvas.getBoundingClientRect();
|
||||
const toClient = point => ({
|
||||
x: rect.left + point.x * rect.width / state.mainCanvas.width,
|
||||
y: rect.top + point.y * rect.height / state.mainCanvas.height,
|
||||
});
|
||||
return {
|
||||
center: { ...state.transformCenter },
|
||||
emptyCorner: toClient({ x: 5, y: 5 }),
|
||||
centerClient: toClient(state.transformCenter),
|
||||
};
|
||||
});
|
||||
|
||||
await page.mouse.move(frame.emptyCorner.x, frame.emptyCorner.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(frame.emptyCorner.x + 24, frame.emptyCorner.y + 18);
|
||||
await page.mouse.up();
|
||||
const afterEmptyCorner = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { ...state.transformCenter };
|
||||
});
|
||||
expect(afterEmptyCorner.x).toBeCloseTo(frame.center.x, 5);
|
||||
expect(afterEmptyCorner.y).toBeCloseTo(frame.center.y, 5);
|
||||
|
||||
await page.mouse.move(frame.centerClient.x, frame.centerClient.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(frame.centerClient.x + 24, frame.centerClient.y + 18, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
const afterDrag = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { ...state.transformCenter };
|
||||
});
|
||||
expect(afterDrag.x).toBeGreaterThan(frame.center.x);
|
||||
expect(afterDrag.y).toBeGreaterThan(frame.center.y);
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
const afterKeys = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { ...state.transformCenter };
|
||||
});
|
||||
expect(afterKeys.x).toBeCloseTo(afterDrag.x + 1, 5);
|
||||
expect(afterKeys.y).toBeCloseTo(afterDrag.y + 10, 5);
|
||||
const readout = {
|
||||
x: Number(await page.locator('#ge-transform-x').inputValue()),
|
||||
y: Number(await page.locator('#ge-transform-y').inputValue()),
|
||||
w: Number(await page.locator('#ge-transform-w').inputValue()),
|
||||
h: Number(await page.locator('#ge-transform-h').inputValue()),
|
||||
angle: Number(await page.locator('#ge-transform-rot').inputValue()),
|
||||
};
|
||||
expect(readout.x).toBeCloseTo(afterKeys.x, 2);
|
||||
expect(readout.y).toBeCloseTo(afterKeys.y, 2);
|
||||
expect(readout.w).toBeGreaterThan(0);
|
||||
expect(readout.h).toBeGreaterThan(0);
|
||||
expect(readout.angle).toBe(45);
|
||||
|
||||
await page.locator('#ge-transform-x').fill('250');
|
||||
await page.locator('#ge-transform-y').fill('180');
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { ...state.transformCenter };
|
||||
})).toEqual({ x: 250, y: 180 });
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.ge-transform-popup')).toBeHidden();
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformActive;
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
test('transform previews remain source-derived and reject unsafe allocations', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 160 }, 'Transform source integrity');
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
for (let y = 0; y < layer.canvas.height; y += 8) {
|
||||
for (let x = 0; x < layer.canvas.width; x += 8) {
|
||||
layer.ctx.fillStyle = ((x / 8 + y / 8) % 2) ? '#f24f5f' : '#27c2a3';
|
||||
layer.ctx.fillRect(x, y, 8, 8);
|
||||
}
|
||||
}
|
||||
});
|
||||
const layerSnapshot = () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
const pixels = layer.ctx.getImageData(0, 0, layer.canvas.width, layer.canvas.height).data;
|
||||
let hash = 2166136261;
|
||||
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
|
||||
return { width: layer.canvas.width, height: layer.canvas.height, hash: hash >>> 0 };
|
||||
});
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
|
||||
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.8)));
|
||||
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
|
||||
const afterSequentialPreviews = await layerSnapshot();
|
||||
await page.locator('#ge-transform-cancel-btn').click();
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
|
||||
const afterDirectPreview = await layerSnapshot();
|
||||
expect(afterSequentialPreviews).toEqual(afterDirectPreview);
|
||||
|
||||
const safeWidth = await page.locator('#ge-transform-w').inputValue();
|
||||
const beforeRejected = await layerSnapshot();
|
||||
await page.locator('#ge-transform-w').fill('40000');
|
||||
await expect(page.locator('#toast')).toContainText('dimension limit');
|
||||
await expect(page.locator('#ge-transform-w')).toHaveValue(safeWidth);
|
||||
expect(await layerSnapshot()).toEqual(beforeRejected);
|
||||
await page.locator('#ge-transform-cancel-btn').click();
|
||||
});
|
||||
|
||||
test('mobile touch input can grab every transform handle', async ({ browser, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile transform handles');
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await expect(page.locator('.ge-transform-popup')).toBeVisible();
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const ids = ['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l', 'rot'];
|
||||
|
||||
const handlePoint = id => page.evaluate(async handleId => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
|
||||
const geometry = transformFrameGeometry({
|
||||
centerX: state.transformCenter.x,
|
||||
centerY: state.transformCenter.y,
|
||||
width: state.transformPendingW,
|
||||
height: state.transformPendingH,
|
||||
rotation: state.transformPendingRot,
|
||||
}, { zoom: state.zoom, rotationInside: handleId === 'rot' });
|
||||
const handle = geometry.handles.find(item => item.id === handleId);
|
||||
const rect = state.mainCanvas.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + handle.x * rect.width / state.mainCanvas.width,
|
||||
y: rect.top + handle.y * rect.height / state.mainCanvas.height,
|
||||
};
|
||||
}, id);
|
||||
|
||||
for (let index = 0; index < ids.length; index += 1) {
|
||||
const expectedId = ids[index];
|
||||
const point = await handlePoint(expectedId);
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: point.x, y: point.y, id: index + 1, radiusX: 4, radiusY: 4 }],
|
||||
});
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformHandle;
|
||||
})).toBe(expectedId);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformHandle;
|
||||
})).toBe(null);
|
||||
}
|
||||
|
||||
const right = await handlePoint('r');
|
||||
const widthBefore = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformPendingW;
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: right.x, y: right.y, id: 20, radiusX: 4, radiusY: 4 }],
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ x: right.x + 80, y: right.y, id: 20, radiusX: 4, radiusY: 4 }],
|
||||
});
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformPendingW;
|
||||
})).toBeGreaterThan(widthBefore);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
|
||||
const center = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const rect = state.mainCanvas.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + state.transformCenter.x * rect.width / state.mainCanvas.width,
|
||||
y: rect.top + state.transformCenter.y * rect.height / state.mainCanvas.height,
|
||||
zoom: state.zoom,
|
||||
};
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: center.x, y: center.y, id: 30, radiusX: 4, radiusY: 4 }],
|
||||
});
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformHandle;
|
||||
})).toBe('move');
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [
|
||||
{ x: center.x - 20, y: center.y, id: 30, radiusX: 4, radiusY: 4 },
|
||||
{ x: center.x + 20, y: center.y, id: 31, radiusX: 4, radiusY: 4 },
|
||||
],
|
||||
});
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformHandle;
|
||||
})).toBe(null);
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [
|
||||
{ x: center.x - 45, y: center.y, id: 30, radiusX: 4, radiusY: 4 },
|
||||
{ x: center.x + 45, y: center.y, id: 31, radiusX: 4, radiusY: 4 },
|
||||
],
|
||||
});
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.zoom;
|
||||
})).toBeGreaterThan(center.zoom);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
const repositoryRoot = path.join(__dirname, '..', '..');
|
||||
const defaultPython = process.platform === 'win32'
|
||||
? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe')
|
||||
: path.join(repositoryRoot, '.venv', 'bin', 'python');
|
||||
const python = process.env.ODYSSEUS_TEST_PYTHON || (fs.existsSync(defaultPython) ? defaultPython : 'python');
|
||||
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR
|
||||
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-photo-editor-e2e-'));
|
||||
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH
|
||||
|| path.join(dataDirectory, 'app.db');
|
||||
process.env.PHOTO_EDITOR_E2E_DB_PATH = databasePath;
|
||||
process.env.PHOTO_EDITOR_E2E_DATA_DIR = dataDirectory;
|
||||
|
||||
const browserName = process.env.PHOTO_EDITOR_E2E_BROWSER || 'chromium';
|
||||
if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
|
||||
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
|
||||
}
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: path.join(__dirname, 'photo-editor'),
|
||||
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
|
||||
globalSetup: require.resolve('./setup.js'),
|
||||
globalTeardown: require.resolve('./teardown.js'),
|
||||
use: {
|
||||
baseURL,
|
||||
browserName,
|
||||
headless: true,
|
||||
viewport: { width: 1440, height: 960 },
|
||||
extraHTTPHeaders: { 'Accept-Encoding': 'identity' },
|
||||
serviceWorkers: 'block',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
|
||||
cwd: repositoryRoot,
|
||||
url: baseURL,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
env: {
|
||||
...process.env,
|
||||
AUTH_ENABLED: 'false',
|
||||
DATABASE_URL: `sqlite:///${databasePath}`,
|
||||
ODYSSEUS_DATA_DIR: dataDirectory,
|
||||
ODYSSEUS_STARTUP_WARMUPS: '0',
|
||||
RESPONSE_COMPRESSION_ENABLED: 'false',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
const fs = require('node:fs');
|
||||
|
||||
module.exports = async () => {
|
||||
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
|
||||
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH;
|
||||
if (!dataDirectory || !databasePath) {
|
||||
throw new Error('Photo editor E2E data paths were not configured');
|
||||
}
|
||||
fs.mkdirSync(dataDirectory, { recursive: true });
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const fs = require('node:fs');
|
||||
|
||||
module.exports = async () => {
|
||||
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
|
||||
if (!dataDirectory) return;
|
||||
fs.rmSync(dataDirectory, { recursive: true, force: true });
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.route('http://editor.test/**', async route => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<button id="run">Generate</button>' });
|
||||
const body = await readFile(new URL('../static/js/editor/' + path.slice(1), import.meta.url), 'utf8');
|
||||
return route.fulfill({ contentType: 'text/javascript', body });
|
||||
});
|
||||
await page.goto('http://editor.test/');
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createApplyImageTool } = await import('/ai-tool-runner.js');
|
||||
const { decodeAIImage } = await import('/ai-operation.js');
|
||||
const { state } = await import('/state.js');
|
||||
state.editorOpen = true;
|
||||
let requests = 0;
|
||||
let layers = 0;
|
||||
const messages = [];
|
||||
window.fetch = (_url, { signal }) => {
|
||||
requests++;
|
||||
return new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true }));
|
||||
};
|
||||
const run = createApplyImageTool({
|
||||
flatten: () => document.createElement('canvas'), saveState() {},
|
||||
createLayer() { layers++; }, composite() {}, renderLayerPanel() {},
|
||||
deriveBusyLabel: () => 'Generating', getSelectedAIEndpoint: () => ({}),
|
||||
spinnerModule: {}, uiModule: { showToast: message => messages.push(message) },
|
||||
});
|
||||
const button = document.querySelector('button');
|
||||
let pending;
|
||||
button.addEventListener('click', () => { pending = run('/test', {}, 'Test', button); });
|
||||
button.click();
|
||||
const cancellable = !button.disabled && button.getAttribute('aria-busy') === 'true';
|
||||
button.click();
|
||||
await pending;
|
||||
const restored = button.textContent === 'Generate' && !button.hasAttribute('aria-busy');
|
||||
button.click();
|
||||
button.click();
|
||||
await pending;
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
let decodeCancelled = false;
|
||||
try { await decodeAIImage('invalid', controller.signal); }
|
||||
catch (error) { decodeCancelled = error.name === 'AbortError'; }
|
||||
return { requests, layers, messages, cancellable, restored, decodeCancelled };
|
||||
});
|
||||
assert.equal(result.requests, 2);
|
||||
assert.equal(result.layers, 0);
|
||||
assert.deepEqual(result.messages, ['Cancelled', 'Cancelled']);
|
||||
assert.equal(result.cancellable, true);
|
||||
assert.equal(result.restored, true);
|
||||
assert.equal(result.decodeCancelled, true);
|
||||
console.log('AI cancel: repeated click, request abort, retry, UI restoration, and decode guard passed');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { chromium } from 'playwright';
|
||||
import { appCss } from './helpers/stylesheets.mjs';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.route('http://editor.test/**', async route => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<button id="fx" style="position:fixed;bottom:4px;right:4px">fx</button>' });
|
||||
if (!path.endsWith('.js')) return route.fulfill({ status: 404, body: '' });
|
||||
return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('../static/js/editor' + path, import.meta.url), 'utf8') });
|
||||
});
|
||||
await page.goto('http://editor.test/');
|
||||
const results = await page.evaluate(async () => {
|
||||
const { LAYER_STYLES } = await import('/layer-styles.js');
|
||||
const { normalizeEffect, renderEffects, renderEffectsAsync, effectsWithPreview } = await import('/effects.js');
|
||||
const source = document.createElement('canvas'); source.width = source.height = 64;
|
||||
const ctx = source.getContext('2d'); ctx.fillStyle = '#668899'; ctx.fillRect(16, 16, 32, 32);
|
||||
const original = ctx.getImageData(0, 0, 64, 64).data;
|
||||
const shadow = normalizeEffect({ id: 'shadow', type: 'drop-shadow', params: { color: '#ff0000', opacity: 1, blur: 0, x: 8, y: 8 } });
|
||||
const shadowed = renderEffects(source, [shadow]);
|
||||
const pixel = (canvas, x, y) => [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
|
||||
if (String(pixel(shadowed, 30, 30)) !== String(pixel(source, 30, 30))) throw Error('shadow covers source');
|
||||
if (String(pixel(shadowed, 50, 50)) !== '255,0,0,255') throw Error('shadow offset or color');
|
||||
const workerShadow = await renderEffectsAsync(source, [shadow]);
|
||||
if (String(pixel(workerShadow, 50, 50)) !== String(pixel(shadowed, 50, 50))) throw Error('shadow worker mismatch');
|
||||
const edited = { ...shadow, params: { ...shadow.params, x: 12 } };
|
||||
const preview = effectsWithPreview({ effects: [shadow], _effectPreview: edited });
|
||||
if (preview.length !== 1 || preview[0] !== edited) throw Error('duplicate preview');
|
||||
const result = [];
|
||||
for (const type of Object.keys(LAYER_STYLES)) {
|
||||
const effect = normalizeEffect({ type });
|
||||
if (normalizeEffect(JSON.parse(JSON.stringify(effect))).type !== type) throw Error('restore ' + type);
|
||||
const sync = renderEffects(source, [effect]).getContext('2d').getImageData(0, 0, 64, 64).data;
|
||||
if (!sync.some((v, i) => v !== original[i])) throw Error('no effect ' + type);
|
||||
const asyncCanvas = await renderEffectsAsync(source, [effect]);
|
||||
const asyncPixels = asyncCanvas.getContext('2d').getImageData(0, 0, 64, 64).data;
|
||||
const difference = sync.reduce((max, v, i) => Math.max(max, Math.abs(v - asyncPixels[i])), 0);
|
||||
if (difference > 2) throw Error('worker mismatch ' + type + ': ' + difference);
|
||||
const hidden = renderEffects(source, [{ ...effect, visible: false }]).getContext('2d').getImageData(0, 0, 64, 64).data;
|
||||
if (hidden.some((v, i) => v !== original[i])) throw Error('hidden ' + type);
|
||||
if (ctx.getImageData(0, 0, 64, 64).data.some((v, i) => v !== original[i])) throw Error('mutated ' + type);
|
||||
result.push(type);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
assert.equal(results.length, 7);
|
||||
await page.addStyleTag({ content: await appCss() });
|
||||
await page.evaluate(async () => {
|
||||
const { openLayerStyleMenu } = await import('/layer-style-menu.js');
|
||||
document.querySelector('#fx').onclick = event => { event.stopPropagation(); openLayerStyleMenu(event.currentTarget, () => {}); };
|
||||
});
|
||||
for (const viewport of [{ width: 1280, height: 720 }, { width: 375, height: 420 }]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.locator('#fx').click();
|
||||
const menu = page.locator('.ge-layer-style-menu');
|
||||
assert.equal(await menu.locator('button').count(), 10);
|
||||
const bounds = await menu.boundingBox();
|
||||
assert.equal(await menu.evaluate(el => getComputedStyle(el).opacity), '1');
|
||||
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= viewport.width && bounds.y + bounds.height <= viewport.height);
|
||||
await page.screenshot({ path: `/tmp/editor-layer-styles-${viewport.width}.png` });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(30);
|
||||
assert.equal(await menu.count(), 0);
|
||||
}
|
||||
await page.locator('#fx').click();
|
||||
await page.locator('#fx').click();
|
||||
await page.waitForTimeout(30);
|
||||
assert.equal(await page.locator('.ge-layer-style-menu').count(), 0, 'repeat click closes menu');
|
||||
console.log('7 styles: render, worker parity, restoration, visibility and source preservation passed; 10-item menu fits desktop/mobile and closes on Escape.');
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { documentSource } from './helpers/document_source.mjs';
|
||||
import { chromium } from 'playwright';
|
||||
const source = documentSource();
|
||||
function extract(name) {
|
||||
const start = source.indexOf(` function ${name}(`);
|
||||
const rest = source.slice(start + 2);
|
||||
const next = rest.slice(10).search(/\n (?:export )?(?:async )?function /);
|
||||
return rest.slice(0, next + 10);
|
||||
}
|
||||
const functions = ['_showRichTextEditor', '_syncEmailRichbody'].map(extract).join('\n');
|
||||
const browser = await chromium.launch({headless:true});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent('<div class="doc-editor-pane"><div id="doc-editor-wrap"></div><textarea id="doc-editor-textarea"></textarea><div id="doc-email-richbody" contenteditable="true"></div></div>');
|
||||
const result = await page.evaluate(async (functions) => {
|
||||
const docs = new Map(); const activeDocId = 'fixture';
|
||||
let _richInlineCodeTypingArmed = false;
|
||||
const _clearRichImageSelection = () => {};
|
||||
const _normalizeRichTextImages = () => {};
|
||||
const _normalizeRichChecklists = () => {};
|
||||
const _normalizeRichInlineCode = () => {};
|
||||
const _wireEmailRichbody = () => {};
|
||||
const _syncRichEmptyImport = () => {};
|
||||
const _isRichTextLang = lang => lang === 'richtext';
|
||||
const _sanitizedRichTextHtml = rich => rich.innerHTML;
|
||||
const _richTextContentToHtml = content => content;
|
||||
const _emailRichbodyActive = () => document.getElementById('doc-email-richbody');
|
||||
const newContent = '<p>This sentence needs work.</p><p><strong>Keep this.</strong></p>';
|
||||
const doc = {id:activeDocId, language:'richtext',content:newContent}; docs.set(activeDocId,doc);
|
||||
eval(functions + '\n_showRichTextEditor(doc);');
|
||||
return {overlayGone:!document.querySelector('.doc-rich-diff-overlay'),rich:_emailRichbodyActive().innerHTML,mirror:document.getElementById('doc-editor-textarea').value,content:doc.content,newContent};
|
||||
}, functions);
|
||||
assert.equal(result.overlayGone, true);
|
||||
assert.equal(result.rich,result.newContent); assert.equal(result.mirror,result.newContent); assert.equal(result.content,result.newContent);
|
||||
const discardSource = source.slice(source.indexOf(' function exitDiffMode('), source.indexOf(' function isDiffModeActive('));
|
||||
const staleSaveCount = await page.evaluate(discardSource => {
|
||||
let _diffModeActive = true, _diffChunks = [], _diffOldContent = 'stale text', _diffNewContent = 'saved text', _diffUnresolvedCount = 1;
|
||||
let saves = 0;
|
||||
const saveDocument = () => { saves++; };
|
||||
const syncHighlighting = () => {};
|
||||
const updateLineNumbers = () => {};
|
||||
eval(discardSource + '\nexitDiffMode(true, { persist: false });');
|
||||
return saves;
|
||||
}, discardSource);
|
||||
assert.equal(staleSaveCount, 0);
|
||||
console.log('PASS: saved rich-text changes appear immediately with no transient diff overlay.');
|
||||
console.log('PASS: clearing a stale review diff cannot overwrite a newer saved edit.');
|
||||
} finally {await browser.close();}
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { documentSource } from './helpers/document_source.mjs';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const source = documentSource();
|
||||
const start = source.indexOf(' function _applySuggestions(');
|
||||
const end = source.indexOf(' /** Animate transition to next suggestion */', start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const applySource = source.slice(start, end);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent('<textarea id="doc-editor-textarea"></textarea><div id="doc-email-richbody"></div><iframe id="doc-html-preview"></iframe>');
|
||||
const results = await page.evaluate((fn) => {
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
const rich = document.getElementById('doc-email-richbody');
|
||||
const docs = new Map();
|
||||
const activeDocId = 'test-doc';
|
||||
let saves = 0;
|
||||
const errors = [];
|
||||
const uiModule = { showError: message => errors.push(message) };
|
||||
const saveCurrentToMap = () => {
|
||||
const doc = docs.get(activeDocId);
|
||||
doc.content = doc.language === 'richtext' ? rich.innerHTML :
|
||||
doc.language === 'email' ? `To: person@example.com\n\n${rich.innerHTML}` : textarea.value;
|
||||
};
|
||||
const _isRichTextLang = lang => lang === 'richtext';
|
||||
const _showRichTextEditor = doc => { rich.innerHTML = doc.content; textarea.value = doc.content; };
|
||||
const _showEmailFields = doc => { rich.innerHTML = doc.content.split('\n\n').slice(1).join('\n\n'); };
|
||||
const _refreshMarkdownPreviewIfVisible = () => {};
|
||||
const _htmlPreviewActive = true;
|
||||
const _isRenderLang = lang => lang === 'svg';
|
||||
const _themedRenderSrcdoc = content => content;
|
||||
const syncHighlighting = () => {};
|
||||
const saveDocument = () => { saveCurrentToMap(); saves++; };
|
||||
return eval(fn + `
|
||||
const outcome = {};
|
||||
for (const language of ['richtext', 'email', 'markdown', 'javascript', 'svg']) {
|
||||
const original = language === 'email' ? 'To: person@example.com\\n\\n<p>old phrase</p>' :
|
||||
language === 'richtext' ? '<p>old phrase</p>' : 'old phrase';
|
||||
docs.set(activeDocId, { id: activeDocId, language, content: original });
|
||||
if (language === 'email' || language === 'richtext') rich.innerHTML = '<p>old phrase</p>';
|
||||
else textarea.value = original;
|
||||
const applied = _applySuggestions([{ id: 'one', find: 'old phrase', replace: 'new phrase' }]);
|
||||
outcome[language] = {
|
||||
applied, content: docs.get(activeDocId).content,
|
||||
preview: document.getElementById('doc-html-preview').srcdoc,
|
||||
visible: language === 'email' || language === 'richtext' ? rich.innerHTML : textarea.value,
|
||||
};
|
||||
}
|
||||
const before = saves;
|
||||
const unmatched = _applySuggestions([{ id: 'missing', find: 'absent phrase', replace: 'x' }]);
|
||||
({ outcome, saves, before, unmatched, errors });
|
||||
`);
|
||||
}, applySource);
|
||||
for (const language of ['richtext', 'email', 'markdown', 'javascript', 'svg']) {
|
||||
assert.deepEqual(results.outcome[language].applied, ['one']);
|
||||
assert.match(results.outcome[language].content, /new phrase/);
|
||||
assert.match(results.outcome[language].visible, /new phrase/);
|
||||
}
|
||||
assert.match(results.outcome.svg.preview, /new phrase/);
|
||||
assert.equal(results.saves, 5);
|
||||
assert.equal(results.before, 5);
|
||||
assert.deepEqual(results.unmatched, []);
|
||||
assert.equal(results.errors.length, 1);
|
||||
console.log('PASS: accepting suggestions updates and saves rich text, email, markdown, and code documents.');
|
||||
console.log('PASS: stale suggestions remain pending and do not save an unchanged document.');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { documentSource } from './helpers/document_source.mjs';
|
||||
import { chromium } from 'playwright';
|
||||
import { appCss } from './helpers/stylesheets.mjs';
|
||||
|
||||
const source = documentSource();
|
||||
const start = source.indexOf(' function _showCurrentSuggestion()');
|
||||
const end = source.indexOf(' /** Show inline diff by modifying', start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const renderSource = source.slice(start, end);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 700, height: 500 } });
|
||||
await page.setContent('<div class="doc-editor-pane"><div id="doc-editor-wrap"></div></div>');
|
||||
await page.addStyleTag({ content: await appCss() });
|
||||
const result = await page.evaluate(code => {
|
||||
let _activeSuggestions = [];
|
||||
let _suggestionTotal = 0, _suggestionIndex = 0;
|
||||
let accepted = [];
|
||||
const _clearSuggestionHighlight = () => {};
|
||||
const topPortalZ = () => 10031;
|
||||
const _clearInlineDiff = () => {};
|
||||
const _clearSuggestionTextSelection = () => {};
|
||||
const _esc = value => String(value || '');
|
||||
const clearAllSuggestions = () => {};
|
||||
const _applySuggestions = suggestions => { accepted = suggestions.map(s => s.id); return accepted; };
|
||||
const _animateNext = () => {};
|
||||
return eval(code + `
|
||||
const inspect = count => {
|
||||
_activeSuggestions = Array.from({length:count}, (_, i) => ({id:String(i),find:'old'+i,replace:'new'+i,reason:'Reason'}));
|
||||
_suggestionTotal = count;
|
||||
_showCurrentSuggestion();
|
||||
const card = document.getElementById('doc-suggestion-active');
|
||||
const buttons = [...card.querySelectorAll('.doc-suggestion-actions button')];
|
||||
const cardRight = card.getBoundingClientRect().right;
|
||||
return {labels:buttons.map(button => button.textContent.trim()),
|
||||
fits:buttons.every(button => button.getBoundingClientRect().right <= cardRight + 1)};
|
||||
};
|
||||
const one = inspect(1);
|
||||
const many = inspect(3);
|
||||
document.querySelector('.doc-suggestion-accept-all').click();
|
||||
({one, many, accepted});
|
||||
`);
|
||||
}, renderSource);
|
||||
assert.deepEqual(result.one.labels, ['Accept', 'Accept All', 'Skip']);
|
||||
assert.deepEqual(result.many.labels, ['Accept', 'Accept All', 'Skip']);
|
||||
assert.equal(result.one.fits, true);
|
||||
assert.equal(result.many.fits, true);
|
||||
assert.deepEqual(result.accepted, ['0', '1', '2']);
|
||||
console.log('PASS: Accept, Accept All, and Skip stay visible and fit in one row.');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const url = process.env.ODYSSEUS_UI_TEST_URL;
|
||||
test('reply stream tolerates caret setup but never overwrites user input', { skip: !url }, async () => {
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
await context.addCookies([{ name: 'odysseus_session', value: process.env.ODYSSEUS_UI_TEST_COOKIE, url }]);
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.documentModule);
|
||||
await page.route('**/api/document/qa-stream-*', route => route.fulfill({ json: {} }));
|
||||
let editDuringRequest = false;
|
||||
await page.route('**/api/email/ai-reply', async route => {
|
||||
if (editDuringRequest) {
|
||||
await page.evaluate(() => {
|
||||
const rich = document.getElementById('doc-email-richbody');
|
||||
rich.textContent = 'My own reply';
|
||||
rich.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
await route.fulfill({ contentType: 'text/event-stream', body: [
|
||||
{ type: 'reply', text: '' },
|
||||
{ type: 'reply', text: 'Hi QA,\nTomorrow works.\nFelix' },
|
||||
{ type: 'result', success: true, reply: 'Hi QA,\nTomorrow works.\nFelix', model_used: 'fixture' },
|
||||
].map(event => `data: ${JSON.stringify(event)}\n\n`).join('') });
|
||||
});
|
||||
const run = id => page.evaluate(async id => {
|
||||
await window.documentModule.injectFreshDoc({ id, title: 'QA reply stream', language: 'email',
|
||||
content: 'To: qa@example.com\nSubject: QA\n---\n\n---------- Previous message ----------\nHello' });
|
||||
const success = await window.documentModule.generateEmailReply({ originalBody: 'Hi Felix, can we meet tomorrow?' });
|
||||
return { success, body: document.getElementById('doc-editor-textarea').value,
|
||||
toast: [...document.querySelectorAll('.toast-message')].map(el => el.textContent).join('\n') };
|
||||
}, id);
|
||||
const generated = await run('qa-stream-normal');
|
||||
assert.equal(generated.success, true, generated.toast);
|
||||
assert.match(generated.body, /Tomorrow works/);
|
||||
assert.match(generated.body, /Previous message/);
|
||||
editDuringRequest = true;
|
||||
const edited = await run('qa-stream-edited');
|
||||
assert.notEqual(edited.success, true);
|
||||
assert.equal(edited.body, 'My own reply');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readEmailReplyResponse} from '../static/js/emailReplyStream.js';
|
||||
|
||||
const response = frames => new Response(frames.map(f => `data: ${JSON.stringify(f)}\n\n`).join(''),
|
||||
{headers: {'content-type': 'text/event-stream'}});
|
||||
|
||||
test('streams body then requires successful terminal result', async () => {
|
||||
const seen = [];
|
||||
const result = await readEmailReplyResponse(response([
|
||||
{type:'reply', text:'Hi'}, {type:'reply', text:'Hi Jonathan'},
|
||||
{type:'result', success:true, reply:'Hi Jonathan'},
|
||||
]), text => seen.push(text));
|
||||
assert.deepEqual(seen, ['Hi', 'Hi Jonathan']);
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
test('editing the draft stops streaming insertion', async () => {
|
||||
await assert.rejects(readEmailReplyResponse(response([{type:'reply', text:'Hi'}]), () => false), /edited or closed/);
|
||||
});
|
||||
|
||||
test('disconnect is not a completed draft', async () => {
|
||||
await assert.rejects(readEmailReplyResponse(response([{type:'reply', text:'Hi'}]), () => {}), /before completion/);
|
||||
});
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
[
|
||||
{
|
||||
"id": "clean_v3_open_email_reply",
|
||||
"kind": "draft",
|
||||
"user": "Write reply this email saying 8am works for me",
|
||||
"active_document": {
|
||||
"title": "New Email",
|
||||
"language": "email",
|
||||
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture@example.com>\nReferences: <fixture@example.com>\nX-Source-UID: 999999\n---\n\n---------- Previous message ----------\nCan you confirm the meeting time?\n"
|
||||
},
|
||||
"expect_first_tool": "update_document",
|
||||
"forbidden_tools": [
|
||||
"create_document",
|
||||
"edit_document",
|
||||
"suggest_document",
|
||||
"manage_documents",
|
||||
"web_search"
|
||||
],
|
||||
"must_mutate": "document_contains_8am"
|
||||
},
|
||||
{
|
||||
"id": "clean_v3_open_email_short_reply",
|
||||
"kind": "draft",
|
||||
"user": "Write reply saying 8am works for me",
|
||||
"active_document": {
|
||||
"title": "Meeting",
|
||||
"language": "email",
|
||||
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture-short@example.com>\nReferences: <fixture-short@example.com>\nX-Source-UID: 999998\n---\n\n---------- Previous message ----------\nCan you confirm the meeting time?\n"
|
||||
},
|
||||
"expect_first_tool": "update_document",
|
||||
"forbidden_tools": [
|
||||
"create_document",
|
||||
"edit_document",
|
||||
"suggest_document",
|
||||
"manage_documents",
|
||||
"web_search"
|
||||
],
|
||||
"must_mutate": "document_contains_8am"
|
||||
},
|
||||
{
|
||||
"id": "clean_v3_open_email_unspecified_reply",
|
||||
"kind": "draft",
|
||||
"user": "Write reply to this email",
|
||||
"active_document": {
|
||||
"title": "Meeting",
|
||||
"language": "email",
|
||||
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture-unspecified@example.com>\nReferences: <fixture-unspecified@example.com>\nX-Source-UID: 999997\n---\n\n---------- Previous message ----------\nCan you confirm whether tomorrow morning works?\n"
|
||||
},
|
||||
"expect_first_tool": "update_document",
|
||||
"forbidden_tools": [
|
||||
"create_document",
|
||||
"edit_document",
|
||||
"suggest_document",
|
||||
"manage_documents",
|
||||
"ui_control",
|
||||
"web_search"
|
||||
],
|
||||
"forbidden_repeat_tools": ["update_document"],
|
||||
"expect_document_changed": true,
|
||||
"must_preserve_active_document_all": [
|
||||
"To:",
|
||||
"Subject:",
|
||||
"In-Reply-To:",
|
||||
"References:",
|
||||
"X-Source-UID:",
|
||||
"---"
|
||||
]
|
||||
}
|
||||
]
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
[
|
||||
{
|
||||
"name": "microsoft/Phi-mini-MoE-instruct",
|
||||
"provider": "test",
|
||||
"parameter_count": "2B",
|
||||
"parameters_raw": 2000000000,
|
||||
"quantization": "F16",
|
||||
"context_length": 4096,
|
||||
"release_date": "2026-01-01",
|
||||
"gguf_sources": []
|
||||
},
|
||||
{
|
||||
"name": "Qwen/Qwen2.5-3B-Instruct-AWQ",
|
||||
"provider": "test",
|
||||
"parameter_count": "3B",
|
||||
"parameters_raw": 3000000000,
|
||||
"quantization": "AWQ-4bit",
|
||||
"context_length": 4096,
|
||||
"release_date": "2026-01-02",
|
||||
"gguf_sources": []
|
||||
},
|
||||
{
|
||||
"name": "Qwen/Qwen2.5-3B-Instruct",
|
||||
"provider": "test",
|
||||
"parameter_count": "3B",
|
||||
"parameters_raw": 3000000000,
|
||||
"quantization": "F16",
|
||||
"context_length": 4096,
|
||||
"release_date": "2026-01-03",
|
||||
"gguf_sources": [
|
||||
{
|
||||
"repo": "test/three-GGUF",
|
||||
"file": "three-Q4_K_M.gguf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Qwen/Qwen3.5-9B",
|
||||
"provider": "test",
|
||||
"parameter_count": "3B",
|
||||
"parameters_raw": 3000000000,
|
||||
"quantization": "F16",
|
||||
"context_length": 4096,
|
||||
"release_date": "2026-01-04",
|
||||
"gguf_sources": [
|
||||
{
|
||||
"repo": "unsloth/Qwen3.5-9B-GGUF",
|
||||
"file": "Qwen3.5-9B-Q4_K_M.gguf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Qwen/Qwen3.6-27B",
|
||||
"provider": "test",
|
||||
"parameter_count": "3B",
|
||||
"parameters_raw": 3000000000,
|
||||
"quantization": "F16",
|
||||
"context_length": 4096,
|
||||
"release_date": "2026-01-05",
|
||||
"gguf_sources": [
|
||||
{
|
||||
"repo": "unsloth/Qwen3.6-27B-GGUF",
|
||||
"file": "Qwen3.6-27B-Q4_K_M.gguf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Qwen/Qwen3.6-35B-A3B",
|
||||
"provider": "test",
|
||||
"parameter_count": "3B",
|
||||
"parameters_raw": 3000000000,
|
||||
"quantization": "F16",
|
||||
"context_length": 4096,
|
||||
"release_date": "2026-01-06",
|
||||
"gguf_sources": [
|
||||
{
|
||||
"repo": "unsloth/Qwen3.6-35B-A3B-GGUF",
|
||||
"file": "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-12B-it",
|
||||
"provider": "test",
|
||||
"parameter_count": "12B",
|
||||
"parameters_raw": 12000000000,
|
||||
"quantization": "F16",
|
||||
"context_length": 4096,
|
||||
"release_date": "2026-01-07",
|
||||
"gguf_sources": [
|
||||
{
|
||||
"repo": "unsloth/gemma-4-12B-it-GGUF",
|
||||
"file": "test-Q4_K_M.gguf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-12B-it-qat-int4",
|
||||
"provider": "test",
|
||||
"parameter_count": "12B",
|
||||
"parameters_raw": 12000000000,
|
||||
"quantization": "QAT-INT4",
|
||||
"context_length": 4096,
|
||||
"release_date": "2026-01-08",
|
||||
"gguf_sources": []
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-12B-it-qat-int8",
|
||||
"provider": "test",
|
||||
"parameter_count": "12B",
|
||||
"parameters_raw": 12000000000,
|
||||
"quantization": "QAT-INT8",
|
||||
"context_length": 4096,
|
||||
"release_date": "2026-01-09",
|
||||
"gguf_sources": []
|
||||
},
|
||||
{
|
||||
"name": "test/native-NVFP4",
|
||||
"provider": "test",
|
||||
"parameter_count": "12B",
|
||||
"parameters_raw": 12000000000,
|
||||
"quantization": "NVFP4",
|
||||
"context_length": 4096,
|
||||
"release_date": "2026-01-10",
|
||||
"gguf_sources": []
|
||||
},
|
||||
{
|
||||
"name": "test/native-FP8",
|
||||
"provider": "test",
|
||||
"parameter_count": "3B",
|
||||
"parameters_raw": 3000000000,
|
||||
"quantization": "FP8",
|
||||
"context_length": 4096,
|
||||
"release_date": "2026-01-11",
|
||||
"gguf_sources": []
|
||||
},
|
||||
{
|
||||
"name": "mlx-community/test-4bit",
|
||||
"provider": "test",
|
||||
"parameter_count": "3B",
|
||||
"parameters_raw": 3000000000,
|
||||
"quantization": "mlx-4bit",
|
||||
"context_length": 4096,
|
||||
"release_date": "2026-01-12",
|
||||
"gguf_sources": []
|
||||
},
|
||||
{
|
||||
"name": "test/standalone-GGUF",
|
||||
"provider": "test",
|
||||
"parameter_count": "3B",
|
||||
"parameters_raw": 3000000000,
|
||||
"quantization": "Q4_K_M",
|
||||
"context_length": 4096,
|
||||
"release_date": null,
|
||||
"gguf_sources": [],
|
||||
"is_gguf": true
|
||||
}
|
||||
]
|
||||
+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,16 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { generatedImageResult } from '../static/js/generatedImageResult.js';
|
||||
|
||||
test('restores successful legacy images from saved tool output', () => {
|
||||
const image = { image_url: '/api/generated-image/test.png', image_model: 'openai/gpt-5-image' };
|
||||
assert.deepEqual(generatedImageResult({ tool: 'generate_image', exit_code: 0, output: JSON.stringify(image) }), image);
|
||||
assert.equal(generatedImageResult({ tool: 'generate_image', error: true, output: JSON.stringify(image) }), null);
|
||||
assert.equal(generatedImageResult({ tool: 'web_fetch', output: JSON.stringify(image) }), null);
|
||||
assert.equal(generatedImageResult({ tool: 'generate_image', output: 'invalid' }), null);
|
||||
});
|
||||
|
||||
test('uses persisted image metadata for new turns', () => {
|
||||
const event = { tool: 'generate_image', image_url: '/api/generated-image/test.png', exit_code: 0 };
|
||||
assert.equal(generatedImageResult(event), event);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Disposable databases for tests that exercise the real ORM and session manager."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
|
||||
@contextmanager
|
||||
def disposable_database(tmp_path):
|
||||
"""Own the database file and engine; keep the canonical ORM classes intact."""
|
||||
import core.database as database
|
||||
|
||||
with TemporaryDirectory(prefix="database-", dir=tmp_path) as directory:
|
||||
engine = create_engine(
|
||||
f"sqlite:///{directory}/test.db",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
try:
|
||||
database.Base.metadata.create_all(engine)
|
||||
yield sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def isolated_session_database(tmp_path):
|
||||
"""Temporarily bind the real manager and database aliases without reloading.
|
||||
|
||||
Reloading core.database changes its ORM classes while existing imports keep
|
||||
the old classes and factories. Patch only resource bindings instead, and
|
||||
undo them before disposing the owned engine and removing its files.
|
||||
"""
|
||||
import core.database as database
|
||||
import core.session_manager as manager
|
||||
import src.database as compatibility_database
|
||||
|
||||
with disposable_database(tmp_path) as factory:
|
||||
engine = factory.kw["bind"]
|
||||
with pytest.MonkeyPatch.context() as patcher:
|
||||
patcher.setenv("DATABASE_URL", str(engine.url))
|
||||
for module in (database, compatibility_database):
|
||||
patcher.setattr(module, "DATABASE_URL", str(engine.url))
|
||||
patcher.setattr(module, "engine", engine)
|
||||
patcher.setattr(module, "SessionLocal", factory)
|
||||
patcher.setattr(manager, "SessionLocal", factory)
|
||||
yield manager.SessionManager(), database
|
||||
@@ -0,0 +1,32 @@
|
||||
// JS twin of tests/helpers/document_source.py: the document editor's whole
|
||||
// implementation set, so tests keep finding code as it moves out of the
|
||||
// static/js/document.js entry into static/js/document/.
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const JS = join(HERE, '..', '..', 'static', 'js');
|
||||
const ENTRY = join(JS, 'document.js');
|
||||
const IMPL_DIR = join(JS, 'document');
|
||||
|
||||
function walk(dir) {
|
||||
return readdirSync(dir).sort().flatMap(name => {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) return walk(path);
|
||||
return name.endsWith('.js') ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Every file holding document-editor implementation, entry first. */
|
||||
export function documentSourcePaths() {
|
||||
if (!existsSync(ENTRY)) {
|
||||
throw new Error(`document editor entry point is missing: ${ENTRY}`);
|
||||
}
|
||||
return [ENTRY, ...(existsSync(IMPL_DIR) ? walk(IMPL_DIR).sort() : [])];
|
||||
}
|
||||
|
||||
/** The whole implementation set as one string, entry first. */
|
||||
export function documentSource() {
|
||||
return documentSourcePaths().map(path => readFileSync(path, 'utf8')).join('\n');
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Read the document editor's JavaScript the way the browser loads it.
|
||||
|
||||
``static/js/document.js`` is being decomposed. It stays the entry point the
|
||||
browser requests -- ``static/index.html`` names it, ``static/sw.js`` precaches
|
||||
it, and five modules import it -- but the implementation moves into modules
|
||||
under ``static/js/document/``. The implementation set is the entry plus that
|
||||
directory.
|
||||
|
||||
Two habits in the existing tests do not survive that move, and this module
|
||||
exists to replace both.
|
||||
|
||||
**Reading the entry file alone.** A membership assertion against
|
||||
``document.js`` silently covers less the moment the behaviour it names moves
|
||||
out. Use :func:`document_source` for those: it is the whole implementation set,
|
||||
so a test keeps finding what it asserts on wherever the code lands.
|
||||
|
||||
**Slicing between two adjacent functions.** ``function_body("a")`` means "the region between a and b", which is only
|
||||
the body of ``a`` while ``a`` and ``b`` happen to be neighbours in one file.
|
||||
After a split they may sit in different modules, and then the slice runs to the
|
||||
end of the concatenation and quietly grows: an ``assert "x" in region`` passes
|
||||
against code it was never meant to see. Several of these also hard-code the
|
||||
entry file's two-space indentation (``"\\n function showDocTabMenu"``), which
|
||||
no extracted module reproduces. Use :func:`function_body` or
|
||||
:func:`declaration` instead -- they find the construct by name, in whichever
|
||||
module defines it, and end at its real closing brace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_STATIC = Path(__file__).resolve().parents[2] / "static"
|
||||
_ENTRY = _STATIC / "js" / "document.js"
|
||||
|
||||
# Extracted implementation modules get one home, so the set is discoverable
|
||||
# without a manifest anyone has to remember to update.
|
||||
_IMPL_DIR = _STATIC / "js" / "document"
|
||||
|
||||
|
||||
def document_source_paths() -> list[Path]:
|
||||
"""Every file holding document-editor implementation, entry first.
|
||||
|
||||
The entry comes first so a concatenation reads in the order the browser
|
||||
evaluates the graph's root; the rest are sorted for determinism.
|
||||
"""
|
||||
if not _ENTRY.is_file():
|
||||
raise AssertionError(f"document editor entry point is missing: {_ENTRY}")
|
||||
extracted = sorted(_IMPL_DIR.rglob("*.js")) if _IMPL_DIR.is_dir() else []
|
||||
return [_ENTRY, *extracted]
|
||||
|
||||
|
||||
def document_source() -> str:
|
||||
"""The whole implementation set as one string, entry first.
|
||||
|
||||
For membership assertions (``assert "..." in document_source()``). For
|
||||
anything positional use :func:`function_body` or :func:`declaration`.
|
||||
"""
|
||||
return "\n".join(p.read_text(encoding="utf-8") for p in document_source_paths())
|
||||
|
||||
|
||||
# --- Locating a construct by name, not by what follows it ------------------
|
||||
|
||||
def _defining_source(pattern: re.Pattern[str], what: str) -> tuple[str, int]:
|
||||
"""The source text that defines ``what``, and the offset of the match."""
|
||||
hits = []
|
||||
for path in document_source_paths():
|
||||
src = path.read_text(encoding="utf-8")
|
||||
for m in pattern.finditer(src):
|
||||
hits.append((path, src, m.start()))
|
||||
if not hits:
|
||||
raise AssertionError(f"{what} is not defined anywhere in {_describe_set()}")
|
||||
if len(hits) > 1:
|
||||
where = ", ".join(
|
||||
f"{p.relative_to(_STATIC.parent)}:{s.count(chr(10), 0, o) + 1}"
|
||||
for p, s, o in hits
|
||||
)
|
||||
raise AssertionError(f"{what} is defined more than once ({where})")
|
||||
_path, src, offset = hits[0]
|
||||
return src, offset
|
||||
|
||||
|
||||
def _describe_set() -> str:
|
||||
return ", ".join(str(p.relative_to(_STATIC.parent)) for p in document_source_paths())
|
||||
|
||||
|
||||
def function_body(name: str) -> str:
|
||||
"""The full text of function ``name``, signature through closing brace.
|
||||
|
||||
Matches ``function name``, optionally prefixed by ``export`` and/or
|
||||
``async``, at any indentation, in whichever module of the implementation
|
||||
set defines it. The end is found by matching braces rather than by naming
|
||||
whatever declaration follows, so moving the function -- or the one after
|
||||
it -- does not change the region a test sees.
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r"^[ \t]*(?:export\s+)?(?:async\s+)?function\s+" + re.escape(name) + r"\s*\(",
|
||||
re.M,
|
||||
)
|
||||
src, offset = _defining_source(pattern, f"function {name}")
|
||||
# Skip the parameter list before looking for the body. A destructured
|
||||
# parameter -- `function f(table, { headerRow, headerColumn })` -- opens a
|
||||
# brace that is not the body, and matching it would return the signature
|
||||
# alone.
|
||||
body_start = _end_of_params(src, src.index("(", offset))
|
||||
return src[offset : _end_of_block(src, body_start)]
|
||||
|
||||
|
||||
def declaration(name: str) -> str:
|
||||
"""The full text of a top-level ``const``/``let``/``var`` named ``name``.
|
||||
|
||||
For the array and object tables the tests assert on (toolbar groups, slash
|
||||
commands, input rules). Ends at the declaration's closing bracket or brace,
|
||||
or at the end of the statement for a simple initialiser.
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r"^[ \t]*(?:export\s+)?(?:const|let|var)\s+" + re.escape(name) + r"\b",
|
||||
re.M,
|
||||
)
|
||||
src, offset = _defining_source(pattern, f"declaration {name}")
|
||||
return src[offset : _end_of_statement(src, offset)]
|
||||
|
||||
|
||||
# --- A brace matcher that is not fooled by braces inside literals ----------
|
||||
#
|
||||
# `document.js` is full of template literals building DOM, regexes containing
|
||||
# braces, and apostrophes inside comments. Counting raw `{`/`}` mis-slices on
|
||||
# all three, so the scan tracks what kind of text it is inside.
|
||||
|
||||
# After one of these, `/` starts a regex literal; after a value it is division.
|
||||
_REGEX_OK_BEFORE = re.compile(r"[({\[,;:=!&|?+\-*~^%<>]\s*$|\b(?:return|typeof|case|in|of|new|delete|void|do|else|yield|await)\s*$")
|
||||
|
||||
|
||||
def _scan(src: str, start: int, stop):
|
||||
"""Walk ``src`` from ``start``, skipping literals and comments.
|
||||
|
||||
Calls ``stop(index, depth_delta_applied)``-free: instead it yields
|
||||
``(index, char)`` for code positions only, so callers can track nesting.
|
||||
"""
|
||||
i, n = start, len(src)
|
||||
# Stack of template-literal depths: entering `${` pushes brace depth.
|
||||
template_stack: list[int] = []
|
||||
while i < n:
|
||||
c = src[i]
|
||||
two = src[i : i + 2]
|
||||
if two == "//":
|
||||
j = src.find("\n", i)
|
||||
i = n if j == -1 else j + 1
|
||||
continue
|
||||
if two == "/*":
|
||||
j = src.find("*/", i + 2)
|
||||
i = n if j == -1 else j + 2
|
||||
continue
|
||||
if c in "'\"":
|
||||
i = _skip_quoted(src, i, c)
|
||||
continue
|
||||
if c == "`":
|
||||
i += 1
|
||||
i, entered = _skip_template(src, i)
|
||||
if entered:
|
||||
template_stack.append(0)
|
||||
continue
|
||||
if c == "/" and _REGEX_OK_BEFORE.search(src[max(0, i - 24) : i]):
|
||||
j = _skip_regex(src, i)
|
||||
if j is not None:
|
||||
i = j
|
||||
continue
|
||||
if template_stack:
|
||||
# Inside `${ ... }`: a `}` that closes it returns to template text.
|
||||
if c == "{":
|
||||
template_stack[-1] += 1
|
||||
elif c == "}":
|
||||
if template_stack[-1] == 0:
|
||||
template_stack.pop()
|
||||
i += 1
|
||||
i, entered = _skip_template(src, i)
|
||||
if entered:
|
||||
template_stack.append(0)
|
||||
continue
|
||||
template_stack[-1] -= 1
|
||||
yield i, c
|
||||
i += 1
|
||||
|
||||
|
||||
def _skip_quoted(src: str, i: int, quote: str) -> int:
|
||||
i += 1
|
||||
n = len(src)
|
||||
while i < n:
|
||||
if src[i] == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if src[i] == quote:
|
||||
return i + 1
|
||||
if src[i] == "\n": # unterminated; do not run away
|
||||
return i
|
||||
i += 1
|
||||
return n
|
||||
|
||||
|
||||
def _skip_template(src: str, i: int) -> tuple[int, bool]:
|
||||
"""From inside template text, advance to the backtick end or a ``${``.
|
||||
|
||||
Returns the new index and whether an interpolation was entered.
|
||||
"""
|
||||
n = len(src)
|
||||
while i < n:
|
||||
if src[i] == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if src[i] == "`":
|
||||
return i + 1, False
|
||||
if src[i : i + 2] == "${":
|
||||
return i + 2, True
|
||||
i += 1
|
||||
return n, False
|
||||
|
||||
|
||||
def _skip_regex(src: str, i: int) -> int | None:
|
||||
"""Past a regex literal starting at ``i``, or None if it is not one."""
|
||||
i += 1
|
||||
n = len(src)
|
||||
in_class = False
|
||||
while i < n:
|
||||
c = src[i]
|
||||
if c == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if c == "\n":
|
||||
return None
|
||||
if in_class:
|
||||
if c == "]":
|
||||
in_class = False
|
||||
elif c == "[":
|
||||
in_class = True
|
||||
elif c == "/":
|
||||
i += 1
|
||||
while i < n and src[i].isalpha(): # flags
|
||||
i += 1
|
||||
return i
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def _end_of_block(src: str, start: int) -> int:
|
||||
"""Index just past the ``}`` closing the first ``{`` at or after ``start``."""
|
||||
depth = 0
|
||||
seen = False
|
||||
for i, c in _scan(src, start, None):
|
||||
if c == "{":
|
||||
depth += 1
|
||||
seen = True
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if seen and depth == 0:
|
||||
return i + 1
|
||||
raise AssertionError(f"unbalanced braces from offset {start}")
|
||||
|
||||
|
||||
def _end_of_statement(src: str, start: int) -> int:
|
||||
"""Index just past the end of the declaration statement at ``start``.
|
||||
|
||||
Ends on the ``;`` or newline that closes it at nesting depth zero, so an
|
||||
array or object initialiser is returned whole.
|
||||
"""
|
||||
depth = 0
|
||||
for i, c in _scan(src, start, None):
|
||||
if c in "{[(":
|
||||
depth += 1
|
||||
elif c in "}])":
|
||||
depth -= 1
|
||||
elif depth == 0 and c == ";":
|
||||
return i + 1
|
||||
elif depth == 0 and c == "\n" and i > start:
|
||||
return i
|
||||
return len(src)
|
||||
|
||||
|
||||
def _end_of_params(src: str, open_paren: int) -> int:
|
||||
"""Index just past the ``)`` closing the parameter list at ``open_paren``."""
|
||||
depth = 0
|
||||
for i, c in _scan(src, open_paren, None):
|
||||
if c == "(":
|
||||
depth += 1
|
||||
elif c == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i + 1
|
||||
raise AssertionError(f"unbalanced parameter list at offset {open_paren}")
|
||||
@@ -31,6 +31,7 @@ safe for callers that pass both a parent package and a child module.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
|
||||
_ABSENT = object()
|
||||
@@ -167,3 +168,33 @@ def preserve_import_state(*module_names):
|
||||
# Phase 2: restore all parent-package attributes.
|
||||
for name, (_, saved_attr) in saved.items():
|
||||
_restore_parent_attr(name, saved_attr)
|
||||
|
||||
|
||||
# Names under these prefixes are the ones a leaked stub actually breaks: a
|
||||
# later test doing ``import src.x`` or ``import core.x`` silently gets the
|
||||
# empty stub instead of the real module.
|
||||
_GUARDED_PREFIXES = ("src.", "core.")
|
||||
|
||||
|
||||
def bare_module_stubs():
|
||||
"""Return the ``src.*``/``core.*`` names currently bound to a bare stub.
|
||||
|
||||
A bare stub is a plain :class:`types.ModuleType` with no on-disk
|
||||
``__file__`` — the object ``types.ModuleType(name)`` produces. That is the
|
||||
same "is this a fake?" test the ``clear_fake_*`` helpers above use, so a
|
||||
module imported from disk is never reported.
|
||||
|
||||
``MagicMock`` stand-ins are deliberately out of scope: they answer every
|
||||
attribute, so they fail loudly at use rather than silently, and several
|
||||
test modules install them on purpose.
|
||||
"""
|
||||
found = set()
|
||||
for name, mod in list(sys.modules.items()):
|
||||
if not name.startswith(_GUARDED_PREFIXES):
|
||||
continue
|
||||
if type(mod) is not types.ModuleType:
|
||||
continue
|
||||
if getattr(mod, "__file__", None):
|
||||
continue
|
||||
found.add(name)
|
||||
return found
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Read a split JS subpackage the way the module graph does.
|
||||
|
||||
``static/js/emailLibrary.js`` is a re-export wrapper; the implementation lives
|
||||
in ``static/js/emailLibrary/``. A test that asserts on email-library behaviour
|
||||
has to look at every module in that package, because reading one file ties the
|
||||
test to whichever module a function happens to sit in today — it goes red the
|
||||
next time something moves without any behaviour changing.
|
||||
|
||||
That is the mistake the stylesheet split made, which is why
|
||||
``tests/helpers/stylesheets.py`` exists. This is the same helper for JS.
|
||||
|
||||
Order is deterministic: the entry module first, then the rest alphabetically.
|
||||
Tests that assert "A appears before B" are asserting about one module's source,
|
||||
not about the package, so the concatenation order only has to be stable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_STATIC_JS = Path(__file__).resolve().parents[2] / "static" / "js"
|
||||
|
||||
EMAIL_LIBRARY_WRAPPER = _STATIC_JS / "emailLibrary.js"
|
||||
EMAIL_LIBRARY_PACKAGE = _STATIC_JS / "emailLibrary"
|
||||
EMAIL_LIBRARY_ENTRY = EMAIL_LIBRARY_PACKAGE / "index.js"
|
||||
|
||||
|
||||
def _package_paths(package: Path, entry: Path) -> list[Path]:
|
||||
if not entry.is_file():
|
||||
raise AssertionError(f"missing package entry module: {entry}")
|
||||
rest = sorted(p for p in package.glob("*.js") if p != entry)
|
||||
return [entry, *rest]
|
||||
|
||||
|
||||
def email_library_paths(include_wrapper: bool = False) -> list[Path]:
|
||||
"""Every module of the email-library package, entry module first.
|
||||
|
||||
``include_wrapper`` adds the compatibility file at the old top-level path.
|
||||
Leave it off for assertions about implementation code: the wrapper holds
|
||||
only an ``export … from`` list.
|
||||
"""
|
||||
paths = _package_paths(EMAIL_LIBRARY_PACKAGE, EMAIL_LIBRARY_ENTRY)
|
||||
return [EMAIL_LIBRARY_WRAPPER, *paths] if include_wrapper else paths
|
||||
|
||||
|
||||
def email_library_source(include_wrapper: bool = False) -> str:
|
||||
"""The whole email-library package as one string."""
|
||||
return "\n".join(
|
||||
p.read_text(encoding="utf-8") for p in email_library_paths(include_wrapper)
|
||||
)
|
||||
|
||||
|
||||
def js_function_source(name: str, source: str | None = None) -> str:
|
||||
"""One top-level JS function, from its signature to its closing brace.
|
||||
|
||||
Two things this does not do, on purpose.
|
||||
|
||||
It does not slice between a signature and a marker further down ("from
|
||||
``_toggleCardPreview`` to the ``Wrap a probable signature`` comment"). That
|
||||
is what a split breaks: the marker ends up in another module, the slice runs
|
||||
past the end of the function without failing, and the assertions keep
|
||||
passing against the wrong text.
|
||||
|
||||
It does not balance braces by walking characters either. The obvious version
|
||||
of that walker treats the apostrophe in a ``// that's a scroll`` comment as
|
||||
an open quote and swallows every brace until the next one, which ends the
|
||||
function early — silently, again.
|
||||
|
||||
Instead it uses the invariant the file actually holds: a top-level
|
||||
declaration starts at column 0, so its closing brace is the next lone ``}``
|
||||
at column 0.
|
||||
"""
|
||||
text = email_library_source() if source is None else source
|
||||
signature = re.compile(
|
||||
r"^(?:export\s+)?(?:async\s+)?function\s+" + re.escape(name) + r"\s*\(",
|
||||
re.M,
|
||||
)
|
||||
match = signature.search(text)
|
||||
assert match, f"no top-level declaration of {name}"
|
||||
closing = re.compile(r"^\}", re.M).search(text, match.end())
|
||||
assert closing, f"unterminated function {name}"
|
||||
return text[match.start():closing.end()]
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Private filesystem defaults established before application imports."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def bootstrap_runtime():
|
||||
"""Establish defaults before collection; APP_PORT opts into live smoke."""
|
||||
worker = os.environ.get("PYTEST_XDIST_WORKER")
|
||||
if os.environ.get("APP_PORT") and not worker:
|
||||
return None
|
||||
runtime = isolated_runtime(worker or "main")
|
||||
runtime.root = runtime.__enter__()
|
||||
return runtime
|
||||
|
||||
|
||||
def configure_runtime(config, runtime):
|
||||
"""Register ownership even when configuration or collection fails."""
|
||||
if runtime is not None:
|
||||
config.add_cleanup(lambda: runtime.__exit__(None, None, None))
|
||||
# pytest's default <TMPDIR>/pytest-of-<user>/pytest-<n> beneath the
|
||||
# private TMPDIR, plus xdist's popen-gw<n>, overflows the 107-byte
|
||||
# AF_UNIX limit for sockets in tmp_path. Workers inherit a basetemp
|
||||
# under the controller's; an explicit --basetemp still wins.
|
||||
if config.option.basetemp is None and not hasattr(config, "workerinput"):
|
||||
config.option.basetemp = str(runtime.root / "pytest")
|
||||
parallel = bool(getattr(config.option, "numprocesses", None)) or hasattr(config, "workerinput")
|
||||
# This consumes the existing test option; its public read and documented
|
||||
# source location remain in the static-server fixture.
|
||||
port_variable = "ODYSSEUS_TEST_STATIC_PORT"
|
||||
if parallel and int(os.environ.get(port_variable) or 0):
|
||||
raise pytest.UsageError(
|
||||
"parallel tests require an ephemeral static-server port; "
|
||||
"unset ODYSSEUS_TEST_STATIC_PORT or use -n 0"
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def isolated_runtime(worker="main"):
|
||||
"""Own mutable defaults for one pytest process, including subprocesses.
|
||||
|
||||
The random suffix separates simultaneous runs, even with the same worker
|
||||
name. Test-specific monkeypatches and function-scoped databases still own
|
||||
their resources; this is a fallback namespace, not a shared DB fixture.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory(prefix=f"ody-{worker}-") as directory:
|
||||
root = Path(directory)
|
||||
with pytest.MonkeyPatch.context() as patcher:
|
||||
paths = {
|
||||
"ODYSSEUS_DATA_DIR": root / "data",
|
||||
"ODYSSEUS_MAIL_ATTACHMENTS_DIR": root / "mail",
|
||||
"FASTEMBED_CACHE_PATH": root / "fastembed",
|
||||
"XDG_RUNTIME_DIR": root / "runtime",
|
||||
}
|
||||
for name, path in paths.items():
|
||||
path.mkdir(mode=0o700)
|
||||
patcher.setenv(name, str(path))
|
||||
(root / "data" / "agent_workspace").mkdir(mode=0o700, exist_ok=True)
|
||||
# Browser resolution falls back to our XDG runtime directory.
|
||||
patcher.delenv("AGENT_BROWSER_SOCKET_DIR", raising=False)
|
||||
tmp = root / "tmp"
|
||||
tmp.mkdir(mode=0o700)
|
||||
for name in ("TMPDIR", "TMP", "TEMP"):
|
||||
patcher.setenv(name, str(tmp))
|
||||
# tempfile may already have cached the caller's directory.
|
||||
patcher.setattr(tempfile, "tempdir", str(tmp))
|
||||
yield root
|
||||
@@ -0,0 +1,313 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
// Execute the production browser entry functions with local network/session
|
||||
// adapters. These are behavioral DOM tests, not assertions about source text.
|
||||
const renderer = await readFile(new URL('../static/js/chatRenderer.js', import.meta.url), 'utf8');
|
||||
const chat = await readFile(new URL('../static/js/chat.js', import.meta.url), 'utf8');
|
||||
const addMessage = renderer.slice(renderer.indexOf('export function addMessage('), renderer.indexOf('\nconst chatRenderer =')).replace('export ', '');
|
||||
const resume = chat.slice(chat.indexOf('export async function resumeStream('), chat.indexOf('\n /**\n * Check for background streams')).replace('export ', '');
|
||||
let browser;
|
||||
before(async () => { browser = await chromium.launch({ headless: true }); });
|
||||
after(async () => { await browser?.close(); });
|
||||
|
||||
async function setup() {
|
||||
const page = await browser.newPage();
|
||||
await page.route('http://render.test/**', async route => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<main id="chat-history"></main><button class="send-btn">Send</button>' });
|
||||
if (path === '/static/js/ui.js') return route.fulfill({ contentType: 'text/javascript', body: 'export default window.uiModule;' });
|
||||
if (!path.startsWith('/static/')) return route.abort();
|
||||
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
|
||||
return route.fulfill({ contentType: 'text/javascript', body: source });
|
||||
});
|
||||
await page.goto('http://render.test/');
|
||||
await page.evaluate(async ({ addMessage, resume }) => {
|
||||
const noop = () => {};
|
||||
const esc = value => { const node = document.createElement('div'); node.textContent = String(value ?? ''); return node.innerHTML; };
|
||||
Object.assign(window, {
|
||||
uiModule: { esc, scrollHistory: noop, showToast: value => window.toasts.push(value), showError: error => { throw Error(error); }, el: id => document.getElementById(id), captureHistoryScroll: () => ({ y: scrollY }), restoreHistoryScroll: s => scrollTo(0, s.y) },
|
||||
hideWelcomeScreen: noop, resolveDocumentPlaceholderLinks: x => x,
|
||||
replyModelPair: () => ({ actualModel: 'test', requestedModel: 'test' }),
|
||||
modelRouteLabel: () => 'test', sameModelName: () => true, applyModelColor: noop,
|
||||
roleTimestamp: () => document.createElement('span'),
|
||||
createMsgFooter: () => { const node = document.createElement('div'); node.className = 'msg-footer'; node.textContent = 'Copy'; return node; },
|
||||
displayMetrics: (node, metrics) => { node.dataset.metricsOwner = metrics.render_owner || ''; },
|
||||
_suppressRawToolOutput: () => false, safeToolScreenshotSrc: () => '', _isPrivateBrowserTool: () => false,
|
||||
_toolDisplayInfo: () => ({}), renderToolIcon: () => '',
|
||||
buildSourcesBox: () => '<div class="sources-section">Sources</div>',
|
||||
buildFindingsBox: () => '<div class="sources-section">Findings</div>',
|
||||
buildRagSourcesBox: () => '<details class="rag-sources"><summary>Documents</summary></details>',
|
||||
API_BASE: '', hasActiveStream: () => false,
|
||||
_streamRunIds: new Map(), _resumingStreams: new Set(), _backgroundStreams: new Map(),
|
||||
updateSubmitButton: noop, _shortModel: x => x, _applyModelColor: noop,
|
||||
_streamDisplayText: x => x, createTerminalStreamError: x => Error(x.message || 'Stream error'),
|
||||
_finishDocumentWritingStatus: noop,
|
||||
spinnerModule: { create: () => { const node = document.createElement('span'); return { createElement: () => node, start: noop, destroy: () => node.remove() }; } },
|
||||
reloads: 0, currentSession: 's', savedHistory: [], labels: [], toasts: [],
|
||||
_setRoleModelLabel: (role, requested, actual) => { window.labels.push({ requested, actual }); role.textContent = requested + ' -> ' + actual; },
|
||||
_metricsCostRecordId: () => 'test-run',
|
||||
sessionModule: { getCurrentSessionId: () => window.currentSession, getSessions: () => [{ id: 's', model: 'test' }], markStreaming: noop, clearStreaming: noop, selectSession: () => { window.reloads++; }, loadSessions: () => { window.reloads++; } },
|
||||
});
|
||||
window.markdownModule = (await import('/static/js/markdown.js')).default;
|
||||
markdownModule.renderMermaid = undefined;
|
||||
window.createTurnRendering = (await import('/static/js/turnRendering.js')).createTurnRendering;
|
||||
window.startsContinuationRound = (await import('/static/js/turnRendering.js')).startsContinuationRound;
|
||||
window.applyModelRouteEventState = (await import('/static/js/chatModelProvenance.js')).applyModelRouteEventState;
|
||||
window.createTerminalStreamError = (await import('/static/js/chatStreamErrors.js')).createTerminalStreamError;
|
||||
window.generatedImageResult = (await import('/static/js/generatedImageResult.js')).generatedImageResult;
|
||||
window.addMessage = (0, eval)('(' + addMessage + ')');
|
||||
window.resumeStream = (0, eval)('(' + resume + ')');
|
||||
window.chatRenderer = { addMessage: window.addMessage, recordSessionMetricsCost: noop, buildSourcesBox, buildFindingsBox, buildRagSourcesBox };
|
||||
let controller;
|
||||
const stream = new ReadableStream({ start(value) { controller = value; } });
|
||||
window.send = event => controller.enqueue(new TextEncoder().encode('data: ' + (typeof event === 'string' ? event : JSON.stringify(event)) + '\n\n'));
|
||||
window.sendError = event => controller.enqueue(new TextEncoder().encode('event: error\ndata: ' + JSON.stringify(event) + '\n\n'));
|
||||
window.fetch = async url => String(url).includes('/api/chat/resume/')
|
||||
? new Response(stream, { headers: { 'X-Odysseus-Run-Id': 'run-1' } })
|
||||
: new Response(JSON.stringify({ history: window.savedHistory }));
|
||||
}, { addMessage, resume });
|
||||
return page;
|
||||
}
|
||||
|
||||
test('history scoped ownership omits drafts for both owners, retains reasoning and tools, places answer last', async () => {
|
||||
const page = await setup();
|
||||
try {
|
||||
for (const render_owner of ['structured', 'streamed']) {
|
||||
const result = await page.evaluate(render_owner => {
|
||||
document.querySelector('#chat-history').replaceChildren();
|
||||
const metadata = { _fromHistory: true, _db_id: 42, render_owner, replacement_scope: 'turn', round_texts: ['<think>Lookup reasoning</think>Draft notes', 'Outdated answer'], tool_events: [{ round: 1, tool: 'manage_notes', output: 'Tool evidence', exit_code: 0 }] };
|
||||
const original = JSON.stringify(metadata);
|
||||
addMessage('assistant', '[Canonical note](#note-42)', 'test', metadata);
|
||||
const root = document.querySelector('#chat-history');
|
||||
return { text: root.textContent, link: root.querySelector('.body a')?.getAttribute('href'), unchanged: original === JSON.stringify(metadata), order: [...root.children].map(x => x.className), finalRaw: root.lastElementChild.dataset.raw };
|
||||
}, render_owner);
|
||||
assert.doesNotMatch(result.text, /Draft notes|Outdated answer/);
|
||||
assert.match(result.text, /Lookup reasoning[\s\S]*Tool evidence[\s\S]*Canonical note/);
|
||||
assert.equal(result.link, '#note-42');
|
||||
assert.equal(result.unchanged, true);
|
||||
assert.match(result.order.at(-1), /msg-ai/);
|
||||
assert.equal(result.finalRaw, '[Canonical note](#note-42)');
|
||||
}
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('legacy history keeps round prose without explicit replacement scope', async () => {
|
||||
const page = await setup();
|
||||
try {
|
||||
const text = await page.evaluate(() => {
|
||||
addMessage('assistant', 'Canonical', 'test', { _fromHistory: true, round_texts: ['Preamble', 'Old answer'], tool_events: [{ round: 1, tool: 'manage_notes' }] });
|
||||
return document.querySelector('#chat-history').textContent;
|
||||
});
|
||||
assert.match(text, /Preamble[\s\S]*Canonical/);
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
async function startReplay(page) {
|
||||
await page.evaluate(() => { window.running = resumeStream('s'); });
|
||||
await page.waitForSelector('.msg-ai');
|
||||
}
|
||||
|
||||
test('resume tool-only final is visible, scoped, and keeps the open timeline at completion', async () => {
|
||||
const page = await setup();
|
||||
try {
|
||||
await startReplay(page);
|
||||
await page.evaluate(() => {
|
||||
send({ type: 'tool_start', tool: 'manage_notes', command: '{}' });
|
||||
send({ type: 'tool_output', tool: 'manage_notes', output: 'Evidence', exit_code: 0 });
|
||||
});
|
||||
await page.waitForSelector('.agent-thread-node:not(.running)');
|
||||
await page.evaluate(() => {
|
||||
window.thread = document.querySelector('.agent-thread');
|
||||
thread.querySelector('.agent-thread-node').classList.add('open');
|
||||
send({ type: 'final_response', content: '[Canonical](#note-42)', render_owner: 'structured', replacement_scope: 'turn' });
|
||||
});
|
||||
await page.waitForSelector('.body a[href="#note-42"]', { state: 'visible' });
|
||||
const result = await page.evaluate(async () => {
|
||||
window.link = document.querySelector('.body a');
|
||||
window.savedHistory = [{ role: 'assistant', content: '[Canonical](#note-42)', metadata: { _db_id: 42, render_owner: 'structured', replacement_scope: 'turn', round_texts: ['Stale draft', 'Canonical'], tool_events: [{ tool: 'manage_notes' }] } }];
|
||||
send({ type: 'message_saved', id: 42, render_owner: 'structured' });
|
||||
send('[DONE]');
|
||||
await running;
|
||||
return { sameThread: thread === document.querySelector('.agent-thread'), sameLink: link === document.querySelector('.body a'), open: thread.querySelector('.agent-thread-node').classList.contains('open'), reloads, count: document.querySelectorAll('.body a').length, id: link.closest('.msg-ai').dataset.dbId, text: document.querySelector('#chat-history').innerText };
|
||||
});
|
||||
assert.deepEqual({ ...result, text: undefined }, { sameThread: true, sameLink: true, open: true, reloads: 0, count: 1, id: '42', text: undefined });
|
||||
assert.doesNotMatch(result.text, /Stale draft/);
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('resume preserves legitimate scoped synthesis and rejects unscoped conflicting prose', async () => {
|
||||
const page = await setup();
|
||||
try {
|
||||
await startReplay(page);
|
||||
const result = await page.evaluate(async () => {
|
||||
send({ delta: 'Earlier draft', render_owner: 'streamed' });
|
||||
send({ type: 'agent_step', round: 2 });
|
||||
send({ type: 'final_response', content: 'Intermediate notes', render_owner: 'structured', replacement_scope: 'turn' });
|
||||
send({ delta: 'Unscoped conflict', render_owner: 'streamed' });
|
||||
send({ delta: 'Reasoning', thinking: true, render_owner: 'structured' });
|
||||
send({ delta: 'Legitimate ', render_owner: 'streamed', replacement_scope: 'turn' });
|
||||
send({ delta: 'synthesis', render_owner: 'streamed' });
|
||||
send('[DONE]');
|
||||
await running;
|
||||
return { text: document.querySelector('#chat-history').innerText, reloads };
|
||||
});
|
||||
assert.match(result.text, /Legitimate synthesis/);
|
||||
assert.doesNotMatch(result.text, /Earlier draft|Intermediate notes|Unscoped conflict|Reasoning/);
|
||||
assert.equal(result.reloads, 0);
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('resume fallback and provider alias remain visible without a history reload', async () => {
|
||||
const page = await setup();
|
||||
try {
|
||||
await startReplay(page);
|
||||
const result = await page.evaluate(async () => {
|
||||
send({ type: 'fallback', selected_model: 'selected-model', answered_by: 'fallback-model', reason: '429' });
|
||||
send({ type: 'model_actual', model: 'provider/fallback-alias' });
|
||||
send({ delta: 'hello' });
|
||||
send('[DONE]');
|
||||
await running;
|
||||
return { labels, toasts, reloads, holders: document.querySelectorAll('.msg-ai').length, role: document.querySelector('.role').textContent, text: document.querySelector('.body').innerText };
|
||||
});
|
||||
assert.deepEqual(result.labels, [{ requested: 'selected-model', actual: 'fallback-model' }, { requested: 'selected-model', actual: 'provider/fallback-alias' }]);
|
||||
assert.deepEqual(result.toasts, ['Fallback: selected-model failed — answered by fallback-model']);
|
||||
assert.equal(result.reloads, 0);
|
||||
assert.equal(result.holders, 1);
|
||||
assert.equal(result.text, 'hello');
|
||||
assert.match(result.role, /provider\/fallback-alias/);
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('resume preoutput provider error stays visible as escaped text without reload', async () => {
|
||||
const page = await setup();
|
||||
try {
|
||||
await startReplay(page);
|
||||
const result = await page.evaluate(async () => {
|
||||
sendError({ status: 401, error: 'invalid key <img src=x>' });
|
||||
send('[DONE]');
|
||||
await running;
|
||||
return { text: document.querySelector('.body').innerText, images: document.querySelectorAll('img').length, reloads };
|
||||
});
|
||||
assert.deepEqual(result, { text: '[Error: invalid key <img src=x>]', images: 0, reloads: 0 });
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('resume terminal failure reconciles exact saved partial without reloading tool timeline', async () => {
|
||||
const page = await setup();
|
||||
try {
|
||||
await startReplay(page);
|
||||
const result = await page.evaluate(async () => {
|
||||
window.savedHistory = [
|
||||
{ role: 'assistant', content: 'Correct partial [Agent stopped]', metadata: { _db_id: 42, render_owner: 'structured', replacement_scope: 'turn' } },
|
||||
{ role: 'assistant', content: 'Wrong later answer', metadata: { _db_id: 43 } },
|
||||
];
|
||||
send({ delta: 'Partial' });
|
||||
send({ type: 'message_saved', id: 42 });
|
||||
send({ type: 'agent_terminal', data: { failure: { status: 429 } } });
|
||||
sendError({ status: 429, error: 'Rate limited' });
|
||||
send('[DONE]');
|
||||
await running;
|
||||
return { text: document.querySelector('.body').innerText, id: document.querySelector('.msg-ai').dataset.dbId, reloads };
|
||||
});
|
||||
assert.deepEqual(result, { text: 'Correct partial [Agent stopped]', id: '42', reloads: 0 });
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('resume stable and complete events clear all round markers while preserving streamed prose and tools', async () => {
|
||||
const page = await setup();
|
||||
try {
|
||||
await startReplay(page);
|
||||
await page.evaluate(() => {
|
||||
send({ delta: 'First streamed round' });
|
||||
send({ type: 'tool_start', tool: 'manage_notes' });
|
||||
send({ type: 'tool_output', tool: 'manage_notes', output: 'Evidence', exit_code: 0 });
|
||||
send({ type: 'agent_step', round: 2 });
|
||||
send({ delta: 'Final streamed round' });
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector('#chat-history').innerText.includes('Final streamed round'));
|
||||
await page.evaluate(() => {
|
||||
window.prose = [...document.querySelectorAll('.body p')];
|
||||
window.thread = document.querySelector('.agent-thread');
|
||||
send({ type: 'stable' });
|
||||
});
|
||||
await page.waitForFunction(() => !document.querySelector('#chat-history .streaming'));
|
||||
const result = await page.evaluate(async () => {
|
||||
thread.classList.add('streaming');
|
||||
send({ type: 'complete' });
|
||||
send('[DONE]');
|
||||
await running;
|
||||
return { sameProse: prose.every(p => p.isConnected), sameThread: thread === document.querySelector('.agent-thread'), markers: document.querySelectorAll('#chat-history .streaming').length, reloads };
|
||||
});
|
||||
assert.deepEqual(result, { sameProse: true, sameThread: true, markers: 0, reloads: 0 });
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('resume structured final and completion remove all transient round bubbles and streaming markers', async () => {
|
||||
const page = await setup();
|
||||
try {
|
||||
await startReplay(page);
|
||||
await page.evaluate(() => {
|
||||
send({ delta: 'Transient draft' });
|
||||
send({ type: 'agent_step', round: 2 });
|
||||
send({ delta: 'Another transient draft' });
|
||||
send({ type: 'final_response', content: '[Canonical](#note-42)', render_owner: 'structured', replacement_scope: 'turn' });
|
||||
});
|
||||
await page.waitForSelector('.body a[href="#note-42"]', { state: 'visible' });
|
||||
const result = await page.evaluate(async () => {
|
||||
const stableMarkers = document.querySelectorAll('#chat-history .streaming').length;
|
||||
const link = document.querySelector('.body a');
|
||||
send('[DONE]');
|
||||
await running;
|
||||
return { stableMarkers, markers: document.querySelectorAll('#chat-history .streaming').length, bodies: document.querySelectorAll('.msg-ai .body').length, same: link === document.querySelector('.body a'), text: document.querySelector('#chat-history').innerText };
|
||||
});
|
||||
assert.equal(result.stableMarkers, 0);
|
||||
assert.equal(result.markers, 0);
|
||||
assert.equal(result.bodies, 1);
|
||||
assert.equal(result.same, true);
|
||||
assert.doesNotMatch(result.text, /Transient|transient/);
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('resume error clears earlier and current streaming markers without dropping partial prose', async () => {
|
||||
const page = await setup();
|
||||
try {
|
||||
await startReplay(page);
|
||||
const result = await page.evaluate(async () => {
|
||||
send({ delta: 'First partial' });
|
||||
send({ type: 'agent_step', round: 2 });
|
||||
send({ delta: 'Second partial' });
|
||||
sendError({ status: 500, error: 'Provider failure' });
|
||||
send('[DONE]');
|
||||
await running;
|
||||
return { markers: document.querySelectorAll('#chat-history .streaming').length, text: document.querySelector('#chat-history').innerText, reloads };
|
||||
});
|
||||
assert.equal(result.markers, 0);
|
||||
assert.equal(result.reloads, 0);
|
||||
assert.match(result.text, /First partial[\s\S]*Second partial[\s\S]*Provider failure/);
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('resume keeps the same initial bubble through preparation and round-one status', async () => {
|
||||
const page = await setup();
|
||||
try {
|
||||
await startReplay(page);
|
||||
await page.evaluate(() => {
|
||||
window.initialBubble = document.querySelector('.msg-ai');
|
||||
send({type: 'agent_step', stage: 'email_task_scope'});
|
||||
send({type: 'agent_step', round: 1});
|
||||
send({delta: 'Hello'});
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector('#chat-history').textContent.includes('Hello'));
|
||||
const result = await page.evaluate(async () => {
|
||||
const same = initialBubble === document.querySelector('.msg-ai');
|
||||
const count = document.querySelectorAll('.msg-ai').length;
|
||||
send('[DONE]');
|
||||
await running;
|
||||
return {same, count};
|
||||
});
|
||||
assert.deepEqual(result, {same: true, count: 1});
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Authored ranking inputs; factual identifiers from regression tests are selectors.
|
||||
|
||||
All sizes, dates and capabilities are synthetic, not statements about Hub models.
|
||||
No copied production-catalog rows or descriptions are included.
|
||||
|
||||
The fixture is opt-in: a module imports ``publication_catalog`` and requests it
|
||||
explicitly (``pytestmark = pytest.mark.usefixtures("publication_catalog")`` or
|
||||
a test argument). It yields the authored input rows so tests can derive their
|
||||
expectations from the input instead of restating it.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FIXTURE_PATH = Path(__file__).parent / "fixtures/hwfit_publication_models.json"
|
||||
|
||||
|
||||
def authored_rows():
|
||||
return json.loads(FIXTURE_PATH.read_text())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def publication_catalog(monkeypatch):
|
||||
from services.hwfit import models, hf_discovery
|
||||
|
||||
rows = authored_rows()
|
||||
# Exercise the real merge/normalization path, independent of user caches.
|
||||
monkeypatch.setattr(models, "_models_cache", None)
|
||||
monkeypatch.setattr(models, "_load_model_file", lambda path: rows if str(path) == models.model_catalog_path() else [])
|
||||
monkeypatch.setattr(hf_discovery, "load_cached_hf_collection_models", lambda: [])
|
||||
monkeypatch.setattr(hf_discovery, "load_cached_mlx_community_models", lambda: [])
|
||||
yield authored_rows()
|
||||
@@ -1,56 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadMarkdown } from './streaming/markdownHarness.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const markdownPath = path.join(__dirname, '..', 'static', 'js', 'markdown.js');
|
||||
let src = fs.readFileSync(markdownPath, 'utf8');
|
||||
|
||||
src = src.replace(
|
||||
/import uiModule from '\.\/ui\.js';/,
|
||||
'const uiModule = { esc: (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\\"/g, """) };'
|
||||
);
|
||||
src = src.replace(
|
||||
/import \{ splitTableRow \} from '\.\/markdown\/tableRow\.js';/,
|
||||
'const splitTableRow = (row) => row.split("|").filter((cell) => cell.trim() !== "");'
|
||||
);
|
||||
src = src.replace(
|
||||
/import \{ replaceEmojiShortcodes, hasEmojiShortcode \} from '\.\/emojiShortcodes\.js';/,
|
||||
'const hasEmojiShortcode = (t) => !!t && t.indexOf(":") !== -1 && /:[a-z0-9_+-]{1,40}:/i.test(t); const replaceEmojiShortcodes = (t) => t;'
|
||||
);
|
||||
src = src.replace(/export function /g, 'function ');
|
||||
src = src.replace(/export const /g, 'const ');
|
||||
src = src.replace(/export default markdownModule;?/g, '');
|
||||
src += '\nthis.__mdToHtml = mdToHtml;';
|
||||
|
||||
class MutationObserver {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
URL,
|
||||
MutationObserver,
|
||||
localStorage: { getItem() { return '[]'; }, setItem() {} },
|
||||
document: {
|
||||
body: { classList: { contains() { return true; } } },
|
||||
addEventListener() {},
|
||||
querySelectorAll() { return []; },
|
||||
getElementById() { return null; },
|
||||
contains() { return true; },
|
||||
},
|
||||
window: {
|
||||
location: { origin: 'http://localhost' },
|
||||
katex: null,
|
||||
mermaid: null,
|
||||
},
|
||||
};
|
||||
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox, { filename: markdownPath });
|
||||
// Use the same ES-module loader as the streaming renderer tests. It keeps the
|
||||
// production exports intact and handles the browser's versioned sibling imports.
|
||||
const { mdToHtml } = await loadMarkdown();
|
||||
|
||||
const input = [
|
||||
'> ```html',
|
||||
@@ -62,7 +15,7 @@ const input = [
|
||||
'> ```',
|
||||
].join('\n');
|
||||
|
||||
const html = sandbox.__mdToHtml(input);
|
||||
const html = mdToHtml(input);
|
||||
assert.equal(html.includes('___ALLOWED_HTML_'), false, html);
|
||||
assert.equal(html.includes('appendChild'), true, html);
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {expectedNoteTitles,compareNoteState} from '../scripts/note_test_oracle.mjs';
|
||||
import {scoreCalls} from '../scripts/compare_schema_thinking.mjs';
|
||||
const rows=[{id:'a123',title:'Japan',content:'body',pinned:false},{id:'b123',title:'Today',content:'keep'}];
|
||||
const call=args=>({function:{name:'manage_notes',arguments:JSON.stringify(args)}});
|
||||
test('shared expectations cover negation, exceptions and invalid cases',()=>{
|
||||
assert.deepEqual(expectedNoteTitles('keep_all',['Japan','Today']),[]);
|
||||
assert.deepEqual(expectedNoteTitles('contrast',['Groceries','Japan','Today']),['Groceries']);
|
||||
assert.deepEqual(expectedNoteTitles('except_one',['Groceries','Japan','Today']),['Groceries','Today']);
|
||||
assert.throws(()=>expectedNoteTitles('unregistered',[]));
|
||||
});
|
||||
test('state oracle detects edits and additions, not just disappearing IDs',()=>{
|
||||
assert.equal(compareNoteState(rows,rows).unchanged,true);
|
||||
assert.equal(compareNoteState(rows,[rows[1]],['a123']).exact,true);
|
||||
assert.equal(compareNoteState(rows,[{...rows[1],content:'changed'}],['a123']).exact,false);
|
||||
assert.deepEqual(compareNoteState(rows,[{...rows[0],pinned:true},rows[1]]).changed_fields,['pinned']);
|
||||
assert.equal(compareNoteState(rows,[...rows,{id:'new',title:'extra'}]).added_count,1);
|
||||
assert.equal(compareNoteState(rows,[{...rows[0],archived:true},rows[1]],['a123']).exact,false);
|
||||
});
|
||||
test('duplicate deletion cannot pass exact proposal check',()=>{
|
||||
const result=scoreCalls([call({action:'delete',id:'a123'}),call({action:'delete',title:'Japan'})],rows,['Japan']);
|
||||
assert.equal(result.exact_target_proposal,false);
|
||||
assert.equal(result.duplicate_targets,1);
|
||||
});
|
||||
test('stale ID with valid unique title follows actual backend fallback',()=>{
|
||||
assert.equal(scoreCalls([call({action:'delete',id:'stale',title:'Japan'})],rows,['Japan']).exact_target_proposal,true);
|
||||
assert.equal(scoreCalls([{function:{name:'manage_notes',arguments:'null'}}],rows,[]).exact_target_proposal,false);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createNotesWorkflows } from '../frontend/src/features/notes/application/workflows.js';
|
||||
import { createLayout } from '../frontend/src/features/notes/ui/layout.js';
|
||||
|
||||
test('views cannot mutate Notes state, but captured identities observe application updates', () => {
|
||||
const app = createNotesState();
|
||||
app.replaceNotes([{ id: 'a', items: [{ text: 'Task', done: false }] }]);
|
||||
const note = app.state.notes[0];
|
||||
assert.throws(() => { note.items[0].done = true; }, /state operations/);
|
||||
assert.throws(() => app.state.notes.push({ id: 'other' }), /state operations/);
|
||||
assert.throws(() => app.state.selectedIds.add('a'), /selection operations/);
|
||||
assert.throws(() => Object.defineProperty(note, 'title', { value: 'unowned' }), /state operations/);
|
||||
assert.throws(() => Object.preventExtensions(note), /state operations/);
|
||||
assert.throws(() => Object.getOwnPropertyDescriptor(note, 'items').value.push({}), /state operations/);
|
||||
app.updateItem(note, 0, { done: true });
|
||||
assert.equal(note.items[0].done, true);
|
||||
assert.equal(app.selectId('a'), undefined, 'selection operations do not leak the mutable set');
|
||||
assert.deepEqual([...app.state.selectedIds], ['a']);
|
||||
assert.throws(() => app.state.selectedIds.valueOf().clear(), /selection operations/);
|
||||
app.state.selectedIds.forEach((_value, _key, set) => {
|
||||
assert.equal(set, app.state.selectedIds);
|
||||
assert.throws(() => set.clear(), /selection operations/);
|
||||
});
|
||||
app.mergeNote(note, { title: 'Changed' });
|
||||
assert.equal(note.title, 'Changed');
|
||||
});
|
||||
|
||||
test('fetch preserves archived query, failures clear notes, and successful fallback shapes', async () => {
|
||||
const app = createNotesState(), requests = [];
|
||||
let response = { ok: true, json: async () => [{ id: 'a' }] };
|
||||
const workflows = createNotesWorkflows({ app, api: { listNotes: async archived => { requests.push(archived); return response; } }, effects: {} });
|
||||
app.setShowingArchived(true);
|
||||
await workflows._fetchNotes();
|
||||
assert.deepEqual(requests, [true]);
|
||||
assert.equal(app.state.loading, false);
|
||||
assert.equal(app.state.notes[0].id, 'a');
|
||||
response = { ok: false };
|
||||
await workflows._fetchNotes();
|
||||
assert.equal(app.state.notes.length, 0);
|
||||
response = { ok: true, json: async () => ({ notes: [{ id: 'b' }] }) };
|
||||
await workflows._fetchNotes();
|
||||
assert.equal(app.state.notes[0].id, 'b');
|
||||
});
|
||||
|
||||
test('fetch completions retain the original last-completion ordering', async () => {
|
||||
const app = createNotesState(), releases = [];
|
||||
const workflows = createNotesWorkflows({ app, api: { listNotes: () => new Promise(resolve => releases.push(resolve)) }, effects: {} });
|
||||
const first = workflows._fetchNotes(), second = workflows._fetchNotes();
|
||||
releases[1]({ ok: true, json: async () => ({ notes: [{ id: 'second' }] }) });
|
||||
await second;
|
||||
assert.equal(app.state.notes[0].id, 'second');
|
||||
releases[0]({ ok: true, json: async () => ({ notes: [{ id: 'first' }] }) });
|
||||
await first;
|
||||
assert.equal(app.state.notes[0].id, 'first');
|
||||
});
|
||||
|
||||
test('writes built from views store raw data, not read-only views', () => {
|
||||
const app = createNotesState();
|
||||
const raw = [{ id: 'a', items: [{ id: 'x', done: false }, { id: 'y', done: false }] }];
|
||||
app.replaceNotes(raw);
|
||||
const note = app.state.notes[0];
|
||||
app.mergeNote(note, { items: note.items.filter(item => item.id !== 'x') });
|
||||
app.prependNote({ ...note, id: 'b' });
|
||||
app.spliceItems(app.state.notes[1], 1, 0, note.items[0]);
|
||||
assert.doesNotThrow(() => structuredClone(raw));
|
||||
raw[1].items[0].done = true;
|
||||
assert.equal(app.state.notes[1].items[0].done, true);
|
||||
assert.throws(() => { app.state.notes[1].items[0].done = false; }, /state operations/);
|
||||
});
|
||||
|
||||
test('masonry observer is not re-created once Notes is closed', () => {
|
||||
const created = [];
|
||||
const previous = { window: globalThis.window, ResizeObserver: globalThis.ResizeObserver };
|
||||
class FakeObserver { constructor() { created.push(this); this.live = true; } observe() {} disconnect() { this.live = false; } }
|
||||
globalThis.ResizeObserver = FakeObserver;
|
||||
globalThis.window = { ResizeObserver: FakeObserver, matchMedia: () => ({ matches: false }) };
|
||||
try {
|
||||
let open = true;
|
||||
const layout = createLayout({ isOpen: () => open });
|
||||
const body = { isConnected: true, closest: () => ({ classList: { contains: name => name === 'notes-view-grid' } }), querySelector: () => null, querySelectorAll: () => [] };
|
||||
layout._applyMasonry(body);
|
||||
assert.equal(created.filter(o => o.live).length, 1);
|
||||
layout.dispose();
|
||||
open = false;
|
||||
layout._applyMasonry(body);
|
||||
assert.equal(created.filter(o => o.live).length, 0);
|
||||
} finally {
|
||||
globalThis.window = previous.window;
|
||||
globalThis.ResizeObserver = previous.ResizeObserver;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { parse } from 'acorn';
|
||||
import { createNotesApi } from '../frontend/src/features/notes/infrastructure/http.js';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createNotesWorkflows } from '../frontend/src/features/notes/application/workflows.js';
|
||||
|
||||
const source = fs.readFileSync(new URL('../static/js/notes.js', import.meta.url), 'utf8');
|
||||
const ast = parse(source, { ecmaVersion: 'latest', sourceType: 'module' });
|
||||
const implementation = fs.readFileSync(new URL('../frontend/src/features/notes/index.js', import.meta.url), 'utf8');
|
||||
const implementationAST = parse(implementation, { ecmaVersion: 'latest', sourceType: 'module' });
|
||||
function functions(names, ports = {}) {
|
||||
const datesSource = fs.readFileSync(new URL('../frontend/src/features/notes/domain/recurrence.js', import.meta.url), 'utf8').replaceAll('export ', '');
|
||||
const context = vm.createContext(ports);
|
||||
const datesContext = vm.createContext({ ...ports, clock: { now: () => (ports.Date || Date).now() } });
|
||||
vm.runInContext(datesSource, datesContext);
|
||||
context.dates = datesContext;
|
||||
context.clock = { now: () => (ports.Date || Date).now() };
|
||||
context.api = createNotesApi({ origin: ports.API_BASE, request: ports.fetch });
|
||||
context._saveNote = context.api.saveNote;
|
||||
context._patchNote = context.api.patchNote;
|
||||
context._deleteNoteApi = context.api.deleteNote;
|
||||
context.workflows = createNotesWorkflows({ app: createNotesState(), api: context.api, effects: {} });
|
||||
for (const statement of implementationAST.body) {
|
||||
const node = statement.declaration || statement;
|
||||
if (node.type === 'FunctionDeclaration' && names.includes(node.id.name)) {
|
||||
vm.runInContext(implementation.slice(node.start, node.end), context);
|
||||
}
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
test('legacy public namespace and default API are intentional', () => {
|
||||
const names = [];
|
||||
for (const node of ast.body) {
|
||||
if (node.type !== 'ExportNamedDeclaration') continue;
|
||||
if (node.declaration) names.push(node.declaration.id.name);
|
||||
for (const spec of node.specifiers) if (spec.exported.name !== 'default') names.push(spec.exported.name);
|
||||
}
|
||||
assert.deepEqual(names.sort(), ['closeNotes', 'closePanel', 'dismissFiredReminderDot', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']);
|
||||
assert.match(source, /window.notesModule = notesModule/);
|
||||
});
|
||||
|
||||
test('CRUD preserves same-origin credentials, payloads and error contracts', async () => {
|
||||
const requests = [];
|
||||
let ok = true;
|
||||
const api = functions(['_saveNote', '_patchNote', '_deleteNoteApi'], {
|
||||
API_BASE: 'https://notes.invalid',
|
||||
fetch: async (url, options) => {
|
||||
requests.push({ url, ...options });
|
||||
return { ok, status: 403, json: async () => ({ id: 'saved' }) };
|
||||
},
|
||||
});
|
||||
await api._saveNote({ title: 'New' });
|
||||
await api._saveNote({ id: 'a', title: '' });
|
||||
await api._patchNote('a', { due_date: null });
|
||||
await api._deleteNoteApi('a');
|
||||
assert.deepEqual(requests.map(r => [r.url, r.method, r.credentials]), [
|
||||
['https://notes.invalid/api/notes', 'POST', 'same-origin'],
|
||||
['https://notes.invalid/api/notes/a', 'PUT', 'same-origin'],
|
||||
['https://notes.invalid/api/notes/a', 'PUT', 'same-origin'],
|
||||
['https://notes.invalid/api/notes/a', 'DELETE', 'same-origin'],
|
||||
]);
|
||||
assert.equal(requests[2].body, '{"due_date":null}');
|
||||
ok = false;
|
||||
await assert.rejects(api._saveNote({}), /Failed to save note/);
|
||||
await assert.rejects(api._patchNote('a', {}), /Failed to update note/);
|
||||
await assert.rejects(api._deleteNoteApi('a'), /HTTP 403/);
|
||||
});
|
||||
|
||||
test('recurrence clock is read after first advancement and never for none', () => {
|
||||
const calls = [];
|
||||
class ClockDate extends Date {
|
||||
static now() { calls.push('clock'); return Date.parse('2024-02-01T12:00'); }
|
||||
setDate(value) { calls.push('step'); return super.setDate(value); }
|
||||
}
|
||||
const domain = functions(['_normalizeRepeat', '_advanceRecurring', '_toLocalDatetimeStr', '_nthWeekdayOfMonth', '_lastWeekdayOfMonth'], { Date: ClockDate });
|
||||
assert.equal(domain.dates._advanceRecurring('2024-02-01T09:00', 'daily', domain.clock), '2024-02-02T09:00');
|
||||
assert.deepEqual(calls, ['step', 'clock']);
|
||||
calls.length = 0;
|
||||
assert.equal(domain.dates._advanceRecurring('2024-02-01T09:00', 'none', domain.clock), null);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('archive failure restores position; undo failure rolls back reinsertion', async () => {
|
||||
const notes = [{ id: 'a' }, { id: 'b' }, { id: 'c' }], errors = [];
|
||||
const stateOwner = createNotesState();
|
||||
stateOwner.replaceNotes(notes);
|
||||
const app = createNotesWorkflows({
|
||||
app: stateOwner,
|
||||
api: { patchNote: async () => { throw new Error('refused'); } },
|
||||
effects: { render: () => {}, showError: e => errors.push(e) },
|
||||
});
|
||||
assert.equal(app._archiveNoteById('b'), true);
|
||||
assert.deepEqual(notes.map(n => n.id), ['a', 'c']);
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(notes.map(n => n.id), ['a', 'b', 'c']);
|
||||
notes.splice(1, 1);
|
||||
app._popAndRunUndo();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(notes.map(n => n.id), ['a', 'c']);
|
||||
assert.deepEqual(errors, ['Failed to archive', 'Undo failed']);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNotesPersistence } from '../frontend/src/features/notes/infrastructure/storage.js';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createNotesDrafts } from '../frontend/src/features/notes/application/drafts.js';
|
||||
import { createDraftEditor } from '../frontend/src/features/notes/ui/draftEditor.js';
|
||||
|
||||
function harness(patch = async () => ({})) {
|
||||
const data = new Map(), timers = new Map(), errors = [];
|
||||
let nextTimer = 0;
|
||||
const context = {
|
||||
localStorage: { getItem: k => data.get(k) ?? null, setItem: (k,v) => data.set(k,v), removeItem: k => data.delete(k) },
|
||||
setTimeout: f => { timers.set(++nextTimer, f); return nextTimer; },
|
||||
clearTimeout: id => timers.delete(id),
|
||||
_patchNote: patch, _notes: [{id:'note1'}],
|
||||
_collectItems: form => form.items,
|
||||
uiModule: {showError: message => errors.push(message)},
|
||||
};
|
||||
context.persistence = createNotesPersistence({ storage: context.localStorage });
|
||||
context.app = createNotesState();
|
||||
context.app.replaceNotes(context._notes);
|
||||
context.state = context.app.state;
|
||||
Object.assign(context, context.persistence);
|
||||
const drafts = createNotesDrafts({ app: context.app, api: { patchNote: patch }, persistence: context.persistence, scheduler: context, effects: context.uiModule });
|
||||
Object.assign(context, createDraftEditor({ ...context, ...context.persistence, bindDraft: drafts.bindDraft }));
|
||||
const fields = {'.note-form-title':{value:'Title'}, '.note-form-content':{value:'Original'}};
|
||||
const handlers = {};
|
||||
const form = { dataset:{noteType:'note'}, items:[], querySelector: s => fields[s], addEventListener:(n,f) => handlers[n]=f };
|
||||
context._wireDraftAutosave(form, 'note1');
|
||||
return {context, data, form, fields, errors,
|
||||
input: () => handlers.input(),
|
||||
drain: async () => { for (const f of timers.values()) f(); timers.clear(); await new Promise(setImmediate); },
|
||||
draft: () => JSON.parse(data.get('odysseus-note-draft-note1') || 'null'),
|
||||
};
|
||||
}
|
||||
|
||||
test('typing is backed up synchronously, even before the autosave timer', () => {
|
||||
const h = harness();
|
||||
h.fields['.note-form-content'].value = 'Just typed'; h.input();
|
||||
assert.equal(h.draft().content, 'Just typed');
|
||||
});
|
||||
|
||||
test('checklist type without an active pill preserves items', () => {
|
||||
const h = harness(); h.form.dataset.noteType='checklist';
|
||||
h.form.items=[{text:'Drop keys',done:false}]; h.input();
|
||||
assert.equal(h.draft().note_type,'checklist');
|
||||
assert.equal(h.draft().items[0].text,'Drop keys');
|
||||
});
|
||||
|
||||
test('failed network save retains recoverable edits including empty text', async () => {
|
||||
const h = harness(async () => { throw new Error('offline'); });
|
||||
h.fields['.note-form-title'].value=''; h.fields['.note-form-content'].value=''; h.input();
|
||||
await h.drain();
|
||||
assert.equal(h.draft().content,'');
|
||||
assert.equal(h.context._applyDraftToNote({content:'Old'},'note1').note.content,'');
|
||||
assert.equal(h.errors.length,1);
|
||||
});
|
||||
|
||||
test('older save cannot clear newer draft and writes are serialized', async () => {
|
||||
const releases=[], writes=[];
|
||||
const h=harness((id,payload) => { writes.push(payload.content); return new Promise(r=>releases.push(r)); });
|
||||
h.fields['.note-form-content'].value='First'; h.input(); await h.drain();
|
||||
h.fields['.note-form-content'].value='Second'; h.input(); await h.drain();
|
||||
assert.deepEqual(writes,['First']);
|
||||
releases.shift()({}); await new Promise(setImmediate);
|
||||
assert.equal(h.draft().content,'Second');
|
||||
assert.deepEqual(writes,['First','Second']);
|
||||
releases.shift()({}); await new Promise(setImmediate);
|
||||
assert.equal(h.draft(),null);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNotesApi } from '../frontend/src/features/notes/infrastructure/http.js';
|
||||
import { createNotesPersistence, REMINDER_FIRED_KEY } from '../frontend/src/features/notes/infrastructure/storage.js';
|
||||
|
||||
test('list, reorder, upload, reminder and agent transports retain exact requests', async () => {
|
||||
const calls = [], result = { ok: false, status: 403 };
|
||||
const api = createNotesApi({ origin: 'https://notes.invalid', request: async (...args) => { calls.push(args); return result; } });
|
||||
const body = { opaque: true };
|
||||
assert.equal(await api.listNotes(true), result);
|
||||
await api.listNotes(false); await api.reorder(['b', 'a']); await api.upload(body);
|
||||
await api.fireReminder({ note_id: 'a' }); await api.defaultChat();
|
||||
await api.createSession(body); await api.startChat(body);
|
||||
assert.deepEqual(calls.map(([url, o]) => [url, o.method || 'GET', o.credentials]), [
|
||||
['https://notes.invalid/api/notes?archived=true', 'GET', 'same-origin'],
|
||||
['https://notes.invalid/api/notes', 'GET', 'same-origin'],
|
||||
['https://notes.invalid/api/notes/reorder', 'POST', 'same-origin'],
|
||||
['https://notes.invalid/api/upload', 'POST', 'same-origin'],
|
||||
['/api/notes/fire-reminder', 'POST', 'same-origin'],
|
||||
['https://notes.invalid/api/default-chat', 'GET', 'same-origin'],
|
||||
['https://notes.invalid/api/session', 'POST', 'same-origin'],
|
||||
['https://notes.invalid/api/chat_stream', 'POST', 'same-origin'],
|
||||
]);
|
||||
assert.equal(calls[2][1].body, '{"ids":["b","a"]}');
|
||||
assert.equal(calls[3][1].body, body);
|
||||
assert.equal(api.uploadUrl('file'), 'https://notes.invalid/api/upload/file');
|
||||
});
|
||||
|
||||
test('draft and marker persistence retain keys, formats and malformed fallback', () => {
|
||||
const values = new Map();
|
||||
const storage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) };
|
||||
const persistence = createNotesPersistence({ storage });
|
||||
assert.equal(persistence._draftKey(null), 'odysseus-note-draft-__new__');
|
||||
persistence._saveFiredReminders(new Set(['a', 'b']));
|
||||
assert.equal(values.get(REMINDER_FIRED_KEY), '["a","b"]');
|
||||
assert.deepEqual([...persistence._loadFiredReminders()], ['a', 'b']);
|
||||
values.set(REMINDER_FIRED_KEY, '{broken');
|
||||
assert.equal(persistence._loadFiredReminders().size, 0);
|
||||
storage.setItem(persistence._draftKey('a'), '{"content":"","_ts":1}');
|
||||
assert.deepEqual(persistence._loadDraft('a'), { content: '', _ts: 1 });
|
||||
persistence._clearDraft('a');
|
||||
assert.equal(persistence._loadDraft('a'), null);
|
||||
const failing = createNotesPersistence({ storage: { getItem() { throw Error('blocked'); }, setItem() { throw Error('full'); } } });
|
||||
assert.equal(failing._loadDraft('a'), null);
|
||||
assert.doesNotThrow(() => failing._saveFiredReminders(new Set(['a'])));
|
||||
assert.throws(() => failing.setItem('odysseus-note-draft-a', '{}'), /full/, 'draft caller must observe backup failure');
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import * as rules from '../frontend/src/features/notes/domain/recurrence.js';
|
||||
|
||||
const baseline = fs.readFileSync(new URL('./fixtures/notesRecurrenceBaseline.mjs', import.meta.url), 'utf8');
|
||||
const timed = new Set(['_formatDueDate', '_isDueOverdue', '_isDueTodayOrOverdue', '_laterTodayDate', '_tomorrowDate', '_nextWeekDate', '_formatReminderTag', '_snapToRepeat', '_advanceRecurring']);
|
||||
for (const zone of ['UTC', 'Europe/Lisbon', 'America/New_York', 'Australia/Sydney']) {
|
||||
test(`recurrence differential oracle in ${zone}`, () => {
|
||||
const oldZone = process.env.TZ;
|
||||
process.env.TZ = zone;
|
||||
try {
|
||||
const now = Date.parse('2024-03-10T01:59:00');
|
||||
class FixedDate extends Date { constructor(...args) { super(...(args.length ? args : [now])); } static now() { return now; } }
|
||||
const original = vm.createContext({ Date: FixedDate });
|
||||
vm.runInContext(baseline, original);
|
||||
const compare = (name, args) => {
|
||||
const before = original[name](...args);
|
||||
const after = rules[name](...args, ...(timed.has(name) ? [{ now: () => now }] : []));
|
||||
assert.equal(after instanceof Date ? after.getTime() : after, before instanceof Date ? before.getTime() : before, `${name}(${args})`);
|
||||
};
|
||||
const dates = ['2024-01-31T09:17', '2024-02-29T09:17', '2023-02-28T09:17', '2024-03-09T02:30', '2024-11-02T01:30', '2030-07-31T18:42', '1900-01-01T09:00', 'invalid', '2024-03-10'];
|
||||
const repeats = [null, '', 'none', 'daily', 'weekly', 'monthly', 'yearly', 'monthly_nth_weekday', 'monthly_last_weekday', 'weekly:1', 'weekly:bad', 'monthly:day:31', 'monthly:nth:5:1', 'monthly:last:0', 'monthly:bad', 'unknown'];
|
||||
for (const date of dates) {
|
||||
for (const repeat of repeats) {
|
||||
compare('_advanceRecurring', [date, repeat]);
|
||||
compare('_normalizeRepeat', [repeat, new Date(date)]);
|
||||
compare('_formatRepeatLabel', [repeat, new Date(date)]);
|
||||
}
|
||||
for (const name of ['_hasTimeComponent', '_formatDueDate', '_isDueOverdue', '_isDueTodayOrOverdue', '_formatReminderTag']) compare(name, [date]);
|
||||
for (const repeat of ['weekly:1', 'monthly:day:31', 'monthly:nth:5:1', 'monthly:last:0', 'daily', 'monthly:bad']) compare('_snapToRepeat', [new Date(date), repeat]);
|
||||
}
|
||||
for (const name of ['_laterTodayDate', '_tomorrowDate', '_nextWeekDate']) compare(name, []);
|
||||
for (const year of [2023, 2024]) for (let month = 0; month < 12; month++) for (let wd = 0; wd < 7; wd++) {
|
||||
compare('_nthWeekdayOfMonth', [year, month, wd, 5]);
|
||||
compare('_lastWeekdayOfMonth', [year, month, wd]);
|
||||
}
|
||||
} finally { if (oldZone === undefined) delete process.env.TZ; else process.env.TZ = oldZone; }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createNotesReminders } from '../frontend/src/features/notes/application/reminders.js';
|
||||
import { createNotesNotifications } from '../frontend/src/features/notes/infrastructure/notifications.js';
|
||||
import { createNotesPersistence, REMINDER_DISMISSED_AT_KEY } from '../frontend/src/features/notes/infrastructure/storage.js';
|
||||
|
||||
function harness(notes = []) {
|
||||
const now = Date.parse('2024-03-10T09:00'), app = createNotesState();
|
||||
app.replaceNotes(notes);
|
||||
const values = new Map(), timeouts = new Map(), intervals = new Map(), fired = [], shown = [], highlighted = [], patches = [];
|
||||
let nextId = 0, response = Promise.resolve({ ok: true, json: async () => ({ synthesis: 'Synthesis' }) });
|
||||
const scheduler = {
|
||||
setTimeout: (fn, ms) => { const id = ++nextId; timeouts.set(id, { fn, ms }); return id; }, clearTimeout: id => timeouts.delete(id),
|
||||
setInterval: (fn, ms) => { const id = ++nextId; intervals.set(id, { fn, ms }); return id; }, clearInterval: id => intervals.delete(id),
|
||||
};
|
||||
const persistence = createNotesPersistence({ storage: { getItem: key => values.get(key), setItem: (key, value) => values.set(key, value) } });
|
||||
const reminders = createNotesReminders({
|
||||
app, clock: { now: () => now }, scheduler, persistence, dismissedKey: REMINDER_DISMISSED_AT_KEY,
|
||||
notifications: { show: (...args) => shown.push(args) }, fetchNotes: async () => {},
|
||||
hasItems: note => ['todo', 'goal', 'checklist'].includes(note.note_type), isNoteFullyDone: () => false,
|
||||
api: { listNotes: async () => ({ ok: true, json: async () => ({ notes }) }), patchNote: async (...args) => patches.push(args), fireReminder: body => { fired.push(body); return response; } },
|
||||
effects: { badge: () => {}, highlight: id => highlighted.push(id) },
|
||||
});
|
||||
return { now, app, reminders, timeouts, intervals, fired, shown, highlighted, patches, persistence, setResponse: value => { response = value; } };
|
||||
}
|
||||
|
||||
test('startup and loop are idempotent, and panel closure leaves background scheduling alive', async () => {
|
||||
const h = harness();
|
||||
h.reminders.start(); h.reminders.start();
|
||||
assert.deepEqual([...h.timeouts.values()].map(t => t.ms), [3000]);
|
||||
await [...h.timeouts.values()][0].fn();
|
||||
h.reminders._startReminderLoop();
|
||||
assert.deepEqual([...h.intervals.values()].map(t => t.ms), [30000]);
|
||||
h.app.setOpen(false);
|
||||
assert.equal(h.intervals.size, 1);
|
||||
h.reminders.dispose();
|
||||
assert.equal(h.intervals.size, 0);
|
||||
h.reminders._startReminderLoop();
|
||||
assert.equal(h.intervals.size, 0);
|
||||
});
|
||||
|
||||
test('fresh reminders dispatch once, stale reminders mark silently, recurring reminders advance', async () => {
|
||||
const now = Date.parse('2024-03-10T09:00');
|
||||
const local = value => { const d = new Date(value); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:30`; };
|
||||
const h = harness([
|
||||
{ id: 'fresh', due_date: local(now - 30000), content: 'Body' },
|
||||
{ id: 'old', due_date: local(now - 180000) },
|
||||
{ id: 'recurring', due_date: local(now - 180000), repeat: 'daily' },
|
||||
{ id: 'date', due_date: '2024-03-10' },
|
||||
{ id: 'archived', archived: true, due_date: local(now - 30000) },
|
||||
]);
|
||||
h.reminders._checkReminders(); h.reminders._checkReminders();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(h.fired.map(n => n.note_id), ['fresh']);
|
||||
assert.deepEqual([...h.persistence._loadFiredReminders()], ['fresh', 'old']);
|
||||
assert.equal(h.patches.length, 1);
|
||||
assert.equal(h.app.state.notes[2].due_date, '2024-03-11T08:57');
|
||||
assert.deepEqual(h.highlighted, ['fresh']);
|
||||
assert.equal(h.shown[0][1], 'Synthesis');
|
||||
});
|
||||
|
||||
test('slow synthesis and rejected dispatch use the local fallback exactly once', async () => {
|
||||
const h = harness();
|
||||
let resolve;
|
||||
h.setResponse(new Promise(r => { resolve = r; }));
|
||||
h.reminders._fireReminder({ id: 'a', title: 'Title', content: 'Raw' });
|
||||
const timer = [...h.timeouts.values()].find(t => t.ms === 1500);
|
||||
timer.fn();
|
||||
resolve({ ok: true, json: async () => ({ synthesis: 'Too late' }) });
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(h.shown, [['Title', 'Raw', 'a']]);
|
||||
h.setResponse(Promise.reject(Error('offline')));
|
||||
h.reminders._fireReminder({ id: 'b', note_type: 'todo', items: [{ text: 'Task', done: false }] });
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(h.shown.length, 2);
|
||||
assert.equal(h.fired[1].body, 'Pending (1):\n- Task');
|
||||
});
|
||||
|
||||
test('notification denial, unsupported browsers and permission errors remain fail-safe', async () => {
|
||||
const toast = [];
|
||||
const absent = createNotesNotifications({ browser: {}, toast: title => toast.push(title) });
|
||||
assert.equal(await absent.ensurePermission(), false);
|
||||
absent.show('Title', 'Body', 'a');
|
||||
assert.deepEqual(toast, ['Title']);
|
||||
const denied = createNotesNotifications({ browser: { Notification: true }, notify: { permission: 'denied' } });
|
||||
assert.equal(await denied.ensurePermission(), false);
|
||||
const failed = createNotesNotifications({ browser: { Notification: true }, notify: { permission: 'default', requestPermission: async () => { throw Error('blocked'); } } });
|
||||
assert.equal(await failed.ensurePermission(), false);
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createCardOperations } from '../frontend/src/features/notes/application/cardOperations.js';
|
||||
import { createChecklistOperations } from '../frontend/src/features/notes/application/checklistOperations.js';
|
||||
import { createNotesSubmission } from '../frontend/src/features/notes/application/submission.js';
|
||||
import { createSelectionOperations } from '../frontend/src/features/notes/application/selection.js';
|
||||
import { createNotesPersistence } from '../frontend/src/features/notes/infrastructure/storage.js';
|
||||
import { createNotesLifecycle } from '../frontend/src/features/notes/ui/lifecycle.js';
|
||||
import { createText } from '../frontend/src/features/notes/ui/text.js';
|
||||
import { createDeletion } from '../frontend/src/features/notes/application/deletion.js';
|
||||
|
||||
const drain = () => new Promise(setImmediate);
|
||||
function owner(notes) { const app = createNotesState(); app.replaceNotes(notes); return app; }
|
||||
|
||||
test('reorder retains desktop persistence-only and mobile post-response state ordering', async () => {
|
||||
const app = owner([{ id: 'a', sort_order: 9 }, { id: 'b', sort_order: 8 }]), requests = [];
|
||||
let resolve;
|
||||
const operations = createCardOperations({ app, api: { reorder: ids => { requests.push(ids); return new Promise(done => { resolve = done; }); } } });
|
||||
const desktop = operations.persistOrder(['b', 'a']);
|
||||
resolve({ ok: false }); await desktop;
|
||||
assert.deepEqual(app.state.notes.map(note => note.sort_order), [9, 8]);
|
||||
const mobile = operations.commitMobileOrder(['b', 'a']);
|
||||
assert.deepEqual(app.state.notes.map(note => note.sort_order), [9, 8]);
|
||||
resolve({ ok: false }); await mobile;
|
||||
assert.deepEqual(app.state.notes.map(note => note.sort_order), [1, 0]);
|
||||
assert.deepEqual(requests, [['b', 'a'], ['b', 'a']]);
|
||||
});
|
||||
|
||||
test('editor deletion waits for confirmation and preserves optimistic failure/refetch timing', async () => {
|
||||
const app = owner([{ id: 'a' }]), events = [];
|
||||
app.setEditingId('a');
|
||||
let confirm, fail;
|
||||
const operations = createDeletion({ app, state: app.state,
|
||||
_deleteNoteApi: id => { events.push('delete:' + id); return new Promise((_resolve, reject) => { fail = reject; }); },
|
||||
_fetchNotes: async () => { events.push('fetch'); app.replaceNotes([{ id: 'a' }]); },
|
||||
_renderNotes: () => events.push('render'),
|
||||
effects: { styledConfirm: () => new Promise(resolve => { confirm = resolve; }), showToast: () => events.push('toast'), showError: error => events.push(error) },
|
||||
});
|
||||
const deletion = operations.deleteFromEditor('a');
|
||||
assert.equal(app.state.notes.length, 1);
|
||||
confirm(false); await deletion;
|
||||
assert.deepEqual(events, []);
|
||||
const accepted = operations.deleteFromEditor('a');
|
||||
confirm(true); await accepted;
|
||||
assert.equal(app.state.notes.length, 0);
|
||||
assert.equal(app.state.editingId, null);
|
||||
assert.deepEqual(events, ['render', 'delete:a']);
|
||||
fail(Error('403')); await drain();
|
||||
assert.equal(app.state.notes[0].id, 'a');
|
||||
assert.deepEqual(events, ['render', 'delete:a', 'Failed to delete', 'fetch', 'render']);
|
||||
});
|
||||
|
||||
test('pin and delete failures roll back captured state and order', async () => {
|
||||
const app = owner([{ id: 'a', pinned: false, sort_order: 4 }, { id: 'b', pinned: true, sort_order: -3 }]), errors = [];
|
||||
const operations = createCardOperations({ app, api: { patchNote: async () => { throw Error('403'); }, deleteNote: async () => { throw Error('403'); } }, effects: { render() {}, showError: error => errors.push(error) } });
|
||||
operations.togglePin('a');
|
||||
assert.equal(app.state.notes[0].pinned, true);
|
||||
assert.equal(app.state.notes[0].sort_order, -4);
|
||||
await drain();
|
||||
assert.equal(app.state.notes[0].pinned, false);
|
||||
assert.equal(app.state.notes[0].sort_order, 4);
|
||||
operations.deleteOptimistically('a');
|
||||
assert.deepEqual(app.state.notes.map(n => n.id), ['b']);
|
||||
await drain();
|
||||
assert.deepEqual(app.state.notes.map(n => n.id), ['a', 'b']);
|
||||
assert.deepEqual(errors, ['Failed to pin', 'Failed to delete']);
|
||||
});
|
||||
|
||||
test('item removal, quick add and checkbox errors preserve checklist contents', async () => {
|
||||
const app = owner([{ id: 'a', items: [{ id: 'first', text: 'A', done: false }, { id: 'second', text: 'B', done: true }] }]);
|
||||
const operations = createChecklistOperations({ app, api: { patchNote: async () => { throw Error('offline'); } }, uid: () => 'new', effects: { render() {}, showError() {} } });
|
||||
operations.removeItem('a', 0);
|
||||
assert.equal(app.state.notes[0].items[0].id, 'second');
|
||||
await drain();
|
||||
assert.deepEqual(app.state.notes[0].items.map(it => it.id), ['first', 'second']);
|
||||
const done = [];
|
||||
operations.toggleItem('a', 0, value => done.push(value), () => {});
|
||||
await drain();
|
||||
assert.deepEqual(done, [true, false]);
|
||||
const persist = operations.addItem('a', 'C', () => {});
|
||||
assert.equal(app.state.notes[0].items.length, 3);
|
||||
persist(); await drain();
|
||||
assert.equal(app.state.notes[0].items.length, 2);
|
||||
operations.editItem(app.state.notes[0], 1, 'Changed');
|
||||
await drain();
|
||||
assert.equal(app.state.notes[0].items[1].text, 'B');
|
||||
});
|
||||
|
||||
test('submission preserves top ordering, server ID replacement and newer draft recovery', async () => {
|
||||
const app = owner([{ id: 'existing', sort_order: -2 }]), values = new Map();
|
||||
const persistence = createNotesPersistence({ storage: { getItem: key => values.get(key), setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) } });
|
||||
const draftKey = persistence._draftKey('__new__');
|
||||
values.set(draftKey, '{"content":"submitted"}');
|
||||
let resolve, renders = 0;
|
||||
const operations = createNotesSubmission({ app, persistence, clock: { now: () => 0 }, uid: () => 'id', api: { saveNote: () => new Promise(r => { resolve = r; }) }, effects: { render: () => renders++, showError() {} }, fetchNotes: async () => {} });
|
||||
operations.submitNote({ title: 'New' }, null, false);
|
||||
assert.equal(app.state.notes[0].id, 'tmp_id');
|
||||
assert.equal(app.state.notes[0].sort_order, -3);
|
||||
values.set(draftKey, '{"content":"newer"}');
|
||||
resolve({ id: 'server-id' }); await drain();
|
||||
assert.equal(app.state.notes[0].id, 'server-id');
|
||||
assert.equal(values.get(draftKey), '{"content":"newer"}');
|
||||
assert.equal(renders, 2);
|
||||
});
|
||||
|
||||
test('selection can exit only after synchronous acceptance', () => {
|
||||
const app = owner([]), order = [];
|
||||
app.selectId('a');
|
||||
let accepted = false;
|
||||
const operations = createSelectionOperations({ app, sendContext: detail => { order.push('dispatch'); detail.accepted = accepted; }, exitSelection: () => order.push('exit'), effects: { showToast: () => order.push('rejected') } });
|
||||
operations.attachSelected();
|
||||
assert.deepEqual(order, ['dispatch', 'rejected']);
|
||||
accepted = true; operations.attachSelected();
|
||||
assert.deepEqual(order, ['dispatch', 'rejected', 'dispatch', 'exit']);
|
||||
});
|
||||
|
||||
test('listener owners release on replacement and disposal is idempotent', () => {
|
||||
const previous = { document: globalThis.document, MutationObserver: globalThis.MutationObserver };
|
||||
let check, disconnected = 0;
|
||||
globalThis.document = { body: {} };
|
||||
globalThis.MutationObserver = class { constructor(callback) { check = callback; } observe() {} disconnect() { disconnected++; } };
|
||||
try {
|
||||
const lifecycle = createNotesLifecycle(), target = new EventTarget(), canvas = { isConnected: true };
|
||||
let calls = 0;
|
||||
lifecycle.listen(canvas, target, 'mouseup', () => calls++);
|
||||
let released = 0;
|
||||
lifecycle.onDispose(canvas, () => released++);
|
||||
target.dispatchEvent(new Event('mouseup'));
|
||||
canvas.isConnected = false; check();
|
||||
target.dispatchEvent(new Event('mouseup'));
|
||||
lifecycle.dispose(); lifecycle.dispose();
|
||||
assert.equal(calls, 1); assert.equal(disconnected, 1);
|
||||
assert.equal(released, 1);
|
||||
} finally {
|
||||
for (const [name, value] of Object.entries(previous)) if (value === undefined) delete globalThis[name]; else globalThis[name] = value;
|
||||
}
|
||||
});
|
||||
|
||||
test('render helpers reject script-capable media and escape URL attributes', () => {
|
||||
const helpers = createText({ uiModule: { esc: value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') } });
|
||||
for (const value of ['javascript:alert(1)', 'data:image/svg+xml;base64,abc', 'data:text/html;base64,abc']) assert.equal(helpers._safeImgSrc(value), '');
|
||||
assert.equal(helpers._safeImgSrc('data:image/png;base64,abc'), 'data:image/png;base64,abc');
|
||||
assert.equal(helpers._attrEsc('"\'<>`'), '"'<>`');
|
||||
assert.match(helpers._linkify('<script> https://example.org/test'), /<script>/);
|
||||
assert.doesNotMatch(helpers._linkify('<script> https://example.org/test'), /<script>/i);
|
||||
});
|
||||
@@ -0,0 +1,625 @@
|
||||
const playwright = require('playwright');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const source = readFileSync('static/js/document.js', 'utf8');
|
||||
function documentFunction(name, text = source) {
|
||||
const match = text.match(new RegExp('^ (?:async )?function ' + name + '\\(.*?^ }', 'ms'));
|
||||
assert(match, name);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const engine = process.env.ODYSSEUS_SECURITY_BROWSER || 'chromium';
|
||||
assert(['chromium', 'firefox', 'webkit'].includes(engine), engine);
|
||||
const browser = await playwright[engine].launch({ headless: true });
|
||||
try {
|
||||
// Opt-in positive controls use an explicit vulnerable revision, so these
|
||||
// regressions also work after the fix is committed or in a shallow checkout.
|
||||
const baselineRevision = process.env.ODYSSEUS_SECURITY_BASELINE_REVISION;
|
||||
if (baselineRevision) {
|
||||
const original = execFileSync('git', ['show', baselineRevision + ':static/js/document.js'], { encoding: 'utf8' });
|
||||
const baseline = await browser.newPage();
|
||||
await baseline.route('**/api/**', route => route.fulfill({ json: { fonts: {}, value: null } }));
|
||||
await baseline.route('**/static/js/chatRenderer-head-harness.js', route => route.fulfill({
|
||||
contentType: 'application/javascript',
|
||||
body: execFileSync('git', ['show', baselineRevision + ':static/js/chatRenderer.js'], { encoding: 'utf8' }),
|
||||
}));
|
||||
await baseline.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN + '/static/js/documentStats.js');
|
||||
const originalFunctions = Object.fromEntries([
|
||||
'_unfoldEmailHeaderLines', '_parseEmailHeader', '_looksLikeWrappedEmailContent', '_decodeBase64EmailWrapper',
|
||||
'_sanitizeOutgoingEmailBody', '_emailHtmlToPlainText', '_aiReply', '_loadOdysseusAttachItems',
|
||||
'_odysseusAttachLabel', '_escHtml',
|
||||
].map(name => [name, documentFunction(name, original)]));
|
||||
const vulnerable = await baseline.evaluate(async functions => {
|
||||
const { recordSessionMetricsCost } = await import('/static/js/chatRenderer-head-harness.js');
|
||||
recordSessionMetricsCost({ model: 'gpt-4o', input_tokens: 100, output_tokens: 10,
|
||||
endpoint_cost_tracked: true, _costRecordId: 'pollutedByLedger' }, '__proto__');
|
||||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||||
const polluted = Object.hasOwn(Object.prototype, 'pollutedByLedger');
|
||||
delete Object.prototype.pollutedByLedger;
|
||||
localStorage.clear();
|
||||
// Isolate the second annotation's overflow assignment from the real
|
||||
// inherited-session lookup defect by seeding an OWN __proto__ run map.
|
||||
// The addition assigned at HEAD:1424 is primitive, so __proto__'s setter
|
||||
// ignores it rather than changing either this map or Object.prototype.
|
||||
const prototypeBefore = Object.getOwnPropertyDescriptors(Object.prototype);
|
||||
localStorage.setItem('ody-session-cost-runs', JSON.stringify({ ['__proto__']:
|
||||
Object.fromEntries(Array.from({ length: 256 }, (_, i) => ['seed-' + i, 0.001])) }));
|
||||
recordSessionMetricsCost({ model: 'gpt-4o', input_tokens: 100, output_tokens: 10,
|
||||
endpoint_cost_tracked: true, _costRecordId: 'overflow-proof' }, '__proto__');
|
||||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||||
const prototypeAfter = Object.getOwnPropertyDescriptors(Object.prototype);
|
||||
const overflowUnchanged = Reflect.ownKeys(prototypeBefore).length === Reflect.ownKeys(prototypeAfter).length
|
||||
&& Reflect.ownKeys(prototypeBefore).every(key => Reflect.ownKeys(prototypeBefore[key])
|
||||
.every(field => prototypeBefore[key][field] === prototypeAfter[key]?.[field]));
|
||||
const overflowRuns = JSON.parse(localStorage.getItem('ody-session-cost-runs'))['__proto__'];
|
||||
const overflowCostStore = localStorage.getItem('ody-session-cost');
|
||||
localStorage.clear();
|
||||
const _unfoldEmailHeaderLines = eval('(' + functions._unfoldEmailHeaderLines + ')');
|
||||
const _parseEmailHeader = eval('(' + functions._parseEmailHeader + ')');
|
||||
const _looksLikeWrappedEmailContent = eval('(' + functions._looksLikeWrappedEmailContent + ')');
|
||||
const _decodeBase64EmailWrapper = eval('(' + functions._decodeBase64EmailWrapper + ')');
|
||||
const _sanitizeOutgoingEmailBody = eval('(' + functions._sanitizeOutgoingEmailBody + ')');
|
||||
const _emailHtmlToPlainText = eval('(' + functions._emailHtmlToPlainText + ')');
|
||||
const activeDocId = 'fixture';
|
||||
const docs = new Map();
|
||||
const uiModule = { showToast() {} };
|
||||
const _splitEmailReplyQuote = text => ({ body: text, quote: '' });
|
||||
const generate = eval('(' + functions._aiReply + ')');
|
||||
document.body.innerHTML = '<textarea id="doc-editor-textarea"></textarea>';
|
||||
const payload = '<p>Existing draft</p><img src="data:,bad" onerror="window.executed++">';
|
||||
const executions = [];
|
||||
for (const inspect of [() => _sanitizeOutgoingEmailBody(payload), () => _emailHtmlToPlainText(payload),
|
||||
() => { document.getElementById('doc-editor-textarea').value = payload; return generate(); }]) {
|
||||
window.executed = 0;
|
||||
await inspect();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
executions.push(window.executed);
|
||||
}
|
||||
const API_BASE = '';
|
||||
const _escHtml = eval('(' + functions._escHtml + ')');
|
||||
const _odysseusAttachLabel = eval('(' + functions._odysseusAttachLabel + ')');
|
||||
const spinnerModule = { createLoadingRow: () => document.createElement('div') };
|
||||
const _syncOdysseusAttachSelection = () => {};
|
||||
const fetch = async () => ({ ok: true, json: async () => ({ items: [{ id: 'quote', filename: 'quote.png',
|
||||
url: 'data:,bad" onerror="window.executed++' }] }) });
|
||||
const menu = document.createElement('div');
|
||||
menu.innerHTML = '<div class="email-odysseus-attach-list"></div>';
|
||||
document.body.appendChild(menu);
|
||||
window.executed = 0;
|
||||
await eval('(' + functions._loadOdysseusAttachItems + ')')(menu, 'gallery');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return { polluted, executions, gallery: window.executed, injected: !!menu.querySelector('[onerror]'),
|
||||
overflowUnchanged, overflowRuns: Object.keys(overflowRuns).length, overflowCostStore };
|
||||
}, originalFunctions);
|
||||
assert.equal(vulnerable.polluted, true);
|
||||
assert(vulnerable.executions.every(count => count > 0), JSON.stringify(vulnerable));
|
||||
assert.equal(vulnerable.injected, true);
|
||||
assert(vulnerable.gallery > 0);
|
||||
assert.equal(vulnerable.overflowUnchanged, true);
|
||||
assert.equal(vulnerable.overflowRuns, 256);
|
||||
assert.equal(vulnerable.overflowCostStore, '{}');
|
||||
await baseline.close();
|
||||
}
|
||||
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
const probes = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
page.on('request', request => {
|
||||
if (request.url().includes('/inert-probe')) probes.push(request.url());
|
||||
});
|
||||
await page.route('**/api/**', route => route.fulfill({ json: { fonts: {}, value: null } }));
|
||||
await page.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN + '/static/js/documentStats.js');
|
||||
await page.setContent('<!doctype html><textarea id="doc-editor-textarea"></textarea>');
|
||||
|
||||
const ledger = await page.evaluate(async () => {
|
||||
const { recordSessionMetricsCost, getSessionCost, resetSessionCost } = await import('/static/js/chatRenderer.js');
|
||||
const metrics = id => ({ model: 'gpt-4o', input_tokens: 100, output_tokens: 10,
|
||||
endpoint_cost_tracked: true, _costRecordId: id });
|
||||
const before = Object.getOwnPropertyDescriptors(Object.prototype);
|
||||
for (const sid of ['__proto__', 'constructor', 'prototype', ['__proto__'], { toString: () => '__proto__' }]) {
|
||||
for (const id of ['pollutedByLedger', '__proto__', 'constructor', 'prototype', '']) {
|
||||
localStorage.clear();
|
||||
recordSessionMetricsCost(metrics(id), sid);
|
||||
// Web Locks settle asynchronously in Chromium.
|
||||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||||
if (Object.hasOwn(Object.prototype, 'pollutedByLedger')) throw new Error('Object.prototype polluted');
|
||||
if (localStorage.getItem('ody-session-cost') || localStorage.getItem('ody-session-cost-runs')) {
|
||||
throw new Error('Unsafe session key was stored');
|
||||
}
|
||||
}
|
||||
localStorage.clear();
|
||||
recordSessionMetricsCost(metrics(' ' + sid + ' '), 'safe-session');
|
||||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||||
if (localStorage.getItem('ody-session-cost-runs')) throw new Error('Unsafe run key was stored');
|
||||
}
|
||||
localStorage.clear();
|
||||
recordSessionMetricsCost(metrics('valid-run'), 'safe-session');
|
||||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||||
const cost = getSessionCost('safe-session');
|
||||
recordSessionMetricsCost(metrics('valid-run'), 'safe-session');
|
||||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||||
// Keys are literal property names, not dot-separated traversal paths.
|
||||
recordSessionMetricsCost(metrics('constructor.prototype'), 'safe.__proto__.session');
|
||||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||||
const legacy = metrics('');
|
||||
recordSessionMetricsCost(legacy, 'legacy-session');
|
||||
recordSessionMetricsCost(legacy, 'legacy-session');
|
||||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||||
for (let i = 0; i < 257; i++) recordSessionMetricsCost(metrics('run-' + i), 'overflow-session');
|
||||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||||
// Snapshot all ordinary-ledger results before the reserved-key fixture
|
||||
// deliberately replaces localStorage below.
|
||||
const replayCost = getSessionCost('safe-session');
|
||||
const legacyCost = getSessionCost('legacy-session');
|
||||
const dottedCost = getSessionCost('safe.__proto__.session');
|
||||
const overflowCost = getSessionCost('overflow-session');
|
||||
const runWire = JSON.parse(localStorage.getItem('ody-session-cost-runs') || '{}');
|
||||
const costWire = JSON.parse(localStorage.getItem('ody-session-cost') || '{}');
|
||||
const retainedRuns = Object.keys(runWire['overflow-session']).length;
|
||||
const wireShape = !Array.isArray(runWire)
|
||||
&& !Array.isArray(costWire)
|
||||
&& !!runWire['overflow-session']
|
||||
&& !Array.isArray(runWire['overflow-session']);
|
||||
|
||||
// A persisted legacy/reserved key must remain data rather than becoming
|
||||
// prototype state. Reading and removing it must also be side-effect free.
|
||||
localStorage.setItem('ody-session-cost', '{"__proto__":1}');
|
||||
localStorage.setItem('ody-session-cost-runs', '{"__proto__":{"legacy-run":2}}');
|
||||
const legacyReservedCost = getSessionCost('__proto__');
|
||||
resetSessionCost('__proto__');
|
||||
const clearedReserved = !Object.hasOwn(
|
||||
JSON.parse(localStorage.getItem('ody-session-cost') || '{}'),
|
||||
'__proto__',
|
||||
) && !Object.hasOwn(
|
||||
JSON.parse(localStorage.getItem('ody-session-cost-runs') || '{}'),
|
||||
'__proto__',
|
||||
);
|
||||
|
||||
const after = Object.getOwnPropertyDescriptors(Object.prototype);
|
||||
return { cost, replayCost, legacyCost, dottedCost,
|
||||
overflowCost, retainedRuns, wireShape, legacyReservedCost, clearedReserved,
|
||||
unchanged: Reflect.ownKeys(before).length === Reflect.ownKeys(after).length
|
||||
&& Reflect.ownKeys(before).every(key => Reflect.ownKeys(before[key]).every(field => before[key][field] === after[key]?.[field])) };
|
||||
});
|
||||
assert(ledger.cost > 0);
|
||||
assert.equal(ledger.replayCost, ledger.cost);
|
||||
assert.equal(ledger.unchanged, true);
|
||||
assert.equal(ledger.legacyCost, ledger.cost);
|
||||
assert.equal(ledger.dottedCost, ledger.cost);
|
||||
assert(Math.abs(ledger.overflowCost - 257 * ledger.cost) < 1e-10);
|
||||
assert.equal(ledger.retainedRuns, 256);
|
||||
assert.equal(ledger.wireShape, true);
|
||||
assert.equal(ledger.legacyReservedCost, 3);
|
||||
assert.equal(ledger.clearedReserved, true);
|
||||
|
||||
const names = ['_unfoldEmailHeaderLines', '_parseEmailHeader', '_looksLikeWrappedEmailContent', '_decodeBase64EmailWrapper',
|
||||
'_sanitizeOutgoingEmailBody', '_emailHtmlToPlainText', '_aiReply',
|
||||
'_loadOdysseusAttachItems', '_odysseusAttachLabel', '_escHtml',
|
||||
'_normalizeRichLinkUrl', '_smartRichPasteUrl', '_insertSmartRichPasteLink', '_cleanRichTextPasteHtml', 'exportAsPdf'];
|
||||
const functions = Object.fromEntries(names.map(name => [name, documentFunction(name)]));
|
||||
const email = await page.evaluate(async functions => {
|
||||
window.executed = 0;
|
||||
const _unfoldEmailHeaderLines = eval('(' + functions._unfoldEmailHeaderLines + ')');
|
||||
const _parseEmailHeader = eval('(' + functions._parseEmailHeader + ')');
|
||||
const _looksLikeWrappedEmailContent = eval('(' + functions._looksLikeWrappedEmailContent + ')');
|
||||
const _decodeBase64EmailWrapper = eval('(' + functions._decodeBase64EmailWrapper + ')');
|
||||
const _sanitizeOutgoingEmailBody = eval('(' + functions._sanitizeOutgoingEmailBody + ')');
|
||||
const _emailHtmlToPlainText = eval('(' + functions._emailHtmlToPlainText + ')');
|
||||
const activeDocId = 'fixture';
|
||||
const docs = new Map();
|
||||
const toasts = [];
|
||||
const uiModule = { showToast: text => toasts.push(text) };
|
||||
const _splitEmailReplyQuote = text => ({ body: text, quote: '' });
|
||||
const generate = eval('(' + functions._aiReply + ')');
|
||||
const payloads = [
|
||||
'<img src="/inert-probe" onerror="window.executed++">',
|
||||
'<svg onload="window.executed++"><script>window.executed++</script></svg>',
|
||||
'<iframe srcdoc="<script>parent.executed++</script>"></iframe>',
|
||||
'<img src="data:,bad" onerror="window.executed++">',
|
||||
];
|
||||
const plain = [];
|
||||
for (const payload of payloads) {
|
||||
_sanitizeOutgoingEmailBody(payload);
|
||||
plain.push(_emailHtmlToPlainText(payload));
|
||||
// A user-authored body must be inspected without executing its HTML.
|
||||
document.getElementById('doc-editor-textarea').value = '<p>Existing draft</p>' + payload;
|
||||
await generate();
|
||||
}
|
||||
// Media-only drafts must not be mistaken for an empty reply. This checks
|
||||
// the query boundary moved from the element to template.content too.
|
||||
for (const body of ['<img src="/inert-probe">', '<video src="/inert-probe"></video>',
|
||||
'<audio src="/inert-probe"></audio>', '<iframe src="/inert-probe"></iframe>', '<table><tr><td></td></tr></table>']) {
|
||||
document.getElementById('doc-editor-textarea').value = body;
|
||||
await generate();
|
||||
}
|
||||
const normal = _emailHtmlToPlainText('<p>Hello <b>world</b> & friends</p>');
|
||||
const literal = _emailHtmlToPlainText('<img src=x onerror="window.executed++">');
|
||||
const outgoing = _sanitizeOutgoingEmailBody('Hello\n\nworld');
|
||||
const wrapped = _sanitizeOutgoingEmailBody(btoa('To: person@example.com\nSubject: Test\n---\nValid reply'));
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
return { normal, literal, outgoing, wrapped, toasts, executed: window.executed };
|
||||
}, functions);
|
||||
assert.equal(email.normal, 'Hello world & friends');
|
||||
assert.equal(email.literal, '<img src=x onerror="window.executed++">');
|
||||
assert.equal(email.outgoing, 'Hello\n\nworld');
|
||||
assert.equal(email.wrapped, 'Valid reply');
|
||||
assert.deepEqual(email.toasts, Array(9).fill('Reply already has text'));
|
||||
assert.equal(email.executed, 0);
|
||||
assert.deepEqual(probes, []);
|
||||
|
||||
// The current payload must execute at an active DOM boundary. This makes
|
||||
// the non-execution assertions above independent of old git revisions.
|
||||
const activeEmailControl = await page.evaluate(async () => {
|
||||
const active = document.createElement('div');
|
||||
active.innerHTML = '<img src="data:,bad" onerror="window.executed++">';
|
||||
document.body.appendChild(active);
|
||||
const deadline = Date.now() + 2000;
|
||||
while (!window.executed && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
active.remove();
|
||||
const executed = window.executed;
|
||||
window.executed = 0;
|
||||
return executed;
|
||||
});
|
||||
assert(activeEmailControl > 0);
|
||||
|
||||
const gallery = await page.evaluate(async functions => {
|
||||
const API_BASE = '';
|
||||
const _escHtml = eval('(' + functions._escHtml + ')');
|
||||
const _odysseusAttachLabel = eval('(' + functions._odysseusAttachLabel + ')');
|
||||
const spinnerModule = { createLoadingRow: () => document.createElement('div') };
|
||||
const _syncOdysseusAttachSelection = () => {};
|
||||
const load = eval('(' + functions._loadOdysseusAttachItems + ')');
|
||||
const urls = ['/static/missing.png" onerror="window.executed++" data-injected="yes',
|
||||
'/static/missing.png"><svg onload="window.executed++"></svg>', '/static/missing.png',
|
||||
'javascript:window.executed++', 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" onload="parent.executed++"/>'];
|
||||
const fetch = async () => ({ ok: true, json: async () => ({ items: urls.map((url, i) => ({
|
||||
id: 'image-' + i, url, filename: '<b>Picture</b>', title: 'Safe title' })) }) });
|
||||
const menu = document.createElement('div');
|
||||
menu.innerHTML = '<div class="email-odysseus-attach-list"></div>';
|
||||
document.body.appendChild(menu);
|
||||
await load(menu, 'gallery');
|
||||
const rows = [...menu.querySelectorAll('.email-odysseus-attach-row')];
|
||||
rows[0].click();
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
return { urls, sources: rows.map(row => row.querySelector('img').getAttribute('src')),
|
||||
unsafe: menu.querySelectorAll('[onerror], [onload], [data-injected], svg, script').length,
|
||||
selected: rows[0].classList.contains('is-selected'),
|
||||
labels: rows.map(row => row.querySelector('.email-odysseus-attach-meta').textContent),
|
||||
executed: window.executed };
|
||||
}, functions);
|
||||
assert.deepEqual(gallery.sources, gallery.urls);
|
||||
assert.equal(gallery.unsafe, 0);
|
||||
assert.equal(gallery.executed, 0);
|
||||
assert.equal(gallery.selected, true);
|
||||
assert.deepEqual(gallery.labels, Array(5).fill('<b>Picture</b>'));
|
||||
|
||||
const links = await page.evaluate(async functions => {
|
||||
const markdownModule = await import('/static/js/markdown.js');
|
||||
const _normalizeRichLinkUrl = eval('(' + functions._normalizeRichLinkUrl + ')');
|
||||
const _smartRichPasteUrl = eval('(' + functions._smartRichPasteUrl + ')');
|
||||
const insert = eval('(' + functions._insertSmartRichPasteLink + ')');
|
||||
const cleanPaste = eval('(' + functions._cleanRichTextPasteHtml + ')');
|
||||
const rejected = ['javascript:window.executed++', 'java\tscript:window.executed++',
|
||||
'data:text/html,<script>window.executed++</script>', 'vbscript:msgbox(1)',
|
||||
'file:///etc/passwd', 'https://example.com/\njavascript:window.executed++',
|
||||
'blob:https://example.com/id', 'about:blank', 'ftp://example.com/path',
|
||||
'JaVaScRiPt:window.executed++', 'java\rscript:window.executed++',
|
||||
'https://', '//outside.example/path'];
|
||||
const rich = document.createElement('div');
|
||||
rich.contentEditable = 'true';
|
||||
// Literal markup in an existing selection must remain text after linking.
|
||||
const literal = '<img src=x onerror="window.executed++">';
|
||||
const bold = document.createElement('strong');
|
||||
bold.textContent = literal;
|
||||
rich.appendChild(bold);
|
||||
document.body.appendChild(rich);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(rich);
|
||||
getSelection().removeAllRanges();
|
||||
getSelection().addRange(range);
|
||||
rich.focus();
|
||||
const internal = location.origin + '/static/index.html?session=valid#doc';
|
||||
const inserted = insert(rich, internal);
|
||||
const link = rich.querySelector('a');
|
||||
const result = { rejected: rejected.map(_smartRichPasteUrl), inserted,
|
||||
href: link?.href, internal, text: link?.textContent, literal,
|
||||
bold: link?.querySelector('strong')?.textContent,
|
||||
unsafe: rich.querySelectorAll('img,script,[onerror],[onload]').length,
|
||||
mail: _smartRichPasteUrl('person@example.com'), tel: _smartRichPasteUrl('tel:+12345'),
|
||||
external: _smartRichPasteUrl('https://outside.example/path?q=1#fragment'),
|
||||
domain: _smartRichPasteUrl('example.com/path'),
|
||||
network: _smartRichPasteUrl('//outside.example/path'),
|
||||
deceptive: _smartRichPasteUrl('https://internal.example@outside.example/path'),
|
||||
executed: window.executed };
|
||||
rich.innerHTML = cleanPaste('<p><strong onmouseover="window.executed++"><a href="javascript:window.executed++">Safe selection</a></strong></p>');
|
||||
const pastedRange = document.createRange();
|
||||
pastedRange.selectNodeContents(rich.querySelector('p'));
|
||||
getSelection().removeAllRanges();
|
||||
getSelection().addRange(pastedRange);
|
||||
result.cleanSelection = insert(rich, 'https://outside.example/path');
|
||||
result.cleanText = rich.querySelector('a')?.textContent;
|
||||
result.cleanUnsafe = rich.querySelectorAll('[onmouseover], a[href^="javascript:"]').length;
|
||||
|
||||
const collapsed = document.createRange();
|
||||
collapsed.selectNodeContents(rich);
|
||||
collapsed.collapse(false);
|
||||
getSelection().removeAllRanges();
|
||||
getSelection().addRange(collapsed);
|
||||
result.collapsed = insert(rich, 'https://outside.example/\"><svg/onload=window.executed++>');
|
||||
result.quoteUnsafe = rich.querySelectorAll('svg,[onload]').length;
|
||||
|
||||
rich.innerHTML = '<p>First</p><p>Second</p>';
|
||||
const crossing = document.createRange();
|
||||
crossing.setStart(rich.firstChild.firstChild, 0);
|
||||
crossing.setEnd(rich.lastChild.firstChild, 6);
|
||||
getSelection().removeAllRanges();
|
||||
getSelection().addRange(crossing);
|
||||
result.crossing = insert(rich, internal);
|
||||
const outside = document.createRange();
|
||||
outside.selectNodeContents(document.getElementById('doc-editor-textarea'));
|
||||
getSelection().removeAllRanges();
|
||||
getSelection().addRange(outside);
|
||||
result.outside = insert(rich, internal);
|
||||
return result;
|
||||
}, functions);
|
||||
assert.deepEqual(links.rejected, Array(13).fill(''));
|
||||
assert.equal(links.inserted, true);
|
||||
assert.equal(links.href, links.internal);
|
||||
assert.equal(links.text, links.literal);
|
||||
assert.equal(links.bold, links.literal);
|
||||
assert.equal(links.unsafe, 0);
|
||||
assert.equal(links.executed, 0);
|
||||
assert.equal(links.mail, 'mailto:person@example.com');
|
||||
assert.equal(links.tel, 'tel:+12345');
|
||||
assert.equal(links.external, 'https://outside.example/path?q=1#fragment');
|
||||
assert.equal(links.domain, 'https://example.com/path');
|
||||
assert.equal(links.network, '');
|
||||
assert.equal(new URL(links.deceptive).hostname, 'outside.example');
|
||||
assert.equal(links.cleanSelection, true);
|
||||
assert.equal(links.cleanText, 'Safe selection');
|
||||
assert.equal(links.cleanUnsafe, 0);
|
||||
assert.equal(links.collapsed, true);
|
||||
assert.equal(links.quoteUnsafe, 0);
|
||||
assert.equal(links.crossing, false);
|
||||
assert.equal(links.outside, false);
|
||||
|
||||
// Exercise the sanitizer itself, then the exact live HTML insertion path.
|
||||
const markdown = await page.evaluate(async functions => {
|
||||
const mod = await import('/static/js/markdown.js');
|
||||
const markdownModule = mod;
|
||||
const _normalizeRichLinkUrl = eval('(' + functions._normalizeRichLinkUrl + ')');
|
||||
const cleanPaste = eval('(' + functions._cleanRichTextPasteHtml + ')');
|
||||
const payloads = [
|
||||
'<script>window.executed++</script><img src="data:,bad" onerror="window.executed++">',
|
||||
'<a href="java	script:window.executed++" onclick="window.executed++">click</a>',
|
||||
'<a href="data:text/html,<script>window.executed++</script>">data</a>',
|
||||
'<img srcset="/safe.png 1x, data:image/svg+xml,bad 2x" onload="window.executed++">',
|
||||
'<svg><foreignObject><img src=x onerror="window.executed++"></foreignObject><script>window.executed++</script></svg>',
|
||||
'<math><mtext><img src=x onerror="window.executed++"></mtext></math>',
|
||||
'<math><mtext><table><mglyph><style><!--</style><img title="--><img src=x onerror=window.executed++>">',
|
||||
'<svg><p><style><g title="</style><img src=x onerror=window.executed++>">',
|
||||
'<details><summary onmouseover="window.executed++">Nested</summary><p style="background:url(javascript:window.executed++)"><a href="javascript:window.executed++">bad</a></p></details>',
|
||||
'<iframe srcdoc="<script>parent.executed++</script>"></iframe><object data="data:text/html,bad"></object>',
|
||||
'<noscript><p title="</noscript><img src=x onerror=window.executed++>">',
|
||||
'<a href="javascript:window.executed++" ONCLICK="window.executed++">entity</a>',
|
||||
'<a href="
JaVaScRiPt:window.executed++">controls</a>',
|
||||
'<a href="vbscript:msgbox(1)">legacy</a><img src="data:image/svg+xml,bad">',
|
||||
'<template><img src=x onerror="window.executed++"></template>',
|
||||
'<details open ontoggle="window.executed++"><summary>Event</summary><p>Text</p></details>',
|
||||
'<svg><a xlink:href="javascript:window.executed++"><text>SVG link</text></a></svg>',
|
||||
'<math><annotation-xml encoding="text/html"><img src=x onerror="window.executed++"></annotation-xml></math>',
|
||||
'<table><caption><details><summary onclick="window.executed++">Nested</summary><img src=x onerror="window.executed++"></details></caption></table>',
|
||||
];
|
||||
const box = document.createElement('div');
|
||||
document.body.appendChild(box);
|
||||
let unsafe = 0;
|
||||
for (const payload of payloads) {
|
||||
const clean = mod.sanitizeAllowedHtml(payload);
|
||||
if (mod.sanitizeAllowedHtml(clean) !== clean) throw new Error('Sanitizer did not stabilize');
|
||||
for (const html of [clean, cleanPaste(payload)]) {
|
||||
// Include repeated serialize/reparse boundaries used by chat and paste.
|
||||
box.innerHTML = html;
|
||||
for (let pass = 0; pass < 3; pass++) box.innerHTML = box.innerHTML;
|
||||
unsafe += box.querySelectorAll('script,svg,math,iframe,object,embed,style,base,meta,template,noscript').length;
|
||||
for (const el of box.querySelectorAll('*')) for (const attr of el.attributes) {
|
||||
const value = attr.value.replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase();
|
||||
if (attr.name.startsWith('on') || attr.name === 'srcdoc'
|
||||
|| (/^(href|src|srcset|style)$/.test(attr.name) && /javascript:|vbscript:|data:/.test(value))) unsafe++;
|
||||
}
|
||||
box.querySelectorAll('a').forEach(a => a.click());
|
||||
}
|
||||
}
|
||||
box.innerHTML = mod.sanitizeAllowedHtml('<details><summary>Title</summary><b>Bold</b><a href="https://example.com/path">Link</a></details>');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return { count: payloads.length, unsafe, executed: window.executed, bold: box.querySelector('b')?.textContent,
|
||||
href: box.querySelector('a')?.href, details: !!box.querySelector('details') };
|
||||
}, functions);
|
||||
assert.equal(markdown.count, 19);
|
||||
assert.equal(markdown.unsafe, 0);
|
||||
assert.equal(markdown.executed, 0);
|
||||
assert.equal(markdown.bold, 'Bold');
|
||||
assert.equal(markdown.href, 'https://example.com/path');
|
||||
assert.equal(markdown.details, true);
|
||||
|
||||
// Run the real print function. Only the OS dialog is replaced; HTML parsing,
|
||||
// frame policy and renderMath are the production implementations.
|
||||
const print = await page.evaluate(async functions => {
|
||||
const mod = await import('/static/js/markdown.js');
|
||||
const activeDocId = 'fixture';
|
||||
const _isRichTextLang = lang => lang === 'richtext';
|
||||
const _isDocxLang = () => false;
|
||||
const _getExportBaseName = () => 'Print <test>';
|
||||
const _richTextExportCss = () => 'b { font-weight: bold; }';
|
||||
const failures = [];
|
||||
const uiModule = { showError: text => failures.push(text) };
|
||||
let prints = 0;
|
||||
const markdownModule = { ...mod, renderMath(container) {
|
||||
container.ownerDocument.defaultView.print = () => { prints++; };
|
||||
container.ownerDocument.defaultView.focus = () => {};
|
||||
return mod.renderMath(container);
|
||||
} };
|
||||
document.body.insertAdjacentHTML('beforeend', '<select id="doc-language-select"><option>richtext</option></select>');
|
||||
const payload = '<b>Printable</b><script>parent.executed++</script>'
|
||||
+ '<img src="data:,bad" onerror="parent.executed++">'
|
||||
+ '<svg onload="parent.executed++"></svg>'
|
||||
+ '<iframe sandbox="allow-scripts allow-same-origin" srcdoc="<script>top.executed++</script><img src=x onerror=top.executed++>"></iframe>'
|
||||
+ '<a href="javascript:parent.executed++">bad link</a>';
|
||||
document.getElementById('doc-editor-textarea').value = payload;
|
||||
await eval('(' + functions.exportAsPdf + ')')();
|
||||
const frame = document.getElementById('doc-browser-print-frame');
|
||||
frame.contentDocument.querySelector('a').click();
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
const result = { prints, failures, sandbox: frame.getAttribute('sandbox'),
|
||||
text: frame.contentDocument.querySelector('b')?.textContent,
|
||||
title: frame.contentDocument.title, executed: window.executed };
|
||||
frame.remove();
|
||||
// Without sandbox, the same event/srcdoc payload must execute.
|
||||
const control = document.createElement('iframe');
|
||||
control.srcdoc = payload;
|
||||
document.body.appendChild(control);
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
result.controlExecuted = window.executed;
|
||||
control.remove();
|
||||
window.executed = 0;
|
||||
return result;
|
||||
}, functions);
|
||||
assert.equal(print.prints, 1);
|
||||
assert.deepEqual(print.failures, []);
|
||||
assert.equal(print.sandbox, 'allow-same-origin allow-modals');
|
||||
assert.equal(print.text, 'Printable');
|
||||
assert.equal(print.title, 'Print <test>');
|
||||
assert.equal(print.executed, 0);
|
||||
assert(print.controlExecuted > 0);
|
||||
|
||||
// Both appendChild findings follow tainted card._md, an ordinary JS
|
||||
// property. Rendering and re-rendering must keep that markdown as text.
|
||||
const skillPayload = '<img src=x onerror="window.executed++"><svg onload="window.executed++"></svg>';
|
||||
await page.route('**/api/skills', route => route.fulfill({ json: { skills: [
|
||||
{ name: 'user-fixture', source: 'user', status: 'published', confidence: 1,
|
||||
description: skillPayload, tags: [skillPayload] },
|
||||
{ name: 'builtin-fixture', source: 'builtin', status: 'published', confidence: 1,
|
||||
description: skillPayload, tags: [skillPayload] },
|
||||
] } }));
|
||||
await page.route('**/static/js/skills-pr6503-harness.js', route => route.fulfill({
|
||||
contentType: 'application/javascript',
|
||||
body: readFileSync('static/js/skills.js', 'utf8') + '\nexport { _mdCache, _expandSkillCard, _toggleSkillEdit, _saveSkillEdit };\n',
|
||||
}));
|
||||
const skills = await page.evaluate(async payload => {
|
||||
document.body.insertAdjacentHTML('beforeend', '<div id="toast"></div><div id="skills-list"></div>');
|
||||
const mod = await import('/static/js/skills-pr6503-harness.js');
|
||||
mod._mdCache.set('user-fixture', payload);
|
||||
mod._mdCache.set('builtin-fixture', payload);
|
||||
await mod.loadSkills();
|
||||
for (const card of document.querySelectorAll('#skills-list .skill-card')) {
|
||||
await mod._expandSkillCard(card, card.dataset.skillName);
|
||||
}
|
||||
await mod.loadSkills();
|
||||
const cards = [...document.querySelectorAll('#skills-list .skill-card')];
|
||||
for (const card of cards) await mod._expandSkillCard(card, card.dataset.skillName);
|
||||
return { sections: cards.map(card => card.dataset.skillSection).sort(),
|
||||
text: cards.map(card => card.querySelector('.skill-md-pre').textContent),
|
||||
stored: cards.map(card => card._md),
|
||||
descriptions: cards.map(card => card.querySelector('.skill-card-desc').textContent),
|
||||
tags: cards.map(card => card.querySelector('.skill-tag-pill').textContent),
|
||||
unsafe: document.querySelectorAll('#skills-list img, #skills-list script, #skills-list [onerror], #skills-list [onload]').length,
|
||||
executed: window.executed };
|
||||
}, skillPayload);
|
||||
assert.deepEqual(skills.sections, ['builtin', 'user']);
|
||||
assert.deepEqual(skills.text, [skillPayload, skillPayload]);
|
||||
assert.deepEqual(skills.stored, [skillPayload, skillPayload]);
|
||||
assert.deepEqual(skills.descriptions, [skillPayload, skillPayload]);
|
||||
assert.deepEqual(skills.tags, [skillPayload, skillPayload]);
|
||||
assert.equal(skills.unsafe, 0);
|
||||
assert.equal(skills.executed, 0);
|
||||
|
||||
// Also trace the real API -> cache -> textContent path with a cold cache;
|
||||
// the seeded fixtures above exercise the preserved-card rerender path.
|
||||
const fetchedSkills = new Set();
|
||||
let postedSkillMarkdown;
|
||||
await page.route('**/api/skills/*/markdown', route => {
|
||||
if (route.request().method() === 'POST') {
|
||||
postedSkillMarkdown = route.request().postDataJSON().markdown;
|
||||
return route.fulfill({ json: {} });
|
||||
}
|
||||
fetchedSkills.add(decodeURIComponent(new URL(route.request().url()).pathname.split('/')[3]));
|
||||
return route.fulfill({ json: { markdown: skillPayload } });
|
||||
});
|
||||
const coldSkills = await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/skills-pr6503-harness.js');
|
||||
mod._mdCache.clear();
|
||||
document.querySelectorAll('#skills-list .skill-card').forEach(card => card.remove());
|
||||
await mod.loadSkills();
|
||||
const cards = [...document.querySelectorAll('#skills-list .skill-card')];
|
||||
for (const card of cards) await mod._expandSkillCard(card, card.dataset.skillName);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return { text: cards.map(card => card.querySelector('.skill-md-pre').textContent),
|
||||
stored: cards.map(card => card._md),
|
||||
unsafe: document.querySelectorAll('#skills-list img, #skills-list script, #skills-list [onerror], #skills-list [onload]').length,
|
||||
executed: window.executed };
|
||||
});
|
||||
assert.deepEqual([...fetchedSkills].sort(), ['builtin-fixture', 'user-fixture']);
|
||||
assert.deepEqual(coldSkills.text, [skillPayload, skillPayload]);
|
||||
assert.deepEqual(coldSkills.stored, [skillPayload, skillPayload]);
|
||||
assert.equal(coldSkills.unsafe, 0);
|
||||
assert.equal(coldSkills.executed, 0);
|
||||
|
||||
// The alert's actual source is the edit textarea at skills.js:1312.
|
||||
const editedPayload = '" & <script>window.executed++</script>' + skillPayload;
|
||||
const editedSkill = await page.evaluate(async payload => {
|
||||
const mod = await import('/static/js/skills-pr6503-harness.js');
|
||||
const card = document.querySelector('[data-skill-name="user-fixture"]');
|
||||
if (!card.classList.contains('doclib-card-expanded')) await mod._expandSkillCard(card, 'user-fixture');
|
||||
mod._toggleSkillEdit(card, 'user-fixture');
|
||||
card.querySelector('.skill-md-editor').value = payload;
|
||||
await mod._saveSkillEdit(card, 'user-fixture');
|
||||
const restored = document.querySelector('[data-skill-name="user-fixture"]');
|
||||
await mod._expandSkillCard(restored, 'user-fixture');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return { text: restored.querySelector('.skill-md-pre').textContent, stored: restored._md,
|
||||
unsafe: restored.querySelectorAll('img,script,[onerror],[onload]').length, executed: window.executed };
|
||||
}, editedPayload);
|
||||
assert.equal(postedSkillMarkdown, editedPayload);
|
||||
assert.equal(editedSkill.text, editedPayload);
|
||||
assert.equal(editedSkill.stored, editedPayload);
|
||||
assert.equal(editedSkill.unsafe, 0);
|
||||
assert.equal(editedSkill.executed, 0);
|
||||
|
||||
// #767 reparses the instruction read from a rendered message's textContent.
|
||||
// Exercise the real addMessage path in every selected browser too.
|
||||
const chat = await page.evaluate(async () => {
|
||||
document.body.insertAdjacentHTML('beforeend',
|
||||
'<div id="sidebar"></div><div id="chat-container"></div><div id="chat-history"></div>');
|
||||
const { addMessage } = await import('/static/js/chatRenderer.js');
|
||||
const payloads = [
|
||||
'<img src=x onerror="window.executed++">',
|
||||
'<details><summary>Nested</summary><a href="java	script:window.executed++">bad</a><img src=x onerror="window.executed++"></details>',
|
||||
'<math><mtext><img src=x onerror="window.executed++"></mtext></math>',
|
||||
'<think><svg onload="window.executed++"></svg></think>**Valid** instruction',
|
||||
];
|
||||
const refs = [];
|
||||
let unsafe = 0;
|
||||
for (const payload of payloads) {
|
||||
const message = addMessage('user', 'In the document, edit this specific text (lines 1–2):\n```\nselected\n```\n\nInstruction: ' + payload);
|
||||
if (!message) throw new Error('addMessage failed');
|
||||
refs.push(message.querySelector('.doc-edit-tag')?.dataset.docEditRef);
|
||||
unsafe += message.querySelector('.body').querySelectorAll('script,math,svg[onload],[onerror],[onload],a[href^="javascript:"]').length;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return { refs, unsafe, executed: window.executed };
|
||||
});
|
||||
assert.deepEqual(chat.refs, Array(4).fill('lines 1–2'));
|
||||
assert.equal(chat.unsafe, 0);
|
||||
assert.equal(chat.executed, 0);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ ledger: true, email: true, gallery: true, links: true, skills: true,
|
||||
sanitizer: true, print: true }));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exit(1); });
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Explicit trusted producer fixtures; no production authority fallback."""
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority
|
||||
from src.agent_runtime.resources import BackgroundJobResource, NativeBackendResource, ProcessResource
|
||||
from src.agent_runtime.process_resources import bind_process_operation, resolve_process_operation, publish_launch
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
|
||||
|
||||
@contextmanager
|
||||
def launch_authority(content, workspace, *, tool="bash", owner="", session_id="chat", authority=None):
|
||||
authority = authority or RequestAuthority("producer-test", owner, session_id, str(workspace), (OperationGrant(tool),))
|
||||
bound = resolve_process_operation(authority, ExactOperation.normalize(tool, content), NativeBackendResource(tool))
|
||||
with bind_request_authority(authority), bind_process_operation(bound):
|
||||
yield authority, bound
|
||||
|
||||
|
||||
def launch(command, session_id="chat", *, cwd, **kwargs):
|
||||
from src import bg_jobs
|
||||
with launch_authority(command, cwd, session_id=session_id):
|
||||
return bg_jobs.launch(command, session_id, cwd=cwd, **kwargs)
|
||||
|
||||
|
||||
def identity(job_id):
|
||||
from src import bg_jobs
|
||||
from src.agent_runtime.process_resources import job_from_record
|
||||
return job_from_record(bg_jobs.peek(job_id))
|
||||
|
||||
|
||||
def get(job_id):
|
||||
from src import bg_jobs
|
||||
return bg_jobs.get(job_id, expected=identity(job_id))
|
||||
|
||||
|
||||
def kill(job_id):
|
||||
from src import bg_jobs
|
||||
return bg_jobs.kill(job_id, expected=identity(job_id))
|
||||
|
||||
|
||||
def seed_linkage(record, workspace, *, owner="", request_id="producer-test"):
|
||||
"""A fake server spawn record, with an explicit fake lifecycle observation."""
|
||||
from src import bg_jobs, containment
|
||||
from src.agent_runtime.authority import save_background_authority
|
||||
from src.agent_runtime.process_resources import resolve_process_operation
|
||||
authority = RequestAuthority(request_id, owner, record["session_id"], str(workspace), (OperationGrant("bash"),))
|
||||
bound = resolve_process_operation(authority, ExactOperation.normalize("bash", record["command"]), NativeBackendResource("bash"))
|
||||
receipt = uuid4().hex
|
||||
record.update(containment_id=receipt, start_token="test-boot:start", pgid=record["pid"])
|
||||
process = ProcessResource("native:bg_jobs", owner, request_id, record["session_id"],
|
||||
ProcessIdentity(record["pid"], record["start_token"], record["pgid"]), "supervisor", record["id"], receipt)
|
||||
resource = BackgroundJobResource("native:bg_jobs", record["id"], bound.launch.generation,
|
||||
owner, request_id, record["session_id"], receipt, (process,))
|
||||
record.update(resource_identity=resource.to_dict(), launch_resource=bound.launch.to_dict())
|
||||
from core.atomic_io import atomic_write_json
|
||||
receipts = containment._load_records()
|
||||
receipts[receipt] = {"id": receipt, "launch_generation": resource.generation,
|
||||
"owner": "bg:" + resource.thread_id, "supervisor_pid": process.identity.pid,
|
||||
"supervisor_token": process.identity.start_token, "mechanism": "process_group"}
|
||||
atomic_write_json(containment._store_path(), receipts)
|
||||
publish_launch(bound.launch, authority, receipt, job=resource, processes=(process,))
|
||||
save_background_authority(record["id"], authority, resource=resource)
|
||||
return resource
|
||||
|
||||
|
||||
def authorized_handler(handler, workspace):
|
||||
async def execute(content, ctx):
|
||||
from src.agent_runtime.process_resources import active_process_operation
|
||||
from src.agent_runtime.authority import active_request_authority
|
||||
if active_process_operation() is not None or active_request_authority() is not None:
|
||||
return await handler(content, ctx)
|
||||
tool = "python" if handler.__qualname__.startswith("PythonTool") else "bash"
|
||||
from src.agent_runtime.resources import FilesystemRoot
|
||||
from src.agent_runtime.process_resources import seal_launch_scope
|
||||
owner = str(ctx.get("owner") or "").casefold()
|
||||
authority = RequestAuthority("producer-test", owner, str(ctx.get("session_id") or ""), str(workspace), (OperationGrant(tool),))
|
||||
authority = replace(authority, launch_scopes=(seal_launch_scope(NativeBackendResource(tool),
|
||||
FilesystemRoot.seal(workspace, owner=owner), env=ctx.get("subproc_env")),))
|
||||
with launch_authority(content, workspace, tool=tool, authority=authority):
|
||||
return await handler(content, ctx)
|
||||
return execute
|
||||
|
||||
|
||||
def install_native_authority(monkeypatch, workspace):
|
||||
from src.agent_tools import subprocess_tools
|
||||
from src import tool_execution
|
||||
from src.constants import DATA_DIR
|
||||
for cls in (subprocess_tools.BashTool, subprocess_tools.PythonTool):
|
||||
original = cls.execute
|
||||
async def execute(self, content, ctx, _original=original):
|
||||
selected = Path(tool_execution.agent_cwd())
|
||||
if selected == Path(DATA_DIR):
|
||||
selected = Path(workspace)
|
||||
return await authorized_handler(_original.__get__(self), selected)(content, ctx)
|
||||
monkeypatch.setattr(cls, "execute", execute)
|
||||
@@ -0,0 +1,162 @@
|
||||
// Behavioral browser checks for the accepted publication surface.
|
||||
const { chromium } = require('playwright');
|
||||
const { readFileSync } = require('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 mediaRequests = [];
|
||||
const errors = [];
|
||||
page.on('request', req => { if (/\.(webm|mp4)(?:$|\?)/.test(req.url())) mediaRequests.push(req.url()); });
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
await page.goto(origin + '/website/index.html');
|
||||
// Landing page contains expected preview panels, preview videos, and background video
|
||||
assert.equal(await page.locator('.preview-panel').count(), 8);
|
||||
assert.equal(await page.locator('section#previews video').count(), 7);
|
||||
assert.equal(await page.locator('section#how video.sec-bg').count(), 1);
|
||||
assert.equal(await page.locator('section#how .sec-bg-tint').count(), 1);
|
||||
|
||||
// Expected video source paths exist and return 200
|
||||
const expectedVideos = [
|
||||
'chat.webm',
|
||||
'research.webm',
|
||||
'compare.webm',
|
||||
'document.webm',
|
||||
'notes.webm',
|
||||
'gallery.webm',
|
||||
'theme.webm',
|
||||
'bg.webm',
|
||||
];
|
||||
const videoSources = await page.locator('video source').evaluateAll(els => els.map(s => s.getAttribute('src')));
|
||||
assert.deepEqual(videoSources.sort(), expectedVideos.slice().sort());
|
||||
for (const src of expectedVideos) {
|
||||
const videoRes = await page.request.get(origin + '/website/' + src);
|
||||
assert.equal(videoRes.status(), 200, `Video source ${src} should return 200`);
|
||||
}
|
||||
|
||||
// Feature and video layout does not overflow at desktop or mobile widths
|
||||
for (const width of [1280, 390]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
assert(await page.locator('.preview-panel').first().isVisible());
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
|
||||
// Manifest has valid icon entries that return successfully
|
||||
const manifest = await (await page.request.get(origin + '/static/manifest.json')).json();
|
||||
assert.equal(manifest.display, 'standalone');
|
||||
assert(Array.isArray(manifest.icons) && manifest.icons.length === 3);
|
||||
for (const icon of manifest.icons) {
|
||||
assert(icon.src, 'Manifest icon entry must have src');
|
||||
const iconUrl = new URL(icon.src, origin + '/static/manifest.json').href;
|
||||
const iconRes = await page.request.get(iconUrl);
|
||||
assert.equal(iconRes.status(), 200, `Manifest icon ${icon.src} should return 200`);
|
||||
}
|
||||
|
||||
// Apple touch icon references in app HTML point to real files
|
||||
for (const filename of ['index.html', 'login.html']) {
|
||||
const html = await (await page.request.get(origin + '/static/' + filename)).text();
|
||||
const match = html.match(/<link[^>]+rel=["']apple-touch-icon["'][^>]+href=["']([^"']+)["']/);
|
||||
assert(match, `missing apple-touch-icon in ${filename}`);
|
||||
const iconUrl = new URL(match[1], origin + '/').href;
|
||||
const res = await page.request.get(iconUrl);
|
||||
assert.equal(res.status(), 200, `Apple touch icon in ${filename} should return 200`);
|
||||
}
|
||||
|
||||
// A small host DOM runs the actual export function and markdown renderer.
|
||||
await page.route('**/publication-harness', route => route.fulfill({ contentType: 'text/html', body: '<!DOCTYPE html><body><textarea id="doc-editor-textarea"></textarea><select id="doc-language-select"><option value="markdown">Markdown</option><option value="text">Text</option><option value="html">HTML</option></select><div id="theme-grid"></div><select id="theme-font-select"><option value="mono">Fira Code</option><option value="sans">Sans</option></select><select id="theme-density-select"><option value="comfortable">Comfortable</option></select></body>' }));
|
||||
await page.route('**/api/**', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ fonts: {}, value: null }) }));
|
||||
await page.goto(origin + '/publication-harness');
|
||||
const source = readFileSync('static/js/document.js', 'utf8');
|
||||
const fn = source.match(/\n async function exportAsPdf\(\) \{(.*?)\n \}\n/s)[0];
|
||||
const result = await page.evaluate(async ({ origin, fn }) => {
|
||||
const module = await import(origin + '/static/js/markdown.js');
|
||||
const markdownModule = module.default || module;
|
||||
const _isRichTextLang = lang => lang === 'html';
|
||||
const _isDocxLang = () => false;
|
||||
const _richTextExportCss = () => 'p { color: black; }';
|
||||
const _getExportBaseName = () => 'Print test';
|
||||
const activeDocId = 'test';
|
||||
const errors = [];
|
||||
const uiModule = { showError: message => errors.push(message) };
|
||||
const originalRender = markdownModule.renderMath;
|
||||
const prints = [];
|
||||
markdownModule.renderMath = async container => {
|
||||
await originalRender(container);
|
||||
const frame = document.getElementById('doc-browser-print-frame');
|
||||
frame.contentWindow.print = () => {
|
||||
prints.push({ body: frame.contentDocument.body.innerHTML, title: frame.contentDocument.title, links: [...frame.contentDocument.querySelectorAll('link')].map(l => l.href) });
|
||||
frame.contentWindow.dispatchEvent(new Event('afterprint'));
|
||||
};
|
||||
};
|
||||
const run = eval('(' + fn.trim() + ')');
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
textarea.value = 'Formula $E = mc^2$ here.';
|
||||
await run();
|
||||
document.getElementById('doc-language-select').value = 'text';
|
||||
textarea.value = '<script>parent.unsafe = true</script>& literal';
|
||||
await run();
|
||||
document.getElementById('doc-language-select').value = 'html';
|
||||
textarea.value = '<p>Rich content</p><script>parent.unsafe = true</script><img src="data:,x" onerror="parent.unsafe = true">';
|
||||
await run();
|
||||
const oldPrint = window.print;
|
||||
window.print = undefined;
|
||||
await run();
|
||||
window.print = oldPrint;
|
||||
return { prints, errors, unsafe: !!window.unsafe, frameRemaining: !!document.getElementById('doc-browser-print-frame') };
|
||||
}, { origin, fn });
|
||||
assert.equal(result.prints.length, 3);
|
||||
assert(result.prints[0].body.includes('class="katex"'));
|
||||
assert(!result.prints[0].body.includes('ody-math-pending'));
|
||||
assert(result.prints[0].links.some(url => url.includes('/katex/')));
|
||||
assert.equal(result.prints[0].title, 'Print test');
|
||||
assert(result.prints[1].body.includes('<script>'));
|
||||
assert(result.prints[2].body.includes('Rich content'));
|
||||
assert.equal(result.unsafe, false);
|
||||
assert.equal(result.frameRemaining, false);
|
||||
assert.deepEqual(result.errors, ['Browser printing is unavailable.']);
|
||||
|
||||
// Actual theme module: default, legacy local/server selections and custom theme preferences.
|
||||
const fonts = await page.evaluate(async origin => {
|
||||
localStorage.setItem('odysseus-theme', JSON.stringify({ name: 'dark', colors: {}, font: 'gohu' }));
|
||||
localStorage.setItem('odysseus-custom-themes', JSON.stringify({ old: { font: 'GohuFont' } }));
|
||||
const theme = await import(origin + '/static/js/theme.js');
|
||||
theme.applyFontDensity(null, null);
|
||||
const fallback = document.documentElement.style.getPropertyValue('--font-family');
|
||||
theme.applyFontDensity('gohu', null);
|
||||
const legacy = document.documentElement.style.getPropertyValue('--font-family');
|
||||
theme.initThemeUI();
|
||||
const selected = document.getElementById('theme-font-select').value;
|
||||
const saved = theme.getSaved().font;
|
||||
const custom = theme.getCustomThemes().old.font;
|
||||
theme.applyFontDensity('sans', null);
|
||||
const sans = document.documentElement.style.getPropertyValue('--font-family');
|
||||
return { fallback, legacy, selected, saved, custom, sans };
|
||||
}, origin);
|
||||
assert.equal(fonts.fallback, "'Fira Code', monospace");
|
||||
assert.equal(fonts.legacy, fonts.fallback);
|
||||
assert.equal(fonts.selected, 'mono');
|
||||
assert.equal(fonts.saved, 'mono');
|
||||
assert.equal(fonts.custom, 'mono');
|
||||
assert(fonts.sans.includes('system-ui'));
|
||||
|
||||
// Verify the real early bootstrap handles persisted legacy preferences.
|
||||
const appHtml = readFileSync('static/index.html', 'utf8');
|
||||
const bootstrap = await page.evaluate(extractThemeBootstrap, appHtml);
|
||||
assert(bootstrap);
|
||||
const early = await page.evaluate(async code => {
|
||||
const theme = await import('/static/js/theme.js');
|
||||
localStorage.setItem('odysseus-theme', JSON.stringify({ name: 'dark', colors: theme.THEMES.dark, font: 'gohu' }));
|
||||
eval(code);
|
||||
return document.documentElement.style.getPropertyValue('--font-family');
|
||||
}, bootstrap);
|
||||
assert.equal(early, fonts.fallback);
|
||||
console.log(JSON.stringify({ website: true, fonts: true, print: true, pwa: true }));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { appCss } from './helpers/stylesheets.mjs';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
test('research primary actions retain shipped cascade sizing across mobile and desktop', async () => {
|
||||
const css = await appCss();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(`<div id="research-pane"><div id="research-past-list">
|
||||
<div class="research-job-card"><div class="research-job-header">
|
||||
<span class="research-job-query">A long research title that must still fit on a narrow phone</span>
|
||||
<button class="task-status-badge research-job-report-badge" title="Open visual report"><svg></svg><span class="task-state-label">Visual Report</span></button>
|
||||
<button class="task-status-badge research-job-discuss-badge" title="Discuss"><svg></svg><span class="task-state-label">Discuss</span></button>
|
||||
</div></div></div></div>`);
|
||||
await page.addStyleTag({ content: css });
|
||||
for (const width of [320, 390, 600, 1024]) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
const buttons = await page.locator('.research-job-header button').evaluateAll(nodes => nodes.map(node => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return { width: rect.width, height: rect.height,
|
||||
icon: node.querySelector('svg').getBoundingClientRect().width,
|
||||
labelHidden: getComputedStyle(node.querySelector('.task-state-label')).display === 'none' };
|
||||
}));
|
||||
for (const button of buttons) {
|
||||
if (width <= 600) {
|
||||
// Assert the complete shipped cascade, not the historical
|
||||
// style.css-only result. Later app styles keep the action labels
|
||||
// visible and use the larger mobile control geometry.
|
||||
assert.ok(button.width > 24);
|
||||
assert.equal(button.height, 28);
|
||||
assert.equal(button.icon, 13);
|
||||
assert.equal(button.labelHidden, false);
|
||||
} else {
|
||||
assert.equal(button.height, 20);
|
||||
assert.equal(button.icon, 10);
|
||||
assert.equal(button.labelHidden, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user