Squash Odysseus development history

This commit is contained in:
pewdiepie-archdaemon
2026-09-11 06:04:19 +00:00
parent e5c99a5eee
commit 6ee6502010
2050 changed files with 538359 additions and 57745 deletions
+202
View File
@@ -0,0 +1,202 @@
# Test Layout Inventory
## Purpose
Inventory for the first low-risk split of the flat `tests/` directory
(issue #3712, parent #2523). This document only records *what* should move
first and *why*; it moves nothing. The actual move is a separate, mechanical
PR that relocates the listed files verbatim and changes no test content.
The target layout and category definitions come from
[`TESTING_STANDARD.md`](./TESTING_STANDARD.md); the collection-time markers
come from [`_taxonomy.py`](./_taxonomy.py), which classifies by **filename
tokens only** (paths are ignored, except the `tests/helpers/` rule). A file
keeps its `area_*`/`sub_*` markers when moved into a subdirectory, and
`conftest.py` discovers marker names recursively (`rglob`), so a move does not
disturb marker registration or focused selection.
## Current low-risk candidate groups
Groups whose tests need no route/app setup and no real DB/session setup:
1. **CLI / script tests** (`area_cli`, 28 files) - load `scripts/` entry
points via `tests.helpers.cli_loader.load_script`; DB access is stubbed
with `tests.helpers.db_stubs` (`SessionLocal` is a plain stub attribute).
No `TestClient`, no FastAPI app import, no SQLite files.
2. **Helper self-tests** (`area_helpers`) - e.g. `test_helpers_import_state.py`,
`test_db_stubs_helper.py`. Safe but tiny (two files), and they test the
shared helpers from the #3685 audit (merged) that the rest of the suite
depends on; little payoff as a first slice.
3. **Pure unit / parsing tests** (`area_unit`) - `*_nonstring.py`,
`*_nondict.py`, parsing tests. Large and heterogeneous; some touch
provider/session modules, so the boundary is less crisp.
4. **Static checks** - e.g. `test_readme_ascii_fenced.py`,
`test_docs_no_orphan_images.py`. Safe but tiny and `uncategorized` in the
taxonomy, so a move buys little and matches no existing marker.
Not candidates for the first move (per #3712 guidance): security/owner-scope
tests, route/API tests, DB/session-heavy tests, auth/session concurrency
tests, and the taxonomy/runner infrastructure tests that changed recently
(#3491, #3556, #3659, #3711).
## Recommended first move
**CLI / script tests → `tests/cli/`**
Why this group over the alternatives:
- Lowest coupling: every file imports only the script under test (via
`cli_loader`) plus `tests.helpers` stubs - no app, no routes, no real DB.
- Crisp, machine-checkable boundary: the set is exactly the files classified
`area_cli` by `_taxonomy.py`, so before/after selection counts can be
compared mechanically.
- Already the planned target dir for this category in `TESTING_STANDARD.md`
(`tests/cli/`).
- Absolute imports (`from tests.helpers...`) and unique basenames mean no
import-order or module-name collisions after the move.
- Lower risk than helper self-tests (tiny group, little payoff), unit tests
(fuzzy boundary), or anything security/route/session-shaped.
## Files included in the first move
The 28 files classified `area_cli` (verified against `_taxonomy.py`):
Note: this inventory was refreshed against current `dev` after `tests/test_research_cli_status.py` was added to the `area_cli` set.
- `tests/test_calendar_cli_name.py`
- `tests/test_contacts_cli_rows.py`
- `tests/test_cookbook_cli_state.py`
- `tests/test_docs_cli_content_length.py`
- `tests/test_gallery_cli_album_count.py`
- `tests/test_gallery_cli_preview.py`
- `tests/test_logs_cli_resolve_nonstring.py`
- `tests/test_mail_cli_read_empty_fetch.py`
- `tests/test_mail_cli_recipients.py`
- `tests/test_mcp_cli_env_serialize.py`
- `tests/test_mcp_cli_json.py`
- `tests/test_memory_cli_rows.py`
- `tests/test_notes_cli_items.py`
- `tests/test_personal_cli_rows.py`
- `tests/test_preset_cli_invalid_entries.py`
- `tests/test_preset_cli_set_corrupt_entry.py`
- `tests/test_preset_cli_store.py`
- `tests/test_research_cli_preview.py`
- `tests/test_research_cli_status_filter.py`
- `tests/test_research_cli_status.py`
- `tests/test_research_cli_store.py`
- `tests/test_sessions_cli.py`
- `tests/test_signature_cli_export.py`
- `tests/test_skills_cli_preview.py`
- `tests/test_skills_cli_rows.py`
- `tests/test_tasks_cli_preview.py`
- `tests/test_theme_cli_store.py`
- `tests/test_webhook_cli_mask.py`
## Files intentionally excluded
- `tests/test_backup_cli_security.py` - classifies as `area_security`
(security outranks cli in the taxonomy); moving it into `tests/cli/` would
make the directory disagree with its marker. It belongs with the security
group in a later phase.
- `tests/test_run_focus.py`, `tests/test_taxonomy.py` - taxonomy/runner
infrastructure tests, recently changed (#3556, #3659); they also pin
flat-layout paths (e.g. `tests/test_auth_config_lock_concurrency.py` in
`test_run_focus.py`), so they stay put.
- Script-like but `uncategorized` files - `test_pr_blocker_audit.py`,
`test_update_database_script.py`, `test_windows_update_script.py`,
`test_setup_admin_user.py`, `test_amd_gpu_check_args.py`, `test_hwfit_*.py`.
They exercise `scripts/` too, but moving them would make `tests/cli/`
diverge from the `area_cli` marker set. Reclassify or move them in a later,
separate slice.
- Everything else (security, routes, services, unit, js, helpers) - out of
scope for the first move by design.
## How this was verified
Read-only checks, run from the repo root on this branch. Note the real API is
`classify_test_path` (there is no `classify_test_file`).
```bash
# Compute the area_cli set and confirm test_backup_cli_security.py is
# area_security. Expected: 28 files, then "security".
./venv/bin/python - <<'PY'
from pathlib import Path
from tests._taxonomy import classify_test_path
cli = [p for p in sorted(Path("tests").glob("test_*.py"))
if classify_test_path(p).area == "cli"]
print(len(cli))
for p in cli:
print(p)
print(classify_test_path("tests/test_backup_cli_security.py").area)
PY
# Coupling check across the CLI files. Expected: the only hits are
# "SessionLocal" as stub attribute names passed to tests.helpers.db_stubs;
# no TestClient, FastAPI, create_app, sqlite, or dependency_overrides.
rg -n "TestClient|FastAPI|create_app|SessionLocal|sqlite|dependency_overrides" \
tests/test_*cli*.py tests/test_sessions_cli.py
# Hard-coded flat paths to the exact CLI files outside tests/. Expected: no matches.
./venv/bin/python - <<'PY2' > /tmp/area_cli_paths.txt
from pathlib import Path
from tests._taxonomy import classify_test_path
for path in sorted(Path("tests").glob("test_*.py")):
if classify_test_path(path).area == "cli":
print(path)
PY2
rg -n -F -f /tmp/area_cli_paths.txt .github scripts docs \
tests/README.md tests/TESTING_STANDARD.md pyproject.toml 2>/dev/null || true
```
Also checked by reading the code: `tests/conftest.py` registers sub-markers
from a recursive `rglob` scan, and `tests/_taxonomy.py` classifies by filename
tokens only (plus the `tests/helpers/` directory rule), so the markers of the
28 files do not change when they move into `tests/cli/`.
## Validation for the future move PR
Run with the project venv (`./venv/bin/python`); system `python3` may miss
pinned deps. Before the move, record the baseline; after, compare:
```bash
# Selection must match the 28 files before and after the move.
./venv/bin/python tests/run_focus.py --dry-run --area cli
./venv/bin/python -m pytest -m area_cli -q
# Moved files pass when targeted directly.
./venv/bin/python -m pytest tests/cli/ -q
# Whole-suite collection still succeeds (catches import/path breakage).
./venv/bin/python -m pytest --collect-only -q
# Taxonomy/runner infrastructure is unaffected.
./venv/bin/python -m pytest tests/test_taxonomy.py tests/test_run_focus.py -q
# No stale flat-path references to the moved files. Expected: no matches
# outside tests/cli/ itself.
./venv/bin/python - <<'PY2' > /tmp/area_cli_paths.txt
from pathlib import Path
from tests._taxonomy import classify_test_path
for path in sorted(Path("tests").glob("test_*.py")):
if classify_test_path(path).area == "cli":
print(path)
PY2
rg -n -F -f /tmp/area_cli_paths.txt .github scripts docs \
tests/README.md tests/TESTING_STANDARD.md pyproject.toml 2>/dev/null || true
```
Pass criteria: identical test counts for `-m area_cli` before/after, zero
collection errors, and no changes outside the moved files.
## Non-goals
- No file moves, renames, or deletions in this PR.
- No changes to `conftest.py`, `_taxonomy.py`, `run_focus.py`, helpers,
markers, CI workflows, or production code.
- No recommendation to split the whole suite at once; later groups get their
own inventory-then-move slices.
+326
View File
@@ -0,0 +1,326 @@
# Oversized Test File Split Plan
## Purpose
This document plans future oversized test-file splits using current repo data.
It does not move files, rewrite assertions, extract helpers, or change CI.
## Roadmap context
- Issue: #3983
- Parent tracker: #2523
- Follows #3973 / #3982, the report-only order-sensitivity diagnostics slice.
## Methodology
Metrics were generated from the current test tree using:
- physical line counts for every recursive `test_*.py` file under `tests/`;
- AST counts for `test_*` functions and `Test*` classes;
- one `pytest --collect-only -q tests` run to count collected items per file;
- current taxonomy classification from `tests._taxonomy.classify_test_path`; and
- static setup-signal scans for route/API, DB/session, import-state, security, filesystem, subprocess/script, async/threading, and UI/static indicators.
Static signals are not proof of risk. They are review prompts.
Future split PRs must still inspect each file manually before editing.
## Current summary
- test files scanned: 583
- collected pytest items counted: 3586
- large-file threshold: 300 lines
- large-collected threshold: 20 collected items
Area distribution:
| Value | Files |
|---|---:|
| cli | 28 |
| helpers | 1 |
| js | 39 |
| routes | 23 |
| security | 77 |
| services | 144 |
| uncategorized | 234 |
| unit | 37 |
Sub-area distribution:
| Value | Files |
|---|---:|
| api | 6 |
| atomic | 3 |
| auth | 9 |
| calendar | 10 |
| cli | 28 |
| confinement | 7 |
| cookbook | 13 |
| document | 11 |
| email | 12 |
| embedding | 3 |
| gallery | 5 |
| history | 3 |
| js | 39 |
| llm | 16 |
| mcp | 8 |
| memory | 15 |
| nondict | 7 |
| nonstring | 22 |
| owner | 14 |
| owner_scope | 23 |
| parse | 4 |
| provider | 6 |
| research | 16 |
| route | 6 |
| routes | 9 |
| scheduler | 3 |
| scope | 5 |
| security | 9 |
| session | 16 |
| ssrf | 3 |
| webhook | 3 |
| xss | 5 |
Values below 2 files: 244 values covering 244 files.
## Top files by collected pytest items
| File | Lines | Collected tests | Test defs | Test classes | Area | Sub-area | Signals |
|---|---:|---:|---:|---:|---|---|---|
| `tests/test_model_routes.py` | 1778 | 139 | 116 | 10 | routes | routes | route/api, db/session, import-state, async/threading |
| `tests/test_security_regressions.py` | 1224 | 92 | 68 | 0 | security | security | route/api, db/session, import-state, security, filesystem, async/threading, ui/static |
| `tests/test_provider_classification.py` | 188 | 67 | 21 | 4 | services | provider | - |
| `tests/test_cookbook_helpers.py` | 912 | 65 | 65 | 0 | services | cookbook | route/api, filesystem, subprocess/script, async/threading, ui/static |
| `tests/test_shell_routes.py` | 481 | 63 | 48 | 8 | routes | routes | route/api, import-state, filesystem |
| `tests/test_pr_blocker_audit.py` | 964 | 58 | 58 | 0 | uncategorized | pr_blocker_audit | import-state, security, filesystem |
| `tests/test_provider_endpoints.py` | 241 | 58 | 18 | 1 | services | provider | subprocess/script |
| `tests/test_agent_loop.py` | 469 | 52 | 52 | 5 | uncategorized | agent_loop | db/session, import-state |
| `tests/test_service_health.py` | 472 | 47 | 42 | 0 | uncategorized | service_health | async/threading |
| `tests/test_run_focus.py` | 399 | 47 | 44 | 0 | uncategorized | run_focus | security, filesystem, subprocess/script, ui/static |
| `tests/test_llm_core_temperature.py` | 196 | 41 | 17 | 0 | services | llm | - |
| `tests/test_endpoint_probing.py` | 411 | 34 | 30 | 6 | uncategorized | endpoint_probing | route/api, db/session, import-state |
| `tests/test_llm_core_anthropic_temp_omit.py` | 94 | 32 | 6 | 0 | services | llm | db/session |
| `tests/test_chat_helpers.py` | 264 | 31 | 18 | 0 | uncategorized | chat_helpers | route/api |
| `tests/test_provider_detection.py` | 148 | 31 | 31 | 5 | services | provider | - |
| `tests/test_model_context.py` | 251 | 30 | 30 | 4 | uncategorized | model_context | db/session, import-state |
| `tests/test_endpoint_resolver.py` | 148 | 30 | 30 | 6 | uncategorized | endpoint_resolver | - |
| `tests/test_embedding_lanes.py` | 1104 | 29 | 29 | 0 | services | embedding | filesystem |
| `tests/test_upload_limits_centralized.py` | 110 | 29 | 5 | 0 | uncategorized | upload_limits_centralized | import-state, filesystem |
| `tests/test_email_oauth.py` | 580 | 28 | 25 | 0 | services | email | route/api, db/session, security, async/threading |
| `tests/test_review_regressions.py` | 930 | 26 | 26 | 0 | uncategorized | review_regressions | route/api, db/session, import-state, filesystem, async/threading |
| `tests/test_rename_user_owner_sync.py` | 686 | 26 | 26 | 0 | security | owner | route/api, db/session, import-state, filesystem, async/threading |
| `tests/test_helpers_import_state.py` | 426 | 26 | 26 | 0 | helpers | helpers | route/api, db/session, import-state |
| `tests/test_taxonomy.py` | 145 | 26 | 16 | 0 | uncategorized | taxonomy | security, ui/static |
| `tests/test_tool_path_confinement.py` | 282 | 24 | 24 | 0 | security | confinement | import-state, filesystem, async/threading |
| `tests/test_copilot.py` | 170 | 23 | 16 | 0 | uncategorized | copilot | - |
| `tests/test_research_utils.py` | 97 | 23 | 23 | 2 | services | research | - |
| `tests/test_api_chat_security.py` | 401 | 22 | 8 | 0 | security | security | route/api, db/session, import-state, filesystem, async/threading |
| `tests/test_tool_support_heuristic.py` | 166 | 22 | 22 | 3 | uncategorized | tool_support_heuristic | - |
| `tests/test_platform_compat.py` | 318 | 21 | 21 | 0 | uncategorized | platform_compat | import-state, filesystem, subprocess/script |
## Top files by physical line count
| File | Lines | Collected tests | Test defs | Test classes | Area | Sub-area | Signals |
|---|---:|---:|---:|---:|---|---|---|
| `tests/test_model_routes.py` | 1778 | 139 | 116 | 10 | routes | routes | route/api, db/session, import-state, async/threading |
| `tests/test_security_regressions.py` | 1224 | 92 | 68 | 0 | security | security | route/api, db/session, import-state, security, filesystem, async/threading, ui/static |
| `tests/test_embedding_lanes.py` | 1104 | 29 | 29 | 0 | services | embedding | filesystem |
| `tests/test_pr_blocker_audit.py` | 964 | 58 | 58 | 0 | uncategorized | pr_blocker_audit | import-state, security, filesystem |
| `tests/test_review_regressions.py` | 930 | 26 | 26 | 0 | uncategorized | review_regressions | route/api, db/session, import-state, filesystem, async/threading |
| `tests/test_cookbook_helpers.py` | 912 | 65 | 65 | 0 | services | cookbook | route/api, filesystem, subprocess/script, async/threading, ui/static |
| `tests/test_rename_user_owner_sync.py` | 686 | 26 | 26 | 0 | security | owner | route/api, db/session, import-state, filesystem, async/threading |
| `tests/test_email_oauth.py` | 580 | 28 | 25 | 0 | services | email | route/api, db/session, security, async/threading |
| `tests/test_api_token_routes.py` | 578 | 17 | 17 | 0 | routes | api_routes | route/api, db/session, import-state, async/threading |
| `tests/test_shell_routes.py` | 481 | 63 | 48 | 8 | routes | routes | route/api, import-state, filesystem |
| `tests/test_email_owner_scope.py` | 474 | 9 | 9 | 0 | security | owner_scope | route/api, db/session, filesystem, async/threading |
| `tests/test_service_health.py` | 472 | 47 | 42 | 0 | uncategorized | service_health | async/threading |
| `tests/test_agent_loop.py` | 469 | 52 | 52 | 5 | uncategorized | agent_loop | db/session, import-state |
| `tests/test_kv_cache_invalidation_2927.py` | 463 | 8 | 8 | 0 | uncategorized | kv_cache_invalidation_2927 | route/api, db/session, import-state, async/threading |
| `tests/test_helpers_import_state.py` | 426 | 26 | 26 | 0 | helpers | helpers | route/api, db/session, import-state |
| `tests/test_endpoint_owner_scope_followup.py` | 414 | 11 | 11 | 0 | security | owner_scope | route/api, db/session, filesystem |
| `tests/test_endpoint_probing.py` | 411 | 34 | 30 | 6 | uncategorized | endpoint_probing | route/api, db/session, import-state |
| `tests/test_imap_leak_fixes.py` | 404 | 15 | 15 | 0 | uncategorized | imap_leak_fixes | route/api, db/session, security, filesystem |
| `tests/test_companion_readonly.py` | 402 | 17 | 17 | 0 | uncategorized | companion_readonly | db/session, import-state |
| `tests/test_api_chat_security.py` | 401 | 22 | 8 | 0 | security | security | route/api, db/session, import-state, filesystem, async/threading |
| `tests/test_upload_handler_atomicity.py` | 401 | 9 | 9 | 0 | uncategorized | upload_handler_atomicity | filesystem, async/threading |
| `tests/test_run_focus.py` | 399 | 47 | 44 | 0 | uncategorized | run_focus | security, filesystem, subprocess/script, ui/static |
| `tests/test_auth_regressions.py` | 375 | 15 | 15 | 0 | security | auth | route/api, db/session, import-state, async/threading |
| `tests/test_calendar_owner_scope.py` | 345 | 7 | 7 | 0 | security | owner_scope | route/api, db/session, import-state, filesystem, async/threading, ui/static |
| `tests/test_null_owner_gates.py` | 342 | 20 | 20 | 0 | security | owner | route/api, db/session, import-state |
| `tests/test_agent_migration_manifest.py` | 340 | 15 | 15 | 0 | uncategorized | agent_migration_manifest | import-state, filesystem |
| `tests/test_calendar_recurrence.py` | 338 | 19 | 19 | 0 | services | calendar | - |
| `tests/test_tool_policy.py` | 330 | 13 | 13 | 0 | uncategorized | tool_policy | import-state, async/threading |
| `tests/test_workspace_confine.py` | 328 | 18 | 18 | 0 | uncategorized | workspace_confine | route/api, filesystem, subprocess/script, async/threading |
| `tests/test_diffusion_server_security.py` | 325 | 14 | 14 | 0 | security | security | route/api, import-state, security, filesystem, async/threading, ui/static |
## Split planning candidates
This section is generated from metrics, not from manual judgement.
Files are included when they meet at least one threshold:
- at least 300 physical lines; or
- at least 20 collected pytest items.
These are planning candidates only. A later split PR still needs a focused manual review of each file before moving tests.
| File | Why included | Setup/risk signals | Suggested handling |
|---|---|---|---|
| `tests/test_model_routes.py` | 1778 lines, 139 collected tests | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_security_regressions.py` | 1224 lines, 92 collected tests | route/api, db/session, import-state, security, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_provider_classification.py` | 67 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
| `tests/test_cookbook_helpers.py` | 912 lines, 65 collected tests | route/api, filesystem, subprocess/script, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_shell_routes.py` | 481 lines, 63 collected tests | route/api, import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_pr_blocker_audit.py` | 964 lines, 58 collected tests | import-state, security, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_provider_endpoints.py` | 58 collected tests | subprocess/script | Good first manual-review candidate if test themes are cohesive. |
| `tests/test_agent_loop.py` | 469 lines, 52 collected tests | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_service_health.py` | 472 lines, 47 collected tests | async/threading | Good first manual-review candidate if test themes are cohesive. |
| `tests/test_run_focus.py` | 399 lines, 47 collected tests | security, filesystem, subprocess/script, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_llm_core_temperature.py` | 41 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
| `tests/test_endpoint_probing.py` | 411 lines, 34 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_llm_core_anthropic_temp_omit.py` | 32 collected tests | db/session | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_chat_helpers.py` | 31 collected tests | route/api | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_provider_detection.py` | 31 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
| `tests/test_model_context.py` | 30 collected tests | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_endpoint_resolver.py` | 30 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
| `tests/test_embedding_lanes.py` | 1104 lines, 29 collected tests | filesystem | Good first manual-review candidate if test themes are cohesive. |
| `tests/test_upload_limits_centralized.py` | 29 collected tests | import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_email_oauth.py` | 580 lines, 28 collected tests | route/api, db/session, security, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_review_regressions.py` | 930 lines, 26 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_rename_user_owner_sync.py` | 686 lines, 26 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_helpers_import_state.py` | 426 lines, 26 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_taxonomy.py` | 26 collected tests | security, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_tool_path_confinement.py` | 24 collected tests | import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_copilot.py` | 23 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
| `tests/test_research_utils.py` | 23 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
| `tests/test_api_chat_security.py` | 401 lines, 22 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_tool_support_heuristic.py` | 22 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
| `tests/test_platform_compat.py` | 318 lines, 21 collected tests | import-state, filesystem, subprocess/script | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_context_compactor.py` | 21 collected tests | db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_prompt_security.py` | 21 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
| `tests/test_null_owner_gates.py` | 342 lines, 20 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_youtube_handler_consolidation.py` | 20 collected tests | route/api, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_calendar_recurrence.py` | 338 lines | No obvious setup signals from static scan. | Plan split boundaries before editing. |
| `tests/test_workspace_confine.py` | 328 lines | route/api, filesystem, subprocess/script, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_api_token_routes.py` | 578 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_companion_readonly.py` | 402 lines | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_set_admin.py` | 317 lines | route/api, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_imap_leak_fixes.py` | 404 lines | route/api, db/session, security, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_auth_regressions.py` | 375 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_agent_migration_manifest.py` | 340 lines | import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_diffusion_server_security.py` | 325 lines | route/api, import-state, security, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_tool_policy.py` | 330 lines | import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_endpoint_owner_scope_followup.py` | 414 lines | route/api, db/session, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_upload_routes_owner_scope.py` | 315 lines | route/api, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_email_owner_scope.py` | 474 lines | route/api, db/session, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_upload_handler_atomicity.py` | 401 lines | filesystem, async/threading | Plan split boundaries before editing. |
| `tests/test_kv_cache_invalidation_2927.py` | 463 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_calendar_owner_scope.py` | 345 lines | route/api, db/session, import-state, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
| `tests/test_skills_manager_owner_isolation.py` | 306 lines | import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
## Taxonomy coverage gaps among split candidates
`uncategorized` is a current taxonomy area, not a builder failure.
This plan does not reclassify tests because taxonomy changes should be reviewed separately from oversized-file split planning.
Before using any of these files as a split target, first decide whether the taxonomy should be refined in a separate focused issue/PR.
| File | Lines | Collected tests | Sub-area | Signals | Suggested follow-up |
|---|---:|---:|---|---|---|
| `tests/test_pr_blocker_audit.py` | 964 | 58 | pr_blocker_audit | import-state, security, filesystem | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_agent_loop.py` | 469 | 52 | agent_loop | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_service_health.py` | 472 | 47 | service_health | async/threading | Review taxonomy mapping before using as a split target. |
| `tests/test_run_focus.py` | 399 | 47 | run_focus | security, filesystem, subprocess/script, ui/static | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_endpoint_probing.py` | 411 | 34 | endpoint_probing | route/api, db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_chat_helpers.py` | 264 | 31 | chat_helpers | route/api | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_model_context.py` | 251 | 30 | model_context | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_endpoint_resolver.py` | 148 | 30 | endpoint_resolver | - | Review taxonomy mapping before using as a split target. |
| `tests/test_upload_limits_centralized.py` | 110 | 29 | upload_limits_centralized | import-state, filesystem | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_review_regressions.py` | 930 | 26 | review_regressions | route/api, db/session, import-state, filesystem, async/threading | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_taxonomy.py` | 145 | 26 | taxonomy | security, ui/static | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_copilot.py` | 170 | 23 | copilot | - | Review taxonomy mapping before using as a split target. |
| `tests/test_tool_support_heuristic.py` | 166 | 22 | tool_support_heuristic | - | Review taxonomy mapping before using as a split target. |
| `tests/test_platform_compat.py` | 318 | 21 | platform_compat | import-state, filesystem, subprocess/script | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_context_compactor.py` | 233 | 21 | context_compactor | db/session, import-state, async/threading | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_youtube_handler_consolidation.py` | 104 | 20 | youtube_handler_consolidation | route/api, import-state | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_workspace_confine.py` | 328 | 18 | workspace_confine | route/api, filesystem, subprocess/script, async/threading | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_companion_readonly.py` | 402 | 17 | companion_readonly | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_set_admin.py` | 317 | 17 | set_admin | route/api, import-state, filesystem, async/threading | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_imap_leak_fixes.py` | 404 | 15 | imap_leak_fixes | route/api, db/session, security, filesystem | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_agent_migration_manifest.py` | 340 | 15 | agent_migration_manifest | import-state, filesystem | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_tool_policy.py` | 330 | 13 | tool_policy | import-state, async/threading | Review taxonomy and setup/risk boundaries before any split. |
| `tests/test_upload_handler_atomicity.py` | 401 | 9 | upload_handler_atomicity | filesystem, async/threading | Review taxonomy mapping before using as a split target. |
| `tests/test_kv_cache_invalidation_2927.py` | 463 | 8 | kv_cache_invalidation_2927 | route/api, db/session, import-state, async/threading | Review taxonomy and setup/risk boundaries before any split. |
## Suggested first manual-review candidates
These are not automatic split approvals. They are categorized candidates with enough size/collection value and no route/API, DB/session, import-state, or security signal from the static scan.
Files still in the `uncategorized` taxonomy area are listed separately below so taxonomy review does not get mixed into the first split decision.
| File | Lines | Collected tests | Area | Sub-area | Signals | Why this is a candidate |
|---|---:|---:|---|---|---|---|
| `tests/test_provider_classification.py` | 188 | 67 | services | provider | - | 67 collected tests |
| `tests/test_provider_endpoints.py` | 241 | 58 | services | provider | subprocess/script | 58 collected tests |
| `tests/test_llm_core_temperature.py` | 196 | 41 | services | llm | - | 41 collected tests |
| `tests/test_provider_detection.py` | 148 | 31 | services | provider | - | 31 collected tests |
| `tests/test_embedding_lanes.py` | 1104 | 29 | services | embedding | filesystem | 1104 lines, 29 collected tests |
| `tests/test_research_utils.py` | 97 | 23 | services | research | - | 23 collected tests |
| `tests/test_prompt_security.py` | 203 | 21 | security | security | - | 21 collected tests |
| `tests/test_calendar_recurrence.py` | 338 | 19 | services | calendar | - | 338 lines |
## High-risk candidates to defer first
These files may still be split later, but not as the first implementation slice without a separate manual boundary review.
| File | Lines | Collected tests | High-risk signals |
|---|---:|---:|---|
| `tests/test_model_routes.py` | 1778 | 139 | db/session, import-state, route/api |
| `tests/test_security_regressions.py` | 1224 | 92 | db/session, import-state, route/api, security |
| `tests/test_cookbook_helpers.py` | 912 | 65 | route/api |
| `tests/test_shell_routes.py` | 481 | 63 | import-state, route/api |
| `tests/test_pr_blocker_audit.py` | 964 | 58 | import-state, security |
| `tests/test_agent_loop.py` | 469 | 52 | db/session, import-state |
| `tests/test_run_focus.py` | 399 | 47 | security |
| `tests/test_endpoint_probing.py` | 411 | 34 | db/session, import-state, route/api |
| `tests/test_llm_core_anthropic_temp_omit.py` | 94 | 32 | db/session |
| `tests/test_chat_helpers.py` | 264 | 31 | route/api |
| `tests/test_model_context.py` | 251 | 30 | db/session, import-state |
| `tests/test_upload_limits_centralized.py` | 110 | 29 | import-state |
| `tests/test_email_oauth.py` | 580 | 28 | db/session, route/api, security |
| `tests/test_review_regressions.py` | 930 | 26 | db/session, import-state, route/api |
| `tests/test_rename_user_owner_sync.py` | 686 | 26 | db/session, import-state, route/api |
## Rules for future split PRs
- One file or one coherent file-family per PR.
- No assertion rewrites mixed with file moves.
- No helper extraction mixed with file moves.
- No production code changes.
- No CI workflow changes.
- Preserve existing markers and taxonomy unless the split issue explicitly says otherwise.
- Validate the original file's collected tests before and after the split.
- Validate any neighboring taxonomy/focused-runner behavior if paths change.
- Treat files with route/API, DB/session, import-state, or security signals as higher-risk until manually reviewed.
## Suggested next step
Use this plan to choose the first actual oversized-file split issue.
The first split should prefer a file with high review value and low setup risk.
Do not start a split PR from this planning issue alone if the file's boundaries are still ambiguous.
## Reproduction command
This document was generated with:
```bash
.venv/bin/python tests/tools/build_oversized_test_split_plan.py
```
## Freshness check
After editing the builder or rebasing the branch, regenerate the plan and confirm no unexpected plan drift:
```bash
.venv/bin/python tests/tools/build_oversized_test_split_plan.py
git diff --exit-code -- tests/OVERSIZED_TEST_SPLIT_PLAN.md
```
+256
View File
@@ -0,0 +1,256 @@
# Test Suite Notes
## Purpose
This file documents the shared test helpers and the review expectations that go
with them. The suite is being refactored incrementally, so this is a working
reference for that effort - not a claim that the suite is already fully
organized. Read it before adding a new helper or before reviewing a PR that
touches `tests/helpers/`.
For the broader rules - test taxonomy, determinism/isolation rules, the
behavioral-vs-source-text policy, and helper/factory extraction rules - see
[`TESTING_STANDARD.md`](./TESTING_STANDARD.md). This file is the concrete helper
reference; that file is the standard the refactor works toward.
## Running focused subsets (taxonomy markers)
`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
markers only - it moves no files and changes no test behavior. Use them to run a
focused slice:
```bash
./venv/bin/python -m pytest -m area_security
./venv/bin/python -m pytest -m "area_services and sub_cookbook"
```
Areas are `security`, `routes`, `services`, `cli`, `js`, `helpers`, `unit`, and
`uncategorized`. Classification is conservative and token-based: a file that
matches no area keyword falls back to `area_uncategorized` with its filename as
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.
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 `--`:
```bash
./venv/bin/python tests/run_focus.py --area security
./venv/bin/python tests/run_focus.py --area services --sub-area cookbook
./venv/bin/python tests/run_focus.py --sub-area sub_cookbook
./venv/bin/python tests/run_focus.py --keyword taxonomy
./venv/bin/python tests/run_focus.py --last-failed
./venv/bin/python tests/run_focus.py --dry-run --area services --sub-area cookbook
./venv/bin/python tests/run_focus.py --area services -- --maxfail=1 -q
```
### Fast lane and duration visibility
`--fast` runs the fast lane: the tests that are *not* marked `slow` (it adds the
marker expression `not slow`). It composes with `--area`/`--sub-area` using
`and`. Because no tests may be marked `slow` yet, `--fast` can initially match
the full focused selection; it becomes a real speed-up as `slow` marks are added
from duration evidence. Use it for quick local or reviewer feedback; it does not
replace broader focused or full-suite validation before merge.
`--durations N` and `--durations-min FLOAT` add pytest's slowest-test reporting
so you can see where time goes. They are reporting only and do not count as a
focus selector, so `--durations` must be combined with a real selector
(`--area`, `--sub-area`, `--keyword`, `--last-failed`, or `--fast`).
Use the project Python environment before running these commands. The examples
use the repo's documented `./venv/bin/python` path so they do not accidentally
fall back to system Python.
```bash
./venv/bin/python tests/run_focus.py --fast
./venv/bin/python tests/run_focus.py --area services --fast
./venv/bin/python tests/run_focus.py --area services --durations 25
./venv/bin/python tests/run_focus.py --area services --fast --durations 25 --durations-min 0.05
```
The `slow` marker is opt-in. Mark a test `slow` only with duration evidence
(from `--durations`), not by guessing - see the fast-lane policy in
`TESTING_STANDARD.md`. `--fast` is for quick reviewer feedback and must not
replace the full suite before merge. A `slow` mark only excludes a test from the
fast lane; the test stays runnable directly, e.g.:
```bash
./venv/bin/python -m pytest tests/test_auth_config_lock_concurrency.py
./venv/bin/python -m pytest -m slow
```
## Order-sensitivity reporting (report-only)
`tests/run_order_report.py` runs pytest with the collected test items shuffled
by a seeded RNG, to surface order-sensitive tests (hidden coupling through
shared import state, module caches, databases, etc.). It is report-only: it is
not wired into CI, adds no gate, and changes no normal pytest collection or
ordering - the shuffle exists only inside this runner. The seed is always
printed, and pytest targets/options go after a literal `--`:
```bash
./venv/bin/python tests/run_order_report.py --seed 123 -- tests/cli/ -q
./venv/bin/python tests/run_order_report.py -- tests/cli/ -q # generates and prints a seed
```
The same seed reproduces the same order when the reported working directory,
pytest target arguments, and test environment are also the same. The runner
prints all command arguments with shell-safe POSIX quoting and uses the
invoking Python interpreter.
A generated-seed run starts with output like:
```text
[order-report] working directory: /path/to/odysseus
[order-report] shuffling test order with seed 284734921
[order-report] reproduce from this working directory with the same test environment:
[order-report] reproduce with: /path/to/odysseus/venv/bin/python /path/to/odysseus/tests/run_order_report.py --seed 284734921 -- tests/cli/ -q
```
Run the printed command from the reported working directory to reproduce the
same fixed-seed order:
```text
[order-report] working directory: /path/to/odysseus
[order-report] shuffling test order with seed 284734921
[order-report] reproduce from this working directory with the same test environment:
[order-report] reproduce with: /path/to/odysseus/venv/bin/python /path/to/odysseus/tests/run_order_report.py --seed 284734921 -- tests/cli/ -q
```
Pytest output remains visible between the report header and footer. A failing
run ends with pytest's normal failure report followed by:
```text
FAILED tests/example_test.py::test_example - AssertionError
[order-report] seed 284734921: pytest exit code 1 (report-only; fix order-sensitive failures in separate scoped PRs)
```
Failures discovered this way are real isolation bugs: fix them in separate
scoped PRs - do not silence them with `skip`/`xfail`, and do not "fix" them by
depending on a particular order.
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.
## Core principles
- Keep PRs small and homogeneous: one kind of change per PR.
- Prefer explicit local setup over hidden global fixtures.
- Avoid expanding the root `conftest.py` unless absolutely necessary.
- Do not mix file moves with logic changes in the same PR.
- Do not weaken tests with `skip`/`xfail` just to make CI pass.
- Validate the focused files you changed, plus any neighboring or
order-sensitive groups they interact with.
## Helper conventions
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.cli_loader.load_script`
Use when a test needs to import a script under `scripts/` without repeating
`SourceFileLoader` / `importlib.util` boilerplate.
- Intended for script/CLI tests that load a single file from `scripts/`.
- Not for arbitrary package imports - use a normal `import` for those.
- When migrating an existing test to it, keep the existing stubs and assertions
unchanged. Any `sys.modules` stubs the script needs at import time must still
be injected (e.g. via `monkeypatch`) before calling `load_script`.
### `tests.helpers.import_state.clear_module`
Use when a test must drop one cached module and its parent-package attribute
before a fresh import.
- Clears `sys.modules[name]`.
- Clears the parent-package attribute when present.
- Good replacement for local `sys.modules.pop(...)` + `delattr(parent, child)`
blocks.
### `tests.helpers.import_state.preserve_import_state`
Use when a test temporarily installs stubs into `sys.modules` and needs
deterministic cleanup afterward.
- Context manager: restores both `sys.modules` entries and parent-package
attributes on exit (normal or exception).
- Useful around module-level stubs or temporary imports.
- Prefer narrow, explicit module names over broad ones.
### `tests.helpers.import_state.clear_fake_database_modules`
Use only for the guarded fake/stub database cleanup pattern.
- Preserves a real-looking `core.database` (one with a string `__file__`).
- Removes a fake/stub `core.database` and the related `src.database` state.
- Do not use as a general database reset fixture.
### `tests.helpers.import_state.clear_fake_endpoint_resolver_modules`
Use only for the guarded fake/stub `src.endpoint_resolver` cleanup pattern.
- Preserves real resolver modules (those with a truthy `__file__`).
- Evicts fake/stub resolver modules and the dependent route modules that were
cached against them.
- Accepts explicit extra dependent module names to evict alongside the defaults.
### `tests.helpers.sqlite_db.make_temp_sqlite`
Use for the repeated file-backed temp sqlite setup in tests.
- Only constructs `(SessionLocal, engine, tmpfile)` from the repeated block.
- Does not patch modules and does not clean up the temp file.
- The caller must bind `SessionLocal` explicitly onto whatever module the code
under test reads, and must keep the returned objects alive.
- Do not use it as a general DB fixture framework.
### `tests.helpers.db_stubs.make_core_db_stub`
Use for small import-time `core.database` stubs with a placeholder
`SessionLocal`.
- Pass model names via `models` when MagicMock attributes are sufficient.
- Pass `attributes` when an import needs exact placeholder values.
- Set `install_core_package=True` only when the test also needs a fake parent
`core` module stub.
- Keep custom fake sessions and route-specific database behavior local.
## What not to abstract yet
Some remaining patterns should stay as-is for now rather than being forced into
helpers:
- Large mixed files such as security/review regression files.
- Broad setup-oriented `sys.modules` stub installers.
- One-off custom module patching.
- Custom DB session, route, and app setup.
## Validation expectations
Run validation locally before opening or approving a PR. Practical checks:
- `git diff --check` - catch whitespace and conflict-marker errors.
- `./venv/bin/python -m py_compile <changed files>` - confirm changed files compile.
- Focused `./venv/bin/python -m pytest` on the changed test files.
- `./venv/bin/python -m pytest` on neighboring or order-sensitive test groups
that share import state with the changed files.
- `grep` for the old boilerplate when replacing it, to confirm no stragglers
remain.
- A fresh audit worktree when changing the helpers themselves, so stale
`__pycache__` or import state cannot mask a regression.
## Current roadmap
1. Import-state cleanup - complete.
2. Document helper conventions (this file).
3. Pilot the repeated import-time `core.database` stub helper.
4. Add further tiny helpers only when the repeated semantics are clear.
5. Start low-risk file moves only after helper conventions are documented.
6. Avoid moving high-risk security/route regression files first.
+221
View File
@@ -0,0 +1,221 @@
# Odysseus Testing Standard & Taxonomy
## Purpose
This document defines *how we write and refactor tests* in Odysseus. It is the
standard that the incremental test-suite refactor (issue #2523) works toward,
and it applies to both human contributors and coding agents.
It is intentionally split from [`tests/README.md`](./README.md):
- **`README.md`** - the concrete, current helper reference: what each helper in
`tests/helpers/` does and how to call it.
- **`TESTING_STANDARD.md`** (this file) - the rules and taxonomy: what a good
test looks like, where it belongs, and the policy refactor PRs must follow.
When the two ever disagree, this file states the *intent* and `README.md` states
the *current mechanics*; fix whichever is stale.
This document changes no test behavior. It is guidance only.
## What the test suite is for
The goal is not only to reorganize `tests/`. The goal is for the suite to be a
reliable foundation for future development: deterministic, modular, informative,
behavior-focused, and complete enough to replace manual QA wherever practical.
Run tests with the project virtualenv interpreter (`./venv/bin/python -m pytest`).
The system `python3` may be missing pinned dependencies (e.g. `nh3`), which
shows up as import/collection errors that are environmental, not real failures.
## What "done" means for a single test
Every new or refactored test should be:
- **Deterministic** - same result every run, no reliance on wall-clock, network,
RNG seeds, or collection order.
- **Behavior-first** - asserts on observable behavior, not on the source text or
AST of the code under test (see [Behavioral-first policy](#behavioral-first-policy)).
- **Explicit** - setup and expected result are visible in the test, not hidden in
broad fixtures.
- **Isolated from global process state** - no leaked `sys.modules`, `os.environ`,
CWD, or package parent-attribute mutation (see [Determinism & isolation](#determinism--isolation-rules)).
- **Order-independent** - passes regardless of which tests ran before it.
- **Environment-independent** - does not assume a venv layout, a developer's home
directory, an existing `./data` dir, or optional packages that may be absent.
- **Informative on failure** - the assertion message or structure makes the cause
obvious without a debugger.
- **Small** - understandable quickly; one behavior per test where practical.
- **Backed by shared helpers only when duplication is proven** - not abstracted
preemptively.
## Test taxonomy
Tests are classified by the categories below. Today the suite is mostly flat
under `tests/` (the current `area_cli` set has moved to `tests/cli/`); the
**Target dir** column is the phased layout from #2523 that we move toward
*after* helpers and determinism are stable. Until a category is moved, new
tests in that category stay in flat `tests/` but should still follow this
standard.
| Category | What it covers | Examples today | Target dir |
|---|---|---|---|
| **Route / API integration** | Real ASGI request/response, auth gates, admin gates, owner isolation through the app | files using `TestClient` | `tests/routes/` |
| **CLI / script** | `scripts/` entry points and dev tooling | `tests.helpers.cli_loader.load_script` users, `test_pr_blocker_audit.py` | `tests/cli/` |
| **Frontend / JS** | Browser-coupled JS run via Node subprocess; streaming-render invariants | `*_js.py` wrappers, `tests/streaming/*.test.mjs` | `tests/js/` |
| **Tool execution / parsing** | Tool-call parsing, malformed/nonstring args, tool policy | `test_unknown_tool_calls.py`, `test_tool_policy.py`, `*_nonstring.py` | `tests/unit/` or `tests/services/` |
| **LLM / provider** | Provider response parsing, streaming, sanitize, reasoning fallback | `test_llm_core_*`, `test_anthropic_response_parse.py` | `tests/services/` |
| **Session / history / DB** | Session lifecycle, history, schema, ownership at the data layer | `test_session_*`, `test_sqlite_foreign_keys.py` | `tests/services/` or `tests/unit/` |
| **Security / owner-scope / regression** | Owner isolation, auth, SSRF, path confinement, XSS, prompt injection, pinned regressions | `*_owner_scope.py`, `test_security_regressions.py`, `test_*ssrf*`, `test_*confinement*` | `tests/security/` |
| **Cookbook / bootstrap** | Model serve lifecycle, dependency completion | `test_cookbook_*` | `tests/services/` |
| **Scheduler / background** | Cron computation, background jobs, delivery | `test_compute_next_run_*`, `test_bg_*`, `test_task_scheduler_*` | `tests/services/` |
| **Import / module isolation** | The isolation helpers themselves and their guarantees | `test_helpers_import_state.py` | `tests/unit/` |
A test that genuinely spans categories (e.g. a route test that also pins a
security invariant) is classified by its **primary** assertion target and may be
split if it grows.
## Fast lane policy
The fast lane is `not slow`: `tests/run_focus.py --fast` selects every test that
is not marked `slow`. The `slow` marker is **opt-in**, and slow marks must be
**evidence-driven from `--durations` output** - mark a test slow only when its
measured duration shows it is genuinely expensive, never by guessing. The fast
lane exists for quick local and reviewer feedback; it is **not** a replacement
for broader focused or full-suite validation before merge, and a test must never
be marked `slow` to hide a failure or skip coverage.
## Determinism & isolation rules
Do not mutate shared process state without a controlled helper and guaranteed
cleanup. Specifically:
- **`sys.modules` / parent-package attributes** - never assign at module scope.
Use `tests.helpers.import_state.preserve_import_state`, `clear_module`, or
`monkeypatch.setitem(sys.modules, ...)`. Restoring `sys.modules` alone is not
enough; the parent-package attribute must be restored too (the import-state
helpers handle both).
- **`os.environ`** - use `monkeypatch.setenv` / `monkeypatch.delenv`, never raw
`os.environ[...] = ...` that outlives the test.
- **Current working directory** - never `chdir` without restoring; never assert
against cwd-relative paths like `./data`. Use a temp workspace helper instead.
- **Database** - the root `conftest.py` defaults `DATABASE_URL` to an in-memory
SQLite for collection safety. A test that needs a real file-backed DB must opt
in explicitly via `tests.helpers.sqlite_db.make_temp_sqlite` and bind its
`SessionLocal` onto the module under test. Do not rely on a persistent
on-disk DB existing.
- **Optional dependencies** - do not require packages that may be absent in a
clean environment (e.g. `python-multipart`). Guard or stub them locally.
- **Node-subprocess JS tests** - skip cleanly when `node` is absent
(`shutil.which("node")`), matching the existing wrappers. Treat a skip as a
coverage gap to be aware of, not a pass.
- **Order independence** - a test must not depend on a sibling having imported,
cached, or stubbed something first. Order-sensitivity is a bug to fix, not a
constraint to encode.
## Behavioral-first policy
Prefer tests that exercise real behavior over tests that inspect source code.
- **Avoid** `read_text()` + substring assertions, `ast.parse`, and
`inspect.getsource` checks when the behavior can be driven directly. Source-text
assertions break on benign refactors (renames, reformatting) and can pass even
when behavior regresses, because the asserted string still appears somewhere.
- **Prefer** calling the function/route and asserting the outcome. Example: to
pin owner-scoping of `get_upcoming_events`, seed a temp DB with two owners and
assert one owner cannot see the other's events - rather than asserting the
source contains `q.filter(CalendarCal.owner == owner)`.
- **Narrow exception** - a source-text/AST assertion is acceptable only when the
invariant cannot be practically exercised at runtime (e.g. pinning that a
required constant or guard literally exists in a module that is hard to drive).
When used, say *why* in the test docstring so it is a deliberate choice, not a
shortcut.
- Do not convert source-text assertions to behavioral ones in the *same* PR that
moves files or changes unrelated setup.
## Helper & factory extraction rules
- Extract a shared helper only when the duplicated shape is **proven** - the same
setup repeated (ideally byte-identical) across multiple files.
- Prefer **plain functions** in `tests/helpers/` over fixtures. Reach for a
fixture only when it is clearly scoped to one directory/category, and put it in
that directory's `conftest.py`, not the root.
- Keep the **root `conftest.py` minimal** - `sys.path`, the DB-URL default, and
not-installed heavy-dependency stubs only. It is not a place for
feature-specific fixtures.
- Each helper documents its **intended use and its limits** ("do not stretch this
to cover X"), as the existing helpers in `README.md` do.
- Do not build a generic abstraction layer (factory framework, broad base
fixtures) before the repeated semantics are clear. Small and boring beats
clever and general.
- Candidate factories, to add only after the duplication audit confirms the
shapes: fake users, fake sessions, fake requests, fake DB rows, fake LLM
responses, fake tool calls.
## PR discipline for #2523 refactor slices
- Keep each PR small, reviewable, and behavior-preserving - unless the PR's stated
purpose is to add new coverage.
- **One kind of change per PR.** Do not mix:
- file moves with assertion changes;
- helper extraction with logic changes;
- import-state cleanup with DB-fixture changes.
- Do not weaken assertions, add `skip`/`xfail`, or delete coverage just to make CI
green. A red test is a signal to investigate, not to silence.
- Prefer 3-6 files per refactor batch, and only when they share the *same*
pattern.
- Distinguish a stale test expectation from a real production-policy change before
"fixing" a failing test - never edit a test to match a regression.
## Validation expectations
Run locally before opening or approving a refactor PR:
- `git diff --check` - whitespace and conflict-marker errors.
- `./venv/bin/python -m py_compile <changed .py files>` - changed files compile.
- Focused `./venv/bin/python -m pytest` on the changed files.
- `./venv/bin/python -m pytest` on neighboring / order-sensitive groups that
share import state with the changed files.
- When replacing boilerplate, `grep` for the old pattern to confirm no stragglers.
- When changing a helper itself, validate in a fresh worktree so stale
`__pycache__` or import state cannot mask a regression.
- For order-sensitivity, a randomized run (once `pytest-randomly` is available in
the dev environment) is the strongest check; record the seed on failures.
## Target directory structure (phased)
Move toward this layout *gradually*, only after helper conventions and
determinism are stable. Low-risk categories move first; oversized catch-all files
are split last.
```
tests/
conftest.py # stays minimal
README.md # helper reference
TESTING_STANDARD.md
helpers/ # plain helper functions (exists)
unit/ # pure helper/module tests
cli/ # scripts/ + CLI tests
js/ # node-subprocess + streaming tests
security/ # owner-scope, auth, SSRF, confinement, regressions
routes/ # TestClient integration (per-dir conftest for the client)
services/ # service-layer tests
integration/ # only if a cross-cutting flow needs it, later
```
Suggested move order: **js / cli first → security / routes / services → split
oversized catch-all files last.** Each move is mechanical (no assertion changes
in the same PR), with an identical pass set before and after.
## Related: CI-hardening track (tracked separately)
Making the suite an enforced gate is broader than #2523's organization scope and
should be tracked as its own effort. The intended sequence:
1. Add non-blocking randomized pytest reporting (`pytest-randomly`) so hidden
order-dependence becomes visible without changing any test.
2. Fix surfaced order-dependence in small same-pattern batches.
3. Add coverage reporting with no threshold gate.
4. Only then make the pytest job a blocking CI gate.
5. Consider `pytest-xdist` / parallel isolation after deterministic
single-process randomized runs are stable.
+162
View File
@@ -0,0 +1,162 @@
"""Conservative test taxonomy: classify test files by area and sub-area.
This module is the single source of truth for the collection-time markers added
in ``tests/conftest.py``. It performs no inference beyond simple, exact matching
of filename tokens against small, explicit keyword sets. A file is matched to
the first area (in priority order) whose keyword set intersects its filename
tokens; files that match no area fall back to ``uncategorized`` with the
filename itself as the sub-area.
The categories mirror ``tests/TESTING_STANDARD.md``. This module imports nothing
from the application - only the standard library - and changes no test behavior.
"""
from __future__ import annotations
import re
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
# Area keyword sets. Keep these small and explicit; prefer leaving a file
# ``uncategorized`` over guessing. Matching is exact, token-by-token.
SECURITY_KEYWORDS = frozenset({
"security", "auth", "owner", "scope",
"ssrf", "xss", "confinement", "permission", "redaction",
})
CLI_KEYWORDS = frozenset({"cli"})
ROUTES_KEYWORDS = frozenset({"route", "routes", "api"})
SERVICES_KEYWORDS = frozenset({
"llm", "provider", "cookbook", "session", "history", "email",
"calendar", "memory", "gallery", "document", "research", "mcp",
"scheduler", "webhook", "embedding",
})
UNIT_KEYWORDS = frozenset({
"parse", "parser", "parsing", "nonstring", "nondict",
"atomic", "regex", "tokenize",
})
# Keyword-matched areas, in priority order (first match wins). Security is a
# cross-cutting concern and intentionally outranks the feature areas, so e.g.
# ``test_email_owner_scope.py`` classifies as ``security``, not ``services``.
# ``js`` and ``helpers`` are matched by dedicated rules in ``_match_area``.
KEYWORD_AREAS = (
("security", SECURITY_KEYWORDS),
("cli", CLI_KEYWORDS),
("routes", ROUTES_KEYWORDS),
("services", SERVICES_KEYWORDS),
("unit", UNIT_KEYWORDS),
)
# File extensions that indicate a JavaScript/Node-backed test.
JS_EXTENSIONS = frozenset({".js", ".mjs", ".ts"})
UNCATEGORIZED = "uncategorized"
@dataclass(frozen=True)
class TestClassification:
"""Area and sub-area for a single test file."""
area: str
sub_area: str
def normalize_marker_name(value: str) -> str:
"""Lowercase ``value`` and reduce it to a marker-safe ``[a-z0-9_]`` token."""
lowered = value.lower()
collapsed = re.sub(r"[^a-z0-9]+", "_", lowered)
return collapsed.strip("_")
def _stem(path: str | Path) -> str:
"""Filename without its extension chain (``invariant.test.mjs`` -> ``invariant``)."""
return Path(path).name.split(".", 1)[0]
def _extension(path: str | Path) -> str:
"""Lowercased final file extension, e.g. ``.py`` or ``.mjs``."""
return Path(path).suffix.lower()
def _filename_tokens(path: str | Path) -> tuple[str, ...]:
"""Underscore tokens of the filename stem, with a leading ``test`` dropped."""
tokens = tuple(t for t in normalize_marker_name(_stem(path)).split("_") if t)
if tokens and tokens[0] == "test":
tokens = tokens[1:]
return tokens
def _matched_keywords(tokens: tuple[str, ...], keywords: frozenset[str]) -> tuple[str, ...]:
"""Filename tokens that appear in ``keywords``, in order, de-duplicated."""
matched: list[str] = []
for token in tokens:
if token in keywords and token not in matched:
matched.append(token)
return tuple(matched)
def _match_area(tokens: tuple[str, ...], extension: str) -> tuple[str, tuple[str, ...]]:
"""Return ``(area, matched_keywords)`` using the conservative priority order."""
if extension in JS_EXTENSIONS or "js" in tokens:
return "js", ("js",)
if tokens and tokens[0] == "helpers":
return "helpers", ("helpers",)
for area, keywords in KEYWORD_AREAS:
matched = _matched_keywords(tokens, keywords)
if matched:
return area, matched
return UNCATEGORIZED, ()
def _sub_area(area: str, matched: tuple[str, ...], tokens: tuple[str, ...]) -> str:
"""Derive the sub-area: matched keywords for a known area, else the filename."""
if area == UNCATEGORIZED:
return "_".join(tokens)
return "_".join(matched)
def _in_helpers_dir(path: str | Path) -> bool:
"""True if ``path`` is under the test helper dir ``tests/helpers/``.
Matches the exact adjacent ``tests``/``helpers`` component pair, so an
unrelated ancestor directory merely named ``helpers`` does not count.
"""
parts = Path(path).parent.parts
adjacent_pairs = list(zip(parts, parts[1:]))
return ("tests", "helpers") in adjacent_pairs
def classify_test_path(path: str | Path) -> TestClassification:
"""Classify a test file path into an area and a sub-area.
A test file under a ``helpers`` directory is a helper self-test regardless of
its filename, which complements the filename first-token rule in
``_match_area`` (e.g. ``test_helpers_import_state.py`` in ``tests/``).
"""
if _in_helpers_dir(path):
return TestClassification(area="helpers", sub_area="helpers")
tokens = _filename_tokens(path)
area, matched = _match_area(tokens, _extension(path))
sub_area = _sub_area(area, matched, tokens) or UNCATEGORIZED
return TestClassification(area=area, sub_area=sub_area)
def markers_for_path(path: str | Path) -> tuple[str, ...]:
"""Return the ``(area_*, sub_*)`` marker names for a test file path."""
classification = classify_test_path(path)
area_marker = normalize_marker_name(f"area_{classification.area}")
sub_marker = normalize_marker_name(f"sub_{classification.sub_area}")
return (area_marker, sub_marker)
def discover_markers(paths: Iterable[str | Path]) -> tuple[str, ...]:
"""Distinct ``area_*`` / ``sub_*`` marker names for ``paths``, sorted.
Pure: it derives names from the given paths only and performs no filesystem
access of its own. The caller decides which paths to scan. Used at
``pytest_configure`` time to register the dynamic ``sub_*`` markers.
"""
names: set[str] = set()
for path in paths:
names.update(markers_for_path(path))
return tuple(sorted(names))
+18
View File
@@ -0,0 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { deliveryMessages, researchCardState } from '../static/js/backgroundToolJobs.js';
test('research cards distinguish live work, handoff, completion and no evidence', () => {
assert.match(researchCardState({ status: 'running', rounds: 2, progress: { phase: 'reading', round: 1, total_sources: 3 } }).detail, /Round 1\/2 · 3 sources/);
assert.equal(researchCardState({ status: 'ready' }).label, 'Preparing chat update');
assert.equal(researchCardState({ status: 'delivered', source_count: 4 }).tone, 'done');
assert.equal(researchCardState({ status: 'delivered', outcome: 'no_sources' }).label, 'No sources found');
assert.equal(researchCardState({ status: 'ready', outcome: 'error' }).tone, 'error');
});
test('only new delivered messages append; history reload and repeat polls deduplicate', () => {
const job = { status: 'delivered', message: { content: 'Found it', metadata: { _db_id: 'result-1' } } };
assert.deepEqual(deliveryMessages([job, job, { status: 'running' }], []), [job.message]);
assert.deepEqual(deliveryMessages([job], ['result-1']), []);
assert.deepEqual(deliveryMessages([{ status: 'ready', message: job.message }], []), []);
});
+13
View File
@@ -0,0 +1,13 @@
from types import SimpleNamespace
from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
def test_calendar_name_handles_missing_relation(monkeypatch):
make_core_db_stub(monkeypatch, models=["CalendarCal", "CalendarEvent"])
cli = load_script("odysseus-calendar")
assert cli._calendar_name(SimpleNamespace(calendar=None)) == ""
assert cli._calendar_name(SimpleNamespace(calendar=SimpleNamespace(name=123))) == ""
assert cli._calendar_name(SimpleNamespace(calendar=SimpleNamespace(name="Work"))) == "Work"
+24
View File
@@ -0,0 +1,24 @@
import sys
import types
from unittest.mock import MagicMock
from tests.helpers.cli_loader import load_script
def _load_cli(monkeypatch):
routes = types.ModuleType("routes.contacts_routes")
routes._get_carddav_config = MagicMock()
routes._fetch_contacts = MagicMock()
routes._create_contact = MagicMock()
monkeypatch.setitem(sys.modules, "routes.contacts_routes", routes)
return load_script("odysseus-contacts")
def test_contact_rows_skips_invalid_rows(monkeypatch):
cli = _load_cli(monkeypatch)
assert cli._contact_rows([
{"name": "Ada", "email": "ada@example.test"},
"bad-row",
None,
]) == [{"name": "Ada", "email": "ada@example.test"}]
+17
View File
@@ -0,0 +1,17 @@
import io
import pytest
from tests.helpers.cli_loader import load_script
def test_state_set_rejects_non_object_json(tmp_path, monkeypatch, capsys):
cli = load_script("odysseus-cookbook")
cli._STATE_PATH = tmp_path / "cookbook_state.json"
monkeypatch.setattr(cli.sys, "stdin", io.StringIO("[]"))
with pytest.raises(SystemExit):
cli.cmd_state_set(type("Args", (), {})())
assert "expected a JSON object" in capsys.readouterr().err
assert not cli._STATE_PATH.exists()
+11
View File
@@ -0,0 +1,11 @@
from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
def test_text_len_ignores_non_string_values(monkeypatch):
make_core_db_stub(monkeypatch, models=["Document", "DocumentVersion"])
cli = load_script("odysseus-docs")
assert cli._text_len("hello") == 5
assert cli._text_len(None) == 0
assert cli._text_len({"bad": "row"}) == 0
+13
View File
@@ -0,0 +1,13 @@
from types import SimpleNamespace
from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
def test_album_image_count_handles_missing_relationship(monkeypatch):
make_core_db_stub(monkeypatch, models=["GalleryImage", "GalleryAlbum"])
cli = load_script("odysseus-gallery")
assert cli._album_image_count(SimpleNamespace(images=[1, 2])) == 2
assert cli._album_image_count(SimpleNamespace(images=None)) == 0
assert cli._album_image_count(SimpleNamespace(images=object())) == 0
+35
View File
@@ -0,0 +1,35 @@
"""Regression: gallery CLI image serialization must tolerate a non-string prompt.
`_serialize_image` did `(i.prompt or "")[:200]`. A non-string prompt is truthy,
so `123[:200]` raised TypeError. `_preview_text` coerces non-strings to "".
"""
from types import SimpleNamespace
from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
def test_preview_text_ignores_non_string(monkeypatch):
make_core_db_stub(monkeypatch, models=["GalleryImage", "GalleryAlbum"])
cli = load_script("odysseus-gallery")
assert cli._preview_text(None) == ""
assert cli._preview_text(123) == ""
assert cli._preview_text("p" * 250) == "p" * 200
assert cli._text_field("ok") == "ok"
assert cli._text_field(123) == ""
def test_serialize_image_does_not_crash_on_non_string_prompt(monkeypatch):
make_core_db_stub(monkeypatch, models=["GalleryImage", "GalleryAlbum"])
cli = load_script("odysseus-gallery")
img = SimpleNamespace(
id="i1", filename=123, prompt=123, model=123, size=None, tags=["bad"],
favorite=0, album_id=None, session_id=None, width=1, height=1, file_size=1,
taken_at=None, camera_make=123, camera_model=None, created_at=None,
)
out = cli._serialize_image(img)
assert out["prompt"] == ""
assert out["filename"] == ""
assert out["model"] == ""
assert out["tags"] == ""
assert out["id"] == "i1"
@@ -0,0 +1,13 @@
"""Regression: logs CLI _resolve must tolerate a non-string name.
`_resolve` did `name in p.name` and `p.name == name`; a non-string `name`
(e.g. None) raised TypeError once any *.log file existed. Non-strings now
return None (no match).
"""
from tests.helpers.cli_loader import load_script
def test_non_string_name_returns_none():
cli = load_script("odysseus-logs")
assert cli._resolve(None) is None
assert cli._resolve(123) is None
@@ -0,0 +1,57 @@
import sys
from types import ModuleType, SimpleNamespace
import pytest
from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
class _Conn:
def select(self, folder, readonly=True):
return "OK", [b"1"]
def fetch(self, uid, spec):
# IMAP can return OK with an empty payload (UID expunged mid-session).
return "OK", []
class _ImapCtx:
def __init__(self, account):
pass
def __enter__(self):
return _Conn()
def __exit__(self, *a):
return False
def _load_mail_cli(monkeypatch):
helpers = ModuleType("routes.email_helpers")
helpers._imap = _ImapCtx
helpers._get_email_config = lambda account=None: {}
helpers._decode_header = lambda value: value
helpers._extract_text = lambda msg: ""
helpers._extract_html = lambda msg: ""
helpers._list_attachments_from_msg = lambda msg: []
pollers = ModuleType("routes.email_pollers")
pollers._scheduled_poll_once = lambda: {}
pollers._run_auto_summarize_once = lambda **kwargs: ""
monkeypatch.setitem(sys.modules, "routes.email_helpers", helpers)
monkeypatch.setitem(sys.modules, "routes.email_pollers", pollers)
make_core_db_stub(
monkeypatch,
attributes={"SessionLocal": object, "EmailAccount": object},
install_core_package=True,
)
return load_script("odysseus-mail")
def test_cmd_read_handles_empty_fetch_payload(monkeypatch):
cli = _load_mail_cli(monkeypatch)
args = SimpleNamespace(account="acc", folder="INBOX", uid="5", html=False)
# old code did raw = msg_data[0][1] on the empty list and raised IndexError;
# the guard turns it into a clean fail() (SystemExit).
with pytest.raises(SystemExit):
cli.cmd_read(args)
+57
View File
@@ -0,0 +1,57 @@
import sys
from types import ModuleType
from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
def _load_mail_cli(monkeypatch):
helpers = ModuleType("routes.email_helpers")
helpers._imap = object
helpers._get_email_config = lambda account=None: {}
helpers._decode_header = lambda value: value
helpers._extract_text = lambda msg: ""
helpers._extract_html = lambda msg: ""
helpers._list_attachments_from_msg = lambda msg: []
pollers = ModuleType("routes.email_pollers")
pollers._scheduled_poll_once = lambda: {}
pollers._run_auto_summarize_once = lambda **kwargs: ""
monkeypatch.setitem(sys.modules, "routes.email_helpers", helpers)
monkeypatch.setitem(sys.modules, "routes.email_pollers", pollers)
make_core_db_stub(
monkeypatch,
attributes={"SessionLocal": object, "EmailAccount": object},
install_core_package=True,
)
return load_script("odysseus-mail")
def test_recipient_list_trims_to_cc_and_bcc(monkeypatch):
cli = _load_mail_cli(monkeypatch)
assert cli._recipient_list(" a@example.com, ", "b@example.com", " c@example.com ") == [
"a@example.com",
"b@example.com",
"c@example.com",
]
def test_recipient_list_rejects_empty_envelope(monkeypatch):
cli = _load_mail_cli(monkeypatch)
try:
cli._recipient_list(" , ", "", "")
except SystemExit as exc:
assert exc.code == 1
else:
raise AssertionError("expected empty recipient list to exit")
def test_split_recipients_ignores_non_string_values(monkeypatch):
cli = _load_mail_cli(monkeypatch)
assert cli._split_recipients(None) == []
assert cli._split_recipients(["a@example.test"]) == []
+29
View File
@@ -0,0 +1,29 @@
"""Regression: mcp CLI _serialize must not crash when env JSON is not an object.
`env_obj = json.loads(s.env)` can yield a list (e.g. env stored as "[1,2]").
`if redact_env and env_obj:` then called `env_obj.items()` -> AttributeError.
Guard with isinstance(dict).
"""
from types import SimpleNamespace
from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
def _srv(env):
return SimpleNamespace(id="s1", name="n", transport="stdio", command="c", args="[]",
env=env, url=None, is_enabled=1, oauth_config=None, created_at=None)
def test_serialize_handles_list_env(monkeypatch):
make_core_db_stub(monkeypatch, models=["McpServer"])
cli = load_script("odysseus-mcp")
out = cli._serialize(_srv("[1, 2]")) # JSON array, not object
assert out["id"] == "s1"
def test_serialize_redacts_dict_env(monkeypatch):
make_core_db_stub(monkeypatch, models=["McpServer"])
cli = load_script("odysseus-mcp")
out = cli._serialize(_srv('{"API_KEY": "secret"}'))
assert out["env"] == {"API_KEY": "***"}
+14
View File
@@ -0,0 +1,14 @@
from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
def test_mcp_json_helpers_reject_wrong_shapes(monkeypatch):
make_core_db_stub(monkeypatch, models=["McpServer"])
cli = load_script("odysseus-mcp")
assert cli._json_list('["a"]') == ["a"]
assert cli._json_list('{"not":"list"}') == []
assert cli._json_list("{bad") == []
assert cli._json_dict('{"A":"B"}') == {"A": "B"}
assert cli._json_dict('["bad"]') == {}
assert cli._json_dict("{bad") == {}
+22
View File
@@ -0,0 +1,22 @@
import sys
import types
from unittest.mock import MagicMock
from tests.helpers.cli_loader import load_script
def _load_cli(monkeypatch):
svc = types.ModuleType("services.memory.memory")
svc.MemoryManager = MagicMock()
monkeypatch.setitem(sys.modules, "services.memory.memory", svc)
return load_script("odysseus-memory")
def test_memory_entries_skips_invalid_rows(monkeypatch):
cli = _load_cli(monkeypatch)
assert cli._memory_entries([
{"id": "m1", "text": "ok"},
"bad-row",
None,
]) == [{"id": "m1", "text": "ok"}]
+70
View File
@@ -0,0 +1,70 @@
from types import SimpleNamespace
from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
def test_serialize_ignores_invalid_note_items(monkeypatch):
make_core_db_stub(monkeypatch, models=["Note"])
cli = load_script("odysseus-notes")
note = SimpleNamespace(
id="n1",
title="Checklist",
content="",
items="{bad json",
note_type="checklist",
color=None,
label=None,
pinned=False,
archived=False,
due_date=None,
source=None,
created_at=None,
updated_at=None,
)
assert cli._serialize(note)["items"] == []
def test_serialize_keeps_list_note_items(monkeypatch):
make_core_db_stub(monkeypatch, models=["Note"])
cli = load_script("odysseus-notes")
note = SimpleNamespace(
id="n1",
title="Checklist",
content="",
items='[{"text": "done"}]',
note_type="checklist",
color=None,
label=None,
pinned=False,
archived=False,
due_date=None,
source=None,
created_at=None,
updated_at=None,
)
assert cli._serialize(note)["items"] == [{"text": "done"}]
def test_serialize_skips_invalid_note_item_rows(monkeypatch):
make_core_db_stub(monkeypatch, models=["Note"])
cli = load_script("odysseus-notes")
note = SimpleNamespace(
id="n1",
title="Checklist",
content="",
items='[{"text": "done"}, "bad", null, 3]',
note_type="checklist",
color=None,
label=None,
pinned=False,
archived=False,
due_date=None,
source=None,
created_at=None,
updated_at=None,
)
assert cli._serialize(note)["items"] == [{"text": "done"}]
+22
View File
@@ -0,0 +1,22 @@
import sys
import types
from unittest.mock import MagicMock
from tests.helpers.cli_loader import load_script
def _load_cli(monkeypatch):
personal_docs = types.ModuleType("src.personal_docs")
personal_docs.PersonalDocsManager = MagicMock()
monkeypatch.setitem(sys.modules, "src.personal_docs", personal_docs)
return load_script("odysseus-personal")
def test_file_rows_skips_invalid_rows(monkeypatch):
cli = _load_cli(monkeypatch)
assert cli._file_rows([
{"name": "notes.txt", "path": "/tmp/notes.txt"},
"bad-row",
None,
]) == [{"name": "notes.txt", "path": "/tmp/notes.txt"}]
@@ -0,0 +1,18 @@
from tests.helpers.cli_loader import load_script
def test_entry_or_fail_rejects_non_object_entries():
cli = load_script("odysseus-preset")
try:
cli._entry_or_fail({"broken": "raw prompt"}, "broken")
except SystemExit as exc:
assert exc.code == 1
else:
raise AssertionError("expected invalid preset entry to exit")
def test_entry_or_fail_returns_valid_entry():
cli = load_script("odysseus-preset")
assert cli._entry_or_fail({"ok": {"name": "ok"}}, "ok") == {"name": "ok"}
@@ -0,0 +1,34 @@
from types import SimpleNamespace
from tests.helpers.cli_loader import load_script
def _load_preset_cli():
return load_script("odysseus-preset")
def test_set_replaces_corrupt_existing_entry(monkeypatch):
cli = _load_preset_cli()
saved = {}
emitted = {}
monkeypatch.setattr(cli, "_load", lambda: {"broken": "raw prompt"})
monkeypatch.setattr(cli, "_save", lambda data: saved.update(data))
monkeypatch.setattr(cli, "emit", lambda payload, _args: emitted.update(payload))
args = SimpleNamespace(
name="broken",
prompt="new prompt",
prompt_file=None,
temperature=0.7,
display_name=None,
)
cli.cmd_set(args)
assert saved["broken"] == {
"name": "broken",
"system_prompt": "new prompt",
"temperature": 0.7,
}
assert emitted["ok"] is True
+14
View File
@@ -0,0 +1,14 @@
import pytest
from tests.helpers.cli_loader import load_script
def test_load_rejects_non_object_preset_store(tmp_path, capsys):
cli = load_script("odysseus-preset")
cli._PATH = tmp_path / "presets.json"
cli._PATH.write_text("[]")
with pytest.raises(SystemExit):
cli._load()
assert "expected an object" in capsys.readouterr().err
+25
View File
@@ -0,0 +1,25 @@
"""Regression: research CLI summary must tolerate a non-string query.
`_summarize` did `(data.get("query") or "")[:200]`. A non-string query from a
legacy/corrupt research JSON is truthy, so `123[:200]` raised TypeError.
"""
from tests.helpers.cli_loader import load_script
def _load_cli():
return load_script("odysseus-research")
def test_preview_text_ignores_non_string():
cli = _load_cli()
assert cli._preview_text(None) == ""
assert cli._preview_text(123) == ""
assert cli._preview_text(["x"]) == ""
assert cli._preview_text("q" * 250) == "q" * 200
def test_summarize_does_not_crash_on_non_string_query():
cli = _load_cli()
out = cli._summarize("rp1", {"query": 123, "status": "done"})
assert out["query"] == ""
assert out["id"] == "rp1"
+57
View File
@@ -0,0 +1,57 @@
"""`odysseus-research list --status complete` must match completed runs.
Completed research runs are persisted with status "done" (research_handler),
but the user-facing CLI value is the friendlier "complete". The CLI offered
"complete" yet filtered `status != args.status`, so `--status complete` never
matched any record. The fix keeps "complete" as the CLI value and maps it to
the stored "done" at filter time, so the on-disk corpus stays the source of
truth and the documented CLI surface keeps working.
"""
import importlib.machinery
import importlib.util
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
ROOT = Path(__file__).resolve().parents[2]
def _load_cli():
path = ROOT / "scripts" / "odysseus-research"
loader = importlib.machinery.SourceFileLoader("odysseus_research_cli_status", str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
def test_complete_is_a_valid_status_choice():
cli = _load_cli()
parser = cli._build_parser()
ns = parser.parse_args(["list", "--status", "complete"])
assert ns.status == "complete"
def test_filter_returns_completed_runs(tmp_path, monkeypatch):
cli = _load_cli(); cli._DATA_DIR = tmp_path
(tmp_path / "r1.json").write_text(json.dumps({"query": "q1", "status": "done"}))
(tmp_path / "r2.json").write_text(json.dumps({"query": "q2", "status": "running"}))
emitted = []
monkeypatch.setattr(cli, "emit", lambda value, args: emitted.append(value))
# CLI "complete" must map to the stored "done" and match r1.
cli.cmd_list(SimpleNamespace(status="complete", limit=50))
ids = [r["id"] for r in emitted[0]]
assert ids == ["r1"] # only the completed run
def test_verbatim_status_still_filters(tmp_path, monkeypatch):
cli = _load_cli(); cli._DATA_DIR = tmp_path
(tmp_path / "r1.json").write_text(json.dumps({"query": "q1", "status": "done"}))
(tmp_path / "r2.json").write_text(json.dumps({"query": "q2", "status": "running"}))
emitted = []
monkeypatch.setattr(cli, "emit", lambda value, args: emitted.append(value))
cli.cmd_list(SimpleNamespace(status="running", limit=50))
ids = [r["id"] for r in emitted[0]]
assert ids == ["r2"] # verbatim choices pass through unchanged
@@ -0,0 +1,106 @@
"""`odysseus-research list --status complete` was returning nothing.
The CLI's `--status` argparse choice is "complete" — that is the user-facing
label — but the writer in `services/research/research_handler.py` stores
`status="done"` for a finished run (and the older `src/research_handler.py`
copy does the same). The list filter was a literal string compare, so
`--status complete` matched zero records on any real on-disk corpus.
These tests pin the alias so the friendlier CLI word keeps matching the
stored value. The other choices (`running`, `cancelled`, `error`) are
stored verbatim, so they must NOT be rewritten by the alias map.
Part of #2122 (odysseus-* CLI list/search bugs).
"""
from __future__ import annotations
import importlib.machinery
import importlib.util
import json
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[2]
def _load_cli():
path = ROOT / "scripts" / "odysseus-research"
loader = importlib.machinery.SourceFileLoader("odysseus_research_cli", str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
def _run_list(cli, tmp_path, monkeypatch, status, records):
cli._DATA_DIR = tmp_path
for name, blob in records.items():
(tmp_path / f"{name}.json").write_text(json.dumps(blob))
emitted = []
monkeypatch.setattr(cli, "emit", lambda value, args: emitted.append(value))
cli.cmd_list(SimpleNamespace(status=status, limit=50))
assert emitted, "cmd_list emitted nothing"
return [r["id"] for r in emitted[0]]
def test_status_complete_matches_writer_done_records(tmp_path, monkeypatch):
"""`--status complete` must return the records the writer marked `done`.
Without the alias this filter is silently empty on any real corpus."""
cli = _load_cli()
ids = _run_list(cli, tmp_path, monkeypatch, status="complete", records={
"rp-done": {"query": "finished one", "status": "done", "started_at": "2026-01-02"},
"rp-running": {"query": "still running", "status": "running", "started_at": "2026-01-01"},
"rp-cancelled": {"query": "user stopped", "status": "cancelled","started_at": "2025-12-31"},
})
assert ids == ["rp-done"], (
"--status complete should alias to the writer's stored 'done' value; "
f"got {ids}. The alias map in `_STATUS_CLI_TO_STORED` was bypassed."
)
def test_status_running_still_matches_verbatim(tmp_path, monkeypatch):
"""`running` is stored verbatim, so the alias must NOT rewrite it.
A blanket map that turned every CLI choice into a stored variant would
re-introduce the empty-result bug on the running/cancelled/error paths."""
cli = _load_cli()
ids = _run_list(cli, tmp_path, monkeypatch, status="running", records={
"rp-done": {"query": "finished", "status": "done"},
"rp-running": {"query": "still running", "status": "running"},
})
assert ids == ["rp-running"], f"--status running must match verbatim; got {ids}"
def test_status_cancelled_still_matches_verbatim(tmp_path, monkeypatch):
cli = _load_cli()
ids = _run_list(cli, tmp_path, monkeypatch, status="cancelled", records={
"rp-done": {"query": "finished", "status": "done"},
"rp-cancelled": {"query": "user stop", "status": "cancelled"},
})
assert ids == ["rp-cancelled"]
def test_status_error_still_matches_verbatim(tmp_path, monkeypatch):
cli = _load_cli()
ids = _run_list(cli, tmp_path, monkeypatch, status="error", records={
"rp-done": {"query": "finished", "status": "done"},
"rp-error": {"query": "crashed", "status": "error"},
})
assert ids == ["rp-error"]
def test_status_filter_tolerates_missing_or_non_string_status(tmp_path, monkeypatch):
"""A corrupt record with no `status` (or a non-string status) must not
crash the filter and must not falsely match `--status complete`. The
existing `_load_path` already drops non-dict blobs; this guards the
next layer."""
cli = _load_cli()
ids = _run_list(cli, tmp_path, monkeypatch, status="complete", records={
"rp-good": {"query": "ok", "status": "done"},
"rp-blank": {"query": "no status field"},
"rp-typed": {"query": "non-string", "status": 42},
})
assert ids == ["rp-good"], (
"--status complete should only match the writer's 'done' string; "
f"got {ids}."
)
+32
View File
@@ -0,0 +1,32 @@
import json
from types import SimpleNamespace
from tests.helpers.cli_loader import load_script
def _load_cli():
return load_script("odysseus-research")
def test_list_skips_non_object_research_records(tmp_path, monkeypatch):
cli = _load_cli()
cli._DATA_DIR = tmp_path
(tmp_path / "good.json").write_text(json.dumps({"query": "hello", "status": "complete"}))
(tmp_path / "list.json").write_text("[]")
(tmp_path / "broken.json").write_text("{")
emitted = []
monkeypatch.setattr(cli, "emit", lambda value, args: emitted.append(value))
cli.cmd_list(SimpleNamespace(status=None, limit=50))
assert emitted == [[{
"id": "good",
"query": "hello",
"category": "",
"status": "complete",
"started_at": "",
"completed_at": "",
"sources": 0,
"stats": {},
}]]
+39
View File
@@ -0,0 +1,39 @@
from types import SimpleNamespace
from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
def _load_sessions_cli(monkeypatch):
make_core_db_stub(
monkeypatch,
attributes={"SessionLocal": object, "Session": object},
install_core_package=True,
)
return load_script("odysseus-sessions")
def test_serialize_normalizes_numeric_counters(monkeypatch):
cli = _load_sessions_cli(monkeypatch)
session = SimpleNamespace(
id="s1",
name="chat",
model="m",
endpoint_url="",
owner=None,
folder=None,
archived=False,
rag=False,
is_important=False,
message_count="12",
total_input_tokens="bad",
total_output_tokens=None,
last_accessed=None,
created_at=None,
)
out = cli._serialize(session)
assert out["message_count"] == 12
assert out["total_input_tokens"] == 0
assert out["total_output_tokens"] == 0
+45
View File
@@ -0,0 +1,45 @@
import sys
from types import ModuleType
from tests.helpers.cli_loader import load_script
def _load_signature_cli(monkeypatch):
sqlalchemy_mod = ModuleType("sqlalchemy")
sqlalchemy_mod.text = lambda value: value
core_mod = ModuleType("core")
database_mod = ModuleType("core.database")
database_mod.engine = object()
monkeypatch.setitem(sys.modules, "sqlalchemy", sqlalchemy_mod)
monkeypatch.setitem(sys.modules, "core", core_mod)
monkeypatch.setitem(sys.modules, "core.database", database_mod)
return load_script("odysseus-signature")
def test_decode_png_data_accepts_data_url(monkeypatch):
cli = _load_signature_cli(monkeypatch)
png = b"\x89PNG\r\n\x1a\nrest"
assert cli._decode_png_data("data:image/png;base64,iVBORw0KGgpyZXN0") == png
def test_decode_png_data_rejects_invalid_base64(monkeypatch):
cli = _load_signature_cli(monkeypatch)
try:
cli._decode_png_data("not valid!!!")
except SystemExit as exc:
assert exc.code == 1
else:
raise AssertionError("expected invalid base64 to exit")
def test_decode_png_data_rejects_non_png_bytes(monkeypatch):
cli = _load_signature_cli(monkeypatch)
try:
cli._decode_png_data("aGVsbG8=")
except SystemExit as exc:
assert exc.code == 1
else:
raise AssertionError("expected non-PNG bytes to exit")
+32
View File
@@ -0,0 +1,32 @@
"""Regression: the skills CLI summary must tolerate a non-string description.
`_summary` did `(skill.get("description") or "")[:200]`. A non-string
description (e.g. a number from a hand-edited/legacy skill store) is truthy, so
`123[:200]` raised TypeError. `_preview_text` coerces non-strings to "".
"""
import sys
import types
from unittest.mock import MagicMock
from tests.helpers.cli_loader import load_script
def _load_cli(monkeypatch):
mod = types.ModuleType("services.memory.skills")
mod.SkillsManager = MagicMock()
monkeypatch.setitem(sys.modules, "services.memory.skills", mod)
return load_script("odysseus-skills")
def test_preview_text_ignores_non_string(monkeypatch):
cli = _load_cli(monkeypatch)
assert cli._preview_text(None) == ""
assert cli._preview_text(123) == ""
assert cli._preview_text({"x": 1}) == ""
assert cli._preview_text("y" * 250) == "y" * 200
def test_summary_does_not_crash_on_non_string_description(monkeypatch):
cli = _load_cli(monkeypatch)
out = cli._summary({"name": "n", "description": 123})
assert out["description"] == ""
+22
View File
@@ -0,0 +1,22 @@
import sys
import types
from unittest.mock import MagicMock
from tests.helpers.cli_loader import load_script
def _load_cli(monkeypatch):
svc = types.ModuleType("services.memory.skills")
svc.SkillsManager = MagicMock()
monkeypatch.setitem(sys.modules, "services.memory.skills", svc)
return load_script("odysseus-skills")
def test_skill_entries_skips_invalid_rows(monkeypatch):
cli = _load_cli(monkeypatch)
assert cli._skill_entries([
{"name": "deploy", "category": "ops"},
"bad-row",
None,
]) == [{"name": "deploy", "category": "ops"}]
+11
View File
@@ -0,0 +1,11 @@
from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
def test_preview_text_ignores_non_string_values(monkeypatch):
make_core_db_stub(monkeypatch, models=["ScheduledTask", "TaskRun"])
cli = load_script("odysseus-tasks")
assert cli._preview_text(None) == ""
assert cli._preview_text({"bad": "row"}) == ""
assert cli._preview_text("x" * 201) == ("x" * 200) + "…"
+15
View File
@@ -0,0 +1,15 @@
import pytest
from tests.helpers.cli_loader import load_script
@pytest.mark.parametrize("payload", ["[]", '{"_users": []}'])
def test_load_prefs_rejects_non_object_user_store(tmp_path, capsys, payload):
cli = load_script("odysseus-theme")
cli._USER_PREFS_PATH = tmp_path / "user_prefs.json"
cli._USER_PREFS_PATH.write_text(payload)
with pytest.raises(SystemExit):
cli._load_prefs()
assert "is corrupt" in capsys.readouterr().err
+37
View File
@@ -0,0 +1,37 @@
from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
def test_mask_token_handles_short_values(monkeypatch):
make_core_db_stub(monkeypatch, models=["ScheduledTask"])
cli = load_script("odysseus-webhook")
assert cli._mask_token("") == ""
assert cli._mask_token("short") == "***"
assert cli._mask_token("abcdef1234567890") == "abcdef…7890"
assert cli._mask_token("short", reveal=True) == "short"
def test_task_webhook_url_matches_live_route_and_escapes_path_parts(monkeypatch):
make_core_db_stub(monkeypatch, models=["ScheduledTask"])
cli = load_script("odysseus-webhook")
url = cli._task_webhook_url(
"https://ody.example/",
"task/with space",
"token/with space",
)
assert url == (
"https://ody.example/api/tasks/task%2Fwith%20space/"
"webhook/token%2Fwith%20space"
)
def test_default_task_webhook_url_uses_current_task_prefix(monkeypatch):
make_core_db_stub(monkeypatch, models=["ScheduledTask"])
cli = load_script("odysseus-webhook")
assert cli._task_webhook_url(None, "task-1", "secret") == (
"http://localhost:7000/api/tasks/task-1/webhook/secret"
)
+62 -1
View File
@@ -1,4 +1,4 @@
"""Shared test configuration — ensure project root is on sys.path and stub heavy deps."""
"""Shared test configuration - ensure project root is on sys.path and stub heavy deps."""
import sys
import os
import types
@@ -7,6 +7,29 @@ from unittest.mock import MagicMock
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:")
# 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_*)
# stub sqlalchemy/core.database at module scope with `if mod not in sys.modules`,
# which fires during collection. If the real module hasn't been imported yet,
# the stub wins and contaminates every subsequent test that needs the real ORM.
try:
import sqlalchemy # noqa: F401
import sqlalchemy.orm # noqa: F401
import core.database # noqa: F401
import src.database
except ImportError:
pass # not installed - the stubs below will handle it
def _has_module(mod_name: str) -> bool:
try:
return importlib.util.find_spec(mod_name) is not None
@@ -32,3 +55,41 @@ if "src.database" not in sys.modules:
_db.SessionLocal = MagicMock()
_db.ModelEndpoint = MagicMock()
sys.modules["src.database"] = _db
# Pre-import core.models before test_agent_loop.py's module-level stubs
# run (it replaces sys.modules['core.models'] with a MagicMock during
# collection, which breaks session import in subsequent tests).
import core.models # noqa: E402
def pytest_configure(config):
"""Register the dynamic taxonomy ``sub_*`` markers before collection.
The stable ``area_*`` markers are declared in ``pyproject.toml``. The
per-file ``sub_*`` markers are derived from the test filenames here so that
unknown-mark warnings still surface genuine typos outside the taxonomy. This
only registers marker names; it imports no production module.
"""
import pathlib
from tests._taxonomy import discover_markers
tests_dir = pathlib.Path(__file__).parent
paths = list(tests_dir.rglob("test_*.py")) + list(tests_dir.rglob("*_test.py"))
for marker_name in discover_markers(paths):
if marker_name.startswith("sub_"):
config.addinivalue_line("markers", f"{marker_name}: taxonomy sub-area marker")
def pytest_collection_modifyitems(config, items):
"""Tag each collected test with its taxonomy ``area_*`` and ``sub_*`` markers.
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
production module. See ``tests/_taxonomy.py`` for the classification rules.
"""
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))
+15
View File
@@ -0,0 +1,15 @@
# Browser End-to-End Tests
The photo editor release gate launches an isolated, authentication-disabled
Odysseus server on port `7013` with a temporary SQLite database.
```bash
npm install
npm run test:photo-editor:install
npm run test:photo-editor
```
Set `PHOTO_EDITOR_E2E_PORT` to use another port. Failure screenshots and traces
are written to `test-results/photo-editor/`. The server uses the repository
`.venv` automatically; set `ODYSSEUS_TEST_PYTHON` to another Python executable
when using a different environment layout.
@@ -0,0 +1,367 @@
const { test, expect } = require('@playwright/test');
const {
dragOnCanvas,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click();
const menu = page.locator('.ge-add-layer-menu');
await expect(menu).toBeVisible();
await menu.locator(`[data-adjustment-type="${type}"]`).click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
}
test('Levels is a retained stack layer with clipping, masks, history, and persistence', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 260 }, 'Adjustment layers E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.18, y: 0.25 }, { x: 0.78, y: 0.7 });
const before = await flattenedPixelDigest(page);
await addAdjustment(page, 'levels');
await page.locator('.ge-adj-channel-select').selectOption('red');
await page.locator('.ge-adj-row input[data-key="outWhite"]').fill('128');
await page.locator('.ge-adj-row input[data-key="outWhite"]').dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
let current = await editorState(page);
const levels = current.layers.find(layer => layer.kind === 'adjustment');
expect(levels.adjustment.type).toBe('levels');
expect(levels.adjustment.params.channels.red.outWhite).toBe(128);
expect(await flattenedPixelDigest(page)).not.toEqual(before);
const row = page.locator(`.ge-layer-item[data-layer-id="${levels.id}"]`);
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).clipped).toBe(true);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === levels.id).masks).toHaveLength(1);
await row.locator('.ge-layer-vis').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
await row.locator('.ge-layer-vis').click();
await row.locator('.ge-layer-opacity').fill('45');
await row.locator('.ge-layer-opacity').dispatchEvent('input');
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBeCloseTo(.45, 2);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBe(1);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBeCloseTo(.45, 2);
const draftId = await waitForDraft(page);
const expected = await editorState(page);
const expectedPixels = await flattenedPixelDigest(page);
const exportedPng = await page.evaluate(async () => {
const editor = await import('/static/js/galleryEditor.js');
return editor.exportPNG();
});
expect(await encodedImagePixelDigest(
page,
Buffer.from(exportedPng.split(',', 2)[1], 'base64'),
)).toEqual(expectedPixels);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.find(layer => layer.id === levels.id)).toEqual(expected.layers.find(layer => layer.id === levels.id));
expect(await flattenedPixelDigest(page)).toEqual(expectedPixels);
});
test('Curves supports editable RGB and channel points with reset and cancel', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 260 }, 'Curves E2E');
await addAdjustment(page, 'curves');
const curve = page.locator('.ge-curves-canvas');
const box = await curve.boundingBox();
await page.mouse.click(box.x + box.width * .5, box.y + box.height * .28);
await page.locator('.ge-adj-channel-select').selectOption('blue');
const blueBox = await curve.boundingBox();
await page.mouse.click(blueBox.x + blueBox.width * .4, blueBox.y + blueBox.height * .68);
await page.locator('[data-adj-action="ok"]').click();
let current = await editorState(page);
const curves = current.layers.find(layer => layer.kind === 'adjustment');
expect(curves.adjustment.type).toBe('curves');
expect(curves.adjustment.params.points.rgb).toHaveLength(3);
expect(curves.adjustment.params.points.blue).toHaveLength(3);
const row = page.locator(`.ge-layer-item[data-layer-id="${curves.id}"]`);
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
await page.locator('[data-adj-action="reset"]').click();
await page.locator('[data-adj-action="cancel"]').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === curves.id).adjustment.params.points.rgb).toHaveLength(3);
});
test('color adjustment controls retain neutral reusable parameters', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Color adjustments E2E');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const gradient = layer.ctx.createLinearGradient(0, 0, layer.canvas.width, layer.canvas.height);
gradient.addColorStop(0, '#b8324f');
gradient.addColorStop(.5, '#56a56d');
gradient.addColorStop(1, '#315fc4');
layer.ctx.fillStyle = gradient;
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
window.galleryEditorComposite?.();
});
const cases = [
{ type: 'exposure', key: 'exposure', value: '100', check: p => p.exposure === 1 },
{ type: 'white-balance', key: 'temperature', value: '70', check: p => p.temperature === 70 },
{ type: 'hue-saturation', key: 'lightness', value: '18', check: p => p.lightness === 18 },
{ type: 'vibrance', key: 'vibrance', value: '55', check: p => p.vibrance === 55 },
{ type: 'black-white', key: 'red', value: '72', compare: true, check: p => p.red === 72 && p.green === 59 && p.blue === 11 },
{ type: 'shadows-highlights', key: 'shadows', value: '48', check: p => p.shadows === 48 },
{ type: 'color-balance', key: 'shadows-r', value: '45', check: p => p.shadows.r === 45 },
{ type: 'selective-color', key: 'cyan', value: '35', check: p => p.ranges.reds.cyan === 35 },
];
let priorDigest = await flattenedPixelDigest(page);
for (const item of cases) {
await addAdjustment(page, item.type);
const control = page.locator(`.ge-adj-row input[data-key="${item.key}"]`);
await control.fill(item.value);
await control.dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const current = await editorState(page);
const added = current.layers.filter(layer => layer.kind === 'adjustment').at(-1);
expect(added.adjustment.type).toBe(item.type);
expect(item.check(added.adjustment.params)).toBe(true);
const nextDigest = await flattenedPixelDigest(page);
expect(nextDigest).not.toBe(priorDigest);
if (item.compare) {
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
await page.locator('[data-adj-action="compare"]').click();
expect(await flattenedPixelDigest(page)).toEqual(priorDigest);
await page.locator('[data-adj-action="compare"]').click();
expect(await flattenedPixelDigest(page)).toEqual(nextDigest);
await page.locator('[data-adj-action="cancel"]').click();
}
priorDigest = nextDigest;
}
await addAdjustment(page, 'gradient-map');
await page.locator('[data-gradient-key="shadows"]').fill('#123456');
await page.locator('[data-gradient-key="highlights"]').fill('#f0d080');
await page.locator('[data-gradient-reverse]').check();
await page.locator('[data-adj-action="ok"]').click();
const current = await editorState(page);
const gradient = current.layers.filter(layer => layer.kind === 'adjustment').at(-1);
expect(gradient.adjustment).toEqual({
type: 'gradient-map',
params: { shadows: '#123456', highlights: '#f0d080', midpoint: 50, reverse: true },
});
const expectedPixels = await flattenedPixelDigest(page);
const draftId = await waitForDraft(page);
const expectedState = await editorState(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.filter(layer => layer.kind === 'adjustment')).toEqual(
expectedState.layers.filter(layer => layer.kind === 'adjustment'),
);
expect(await flattenedPixelDigest(page)).toEqual(expectedPixels);
});
test('adjustment presets apply through the retained popup', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Adjustment presets E2E');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.fillStyle = '#5577aa';
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
window.galleryEditorComposite?.();
});
const before = await flattenedPixelDigest(page);
await addAdjustment(page, 'exposure');
await page.locator('.ge-adj-preset-select').selectOption('Lift Exposure');
await page.locator('[data-adj-action="ok"]').click();
const current = await editorState(page);
const exposure = current.layers.find(layer => layer.kind === 'adjustment');
expect(exposure.adjustment.params.exposure).toBe(0.45);
expect(await flattenedPixelDigest(page)).not.toEqual(before);
});
test('adjustment popup keeps controls inside a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile adjustment E2E');
await addAdjustment(page, 'exposure');
const popup = await page.locator('.ge-adj-popup').boundingBox();
expect(popup).not.toBeNull();
expect(popup.x).toBeGreaterThanOrEqual(0);
expect(popup.x + popup.width).toBeLessThanOrEqual(320);
for (const row of await page.locator('.ge-adj-row').all()) {
const box = await row.boundingBox();
expect(box).not.toBeNull();
expect(box.x).toBeGreaterThanOrEqual(popup.x);
expect(box.x + box.width).toBeLessThanOrEqual(popup.x + popup.width);
}
await expect(page.locator('.ge-adj-foot [data-adj-action="cancel"]')).toBeVisible();
await expect(page.locator('.ge-adj-foot [data-adj-action="ok"]')).toBeVisible();
});
test('gradient map controls remain usable on a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile gradient map E2E');
await addAdjustment(page, 'gradient-map');
const popup = await page.locator('.ge-adj-popup').boundingBox();
const colors = page.locator('.ge-gradient-color-row');
const colorBox = await colors.boundingBox();
expect(popup).not.toBeNull();
expect(colorBox).not.toBeNull();
expect(colorBox.x).toBeGreaterThanOrEqual(popup.x);
expect(colorBox.x + colorBox.width).toBeLessThanOrEqual(popup.x + popup.width);
expect(await page.locator('.ge-gradient-color-row').evaluate(el => (
getComputedStyle(el).gridTemplateColumns.trim().split(/\s+/).length
))).toBe(1);
});
test('all adjustment popups remain usable on a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile adjustment matrix E2E');
for (const type of [
'brightness-contrast', 'exposure', 'white-balance', 'hue-saturation',
'vibrance', 'black-white', 'shadows-highlights', 'levels', 'curves',
'color-balance', 'selective-color', 'gradient-map',
]) {
await addAdjustment(page, type);
const popup = await page.locator('.ge-adj-popup').boundingBox();
expect(popup, `${type} popup should be visible`).not.toBeNull();
expect(popup.x).toBeGreaterThanOrEqual(0);
expect(popup.x + popup.width).toBeLessThanOrEqual(320);
const body = page.locator('.ge-adj-body');
expect(await body.evaluate(el => el.scrollWidth <= el.clientWidth + 1), `${type} body should not overflow horizontally`).toBe(true);
await expect(page.locator('.ge-adj-foot [data-adj-action="cancel"]')).toBeVisible();
await expect(page.locator('.ge-adj-foot [data-adj-action="ok"]')).toBeVisible();
await page.locator('[data-adj-action="cancel"]').click();
}
});
test('retained Gaussian Blur survives flattening and draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained effects E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
const before = await flattenedPixelDigest(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
let current = await editorState(page);
const layer = current.layers.find(item => item.effects?.length);
expect(layer.effects).toHaveLength(1);
expect(layer.effects[0].type).toBe('gaussian-blur');
expect(layer.effects[0].params.radius).toBe(14);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
const reopened = current.layers.find(item => item.effects?.length);
expect(reopened.effects).toEqual(layer.effects);
expect(await flattenedPixelDigest(page)).toEqual(after);
await page.locator('.ge-effect-sub-item .ge-adj-sub-name').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('22');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].params.radius).toBe(22);
});
test('retained Sharpen keeps editable amount and survives draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained sharpen E2E');
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-preset-crisp-detail"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
expect(await page.locator('.ge-filter-row input[data-key="amount"]').inputValue()).toBe('35');
await page.locator('.ge-filter-row input[data-key="amount"]').fill('75');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
let current = await editorState(page);
let layer = current.layers.find(item => item.effects?.length);
expect(layer.effects[0].type).toBe('sharpen');
expect(layer.effects[0].params.amount).toBeCloseTo(0.75, 2);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
layer = current.layers.find(item => item.effects?.length);
expect(layer.effects[0].type).toBe('sharpen');
expect(layer.effects[0].params.amount).toBeCloseTo(0.75, 2);
});
test('retained overlay and shadow effects keep editable metadata', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained color effects E2E');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(canvasBox.x + 24, canvasBox.y + 24);
await page.mouse.down();
await page.mouse.move(canvasBox.x + 150, canvasBox.y + 140);
await page.mouse.up();
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
await page.locator('button[title="Add effect mask from selection"]').click();
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-drop-shadow"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="blur"]').fill('18');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
let current = await editorState(page);
let layer = current.layers.find(item => item.effects?.length);
expect(layer.effects.map(effect => effect.type)).toEqual(['color-overlay', 'drop-shadow']);
expect(layer.effects[0].params.color).toBe('#336699');
expect(layer.effects[0].mask.size).toEqual([320, 220]);
await page.locator('button[title="Hide effect mask"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].mask.visible).toBe(false);
await page.locator('button[title="Remove effect mask"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].mask).toBeNull();
expect(layer.effects[0].params.opacity).toBeCloseTo(0.35, 2);
expect(layer.effects[1].params.blur).toBe(18);
await page.locator('.ge-effect-sub-item').nth(0).locator('.ge-layer-vis').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].visible).toBe(false);
await page.locator('.ge-effect-sub-item').nth(1).locator('button[title="Move effect up"]').click();
current = await editorState(page);
layer = current.layers.find(item => item.effects?.length);
expect(layer.effects.map(effect => effect.type)).toEqual(['drop-shadow', 'color-overlay']);
await page.locator('.ge-effect-sub-item').nth(1).locator('button[title="Delete effect"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects).toHaveLength(1);
});
test('retained Stroke uses layer alpha and persists its controls', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-stroke"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="width"]').fill('9');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const layer = (await editorState(page)).layers.find(item => item.effects?.length);
expect(layer.effects[0].type).toBe('stroke');
expect(layer.effects[0].params.width).toBe(9);
const beforeRasterize = await flattenedPixelDigest(page);
await page.locator('.ge-effect-sub-item button[title="Rasterize effects"]').click();
await expect.poll(async () => (await editorState(page)).layers.find(item => item.name === 'Edit').effects.length).toBe(0);
const rasterized = (await editorState(page)).layers.find(item => item.name === 'Edit');
expect(rasterized.effects).toHaveLength(0);
expect(await flattenedPixelDigest(page)).toEqual(beforeRasterize);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
expect((await editorState(page)).layers.find(item => item.name === 'Edit').effects).toHaveLength(0);
});
@@ -0,0 +1,100 @@
const { test, expect } = require('@playwright/test');
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 page.addStyleTag({ url: '/static/style.css?v=20260903comparemodeicons1-emailsettingscards1' });
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 page.addStyleTag({ url: '/static/style.css?v=20260903comparemodeicons1-emailsettingscards1' });
await page.evaluate(() => {
document.body.innerHTML = `
<div class="compare-probe-overlay">
<section class="compare-probe-card">
<div class="compare-probe-title">Checking models...</div>
<div class="compare-probe-list">
<div class="compare-probe-row"><span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">Alpha</span></div>
<div class="compare-probe-row fail"><span class="compare-probe-spinner fail">×</span><span class="compare-probe-name">Beta</span></div>
</div>
<div class="compare-probe-feedback">
<div class="compare-probe-detail"><span class="compare-probe-detail-message">Insufficient balance</span><button class="compare-probe-action-btn"><svg></svg><span>Retry</span></button><button class="compare-probe-action-btn"><svg></svg><span>Swap</span></button></div>
</div>
<div class="compare-probe-footer"><button class="cmp-btn-secondary compare-probe-footer-btn">Go Back</button><button class="cmp-btn-primary compare-probe-footer-btn compare-probe-start-anyway">Start Anyway</button></div>
</section>
</div>`;
});
const layout = await page.evaluate(() => {
const list = document.querySelector('.compare-probe-list').getBoundingClientRect();
const feedback = document.querySelector('.compare-probe-feedback').getBoundingClientRect();
const back = document.querySelector('.compare-probe-footer-btn').getBoundingClientRect();
const start = document.querySelector('.compare-probe-start-anyway').getBoundingClientRect();
const spinnerStyle = getComputedStyle(document.querySelector('.compare-probe-spinner'));
const cardStyle = getComputedStyle(document.querySelector('.compare-probe-card'));
const startStyle = getComputedStyle(document.querySelector('.compare-probe-start-anyway'));
return {
feedbackBelowList: feedback.top >= list.bottom,
splitActions: back.left < start.left && start.right > 350,
spinnerTransform: spinnerStyle.transform,
cardRadius: cardStyle.borderRadius,
startBackground: startStyle.backgroundColor,
};
});
expect(layout.feedbackBelowList).toBe(true);
expect(layout.splitActions).toBe(true);
expect(layout.spinnerTransform).toContain('-2');
expect(layout.cardRadius).toBe('8px');
expect(layout.startBackground).not.toBe('rgba(0, 0, 0, 0)');
});
@@ -0,0 +1,47 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers.js');
test('brush cursor matches the rendered brush diameter and replaces the crosshair', async ({ page }) => {
await openBlankEditor(page, { width: 400, height: 300 });
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 80;
});
const canvas = page.locator('.ge-main-canvas');
const canvasBox = await canvas.boundingBox();
await page.mouse.move(canvasBox.x + canvasBox.width / 2, canvasBox.y + canvasBox.height / 2);
const cursor = page.locator('.ge-brush-cursor');
await expect(cursor).toBeVisible();
await expect(canvas).toHaveCSS('cursor', 'none');
const measurements = await page.evaluate(() => {
const canvasEl = document.querySelector('.ge-main-canvas');
const cursorEl = document.querySelector('.ge-brush-cursor');
const canvasRect = canvasEl.getBoundingClientRect();
const cursorRect = cursorEl.getBoundingClientRect();
return {
expectedWidth: 80 * canvasRect.width / canvasEl.width,
expectedHeight: 80 * canvasRect.height / canvasEl.height,
cursorWidth: cursorRect.width,
cursorHeight: cursorRect.height,
cursorZ: Number(getComputedStyle(cursorEl).zIndex),
galleryZ: Number(getComputedStyle(document.getElementById('gallery-modal')).zIndex || 0),
};
});
expect(measurements.cursorWidth).toBeCloseTo(measurements.expectedWidth, 1);
expect(measurements.cursorHeight).toBeCloseTo(measurements.expectedHeight, 1);
expect(measurements.cursorZ).toBeGreaterThan(measurements.galleryZ);
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 160;
});
await page.mouse.move(canvasBox.x + canvasBox.width / 2 + 1, canvasBox.y + canvasBox.height / 2);
await expect.poll(async () => (await cursor.boundingBox()).width)
.toBeCloseTo(measurements.expectedWidth * 2, 1);
});
+197
View File
@@ -0,0 +1,197 @@
const { test, expect } = require('@playwright/test');
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
async function canvasPoint(page, xRatio, yRatio) {
const box = await page.locator('.ge-main-canvas').boundingBox();
return { x: box.x + box.width * xRatio, y: box.y + box.height * yRatio };
}
test('brush presets and shared stroke controls remain reusable', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await expect(page.locator('#ge-brush-spacing')).toBeVisible();
await page.locator('#ge-brush-spacing').fill('28');
await page.locator('#ge-brush-smoothing').fill('52');
await page.locator('#ge-brush-blend').selectOption('multiply');
await page.locator('.ge-pressure-option').filter({ hasText: 'Opacity' }).locator('.toggle-slider').click();
await expect(page.locator('#ge-pressure-opacity')).toBeChecked();
page.once('dialog', dialog => dialog.accept('My Detail Brush'));
await page.locator('#ge-brush-preset-save').click();
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(1);
await page.locator('#ge-brush-spacing').fill('5');
await page.locator('#ge-brush-preset').selectOption({ label: 'My Detail Brush' });
await expect(page.locator('#ge-brush-spacing')).toHaveValue('28');
await expect(page.locator('#ge-brush-smoothing')).toHaveValue('52');
await expect(page.locator('#ge-brush-blend')).toHaveValue('multiply');
await page.locator('#ge-brush-preset-delete').click();
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(0);
});
test('long sampled strokes stay responsive and undo atomically', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-size-slider').fill('700');
await page.locator('#ge-brush-spacing').fill('8');
await page.locator('#ge-brush-smoothing').fill('65');
const activeLayerId = (await editorState(page)).activeLayerId;
const thumb = page.locator(`.ge-layer-item[data-layer-id="${activeLayerId}"] .ge-layer-inline-thumb`);
const thumbBefore = await thumb.evaluate(canvas => canvas.toDataURL());
const before = await flattenedPixelDigest(page);
const start = await canvasPoint(page, 0.08, 0.5);
const end = await canvasPoint(page, 0.92, 0.5);
const startedAt = Date.now();
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 240 });
await page.mouse.up();
expect(Date.now() - startedAt).toBeLessThan(5000);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
await expect(thumb).toHaveAttribute('width', '68');
expect(await thumb.evaluate(canvas => canvas.toDataURL())).not.toEqual(thumbBefore);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
await page.locator('#ge-redo').click();
expect(await flattenedPixelDigest(page)).toEqual(after);
});
test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#33aa55';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
const sample = await canvasPoint(page, 0.3, 0.3);
await page.mouse.click(sample.x, sample.y);
await page.locator('.ge-tool-btn[data-tool="eyedropper"]').click();
await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
await page.locator('#ge-eyedropper-sample').selectOption('composite');
await page.mouse.move(sample.x, sample.y);
await expect(page.locator('#ge-eyedropper-live-value')).toHaveText('#33aa55');
await expect(page.locator('#ge-eyedropper-live-rgb')).toHaveText('RGB 51 170 85');
await expect(page.locator('#ge-eyedropper-live-hsl')).toHaveText('HSL 137 54% 43%');
await expect(page.locator('#ge-eyedropper-live-swatch')).not.toHaveClass(/empty/);
await expect(page.locator('#ge-eyedropper-loupe')).toBeVisible();
expect((await page.locator('#ge-eyedropper-loupe').evaluate(canvas => canvas.getContext('2d').getImageData(42, 42, 1, 1).data[3]))).toBe(255);
await page.mouse.click(sample.x, sample.y);
await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55');
for (const tool of ['dodge', 'burn']) {
const before = await flattenedPixelDigest(page);
await page.locator(`.ge-tool-btn[data-tool="${tool}"]`).click();
const from = await canvasPoint(page, 0.35, tool === 'dodge' ? 0.45 : 0.6);
const to = await canvasPoint(page, 0.65, tool === 'dodge' ? 0.45 : 0.6);
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(to.x, to.y, { steps: 20 });
await page.mouse.up();
expect((await editorState(page)).layers.length).toBeGreaterThan(0);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
}
const blemish = await canvasPoint(page, 0.72, 0.3);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#111111';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.mouse.click(blemish.x, blemish.y);
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
const beforeHeal = await flattenedPixelDigest(page);
const healTarget = blemish;
await page.mouse.move(healTarget.x, healTarget.y);
await page.mouse.down();
await page.mouse.move(healTarget.x + 50, healTarget.y, { steps: 12 });
await page.mouse.up();
expect(await flattenedPixelDigest(page)).not.toEqual(beforeHeal);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(beforeHeal);
});
test('healing brush can use an optional sampled source', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Sampled healing E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-size-slider').fill('420');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#d24b62';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
const source = await canvasPoint(page, 0.28, 0.5);
await page.mouse.click(source.x, source.y);
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
await page.keyboard.down('Alt');
await page.mouse.click(source.x, source.y);
await page.keyboard.up('Alt');
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.cloneSourceX !== null && state.cloneSourceY !== null;
})).toBe(true);
const target = await canvasPoint(page, 0.72, 0.5);
await page.mouse.click(target.x, target.y);
const result = await page.evaluate(async ({ xRatio, yRatio }) => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const x = Math.round(layer.canvas.width * xRatio);
const y = Math.round(layer.canvas.height * yRatio);
const point = layer.ctx.getImageData(x, y, 1, 1).data;
return { hasSource: !!state.cloneSourceSnapshot, pixel: [...point] };
}, { xRatio: 0.72, yRatio: 0.5 });
expect(result.hasSource).toBe(true);
expect(result.pixel[0]).toBeGreaterThan(80);
expect(result.pixel[3]).toBeGreaterThan(0);
await expect(page.locator('#ge-clone-source-label')).toHaveText('Active layer sampled');
await page.locator('#ge-clone-source-clear').click();
await expect(page.locator('#ge-clone-source-label')).toHaveText('No source selected');
});
test('smudge carries nearby pixels along a stroke and undoes atomically', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Smudge E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-size-slider').fill('700');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#d24b62';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
const source = await canvasPoint(page, 0.28, 0.5);
const target = await canvasPoint(page, 0.55, 0.5);
await page.mouse.click(source.x, source.y);
const before = await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const x = Math.round(canvas.width * 0.55);
const y = Math.round(canvas.height * 0.5);
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
});
await page.locator('.ge-tool-btn[data-tool="smudge"]').click();
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
await page.mouse.move(source.x, source.y);
await page.mouse.down();
await page.mouse.move(target.x, target.y, { steps: 20 });
await page.mouse.up();
const after = await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const x = Math.round(canvas.width * 0.55);
const y = Math.round(canvas.height * 0.5);
const ctx = canvas.getContext('2d');
return [...ctx.getImageData(x, y, 1, 1).data];
});
expect(before[0]).toBeGreaterThan(220);
expect(before[3]).toBe(255);
expect(after[0]).toBeLessThan(220);
expect(after[2]).toBeLessThan(220);
expect(after[3]).toBeGreaterThan(0);
await page.locator('#ge-undo').click();
expect(await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const x = Math.round(canvas.width * 0.55);
const y = Math.round(canvas.height * 0.5);
return canvas.getContext('2d').getImageData(x, y, 1, 1).data[3];
})).toBe(255);
});
@@ -0,0 +1,43 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor } = require('./helpers.js');
async function mainCanvasHash(page) {
return page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let hash = 2166136261;
for (let index = 0; index < data.length; index += 4) {
hash ^= data[index];
hash = Math.imul(hash, 16777619);
hash ^= data[index + 1];
hash = Math.imul(hash, 16777619);
hash ^= data[index + 2];
hash = Math.imul(hash, 16777619);
hash ^= data[index + 3];
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
});
}
test('before compare is visual-only and toggles back to the edited document', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Compare mode E2E');
await expect(page.locator('#ge-compare-btn')).toBeVisible();
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
const baselineHash = await mainCanvasHash(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
const editedState = await editorState(page);
const editedHash = await mainCanvasHash(page);
expect(editedHash).not.toBe(baselineHash);
await page.locator('#ge-compare-btn').click();
await expect(page.locator('#ge-compare-btn')).toHaveAttribute('aria-pressed', 'true');
expect(await mainCanvasHash(page)).toBe(baselineHash);
expect((await editorState(page)).layers).toEqual(editedState.layers);
await page.locator('#ge-compare-btn').click();
await expect(page.locator('#ge-compare-btn')).toHaveAttribute('aria-pressed', 'false');
expect(await mainCanvasHash(page)).toBe(editedHash);
});
@@ -0,0 +1,106 @@
const fs = require('node:fs');
const { test, expect } = require('@playwright/test');
const {
compareExportPixels,
dragOnCanvas,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
test('layered document survives edit, mask, transform, crop, reopen, and export', async ({ page, request }) => {
await openBlankEditor(page);
const initial = await editorState(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.20, y: 0.30 }, { x: 0.72, y: 0.60 });
const painted = await editorState(page);
const paintedLayer = painted.layers.find(layer => layer.name === 'Edit');
const initialLayer = initial.layers.find(layer => layer.name === 'Edit');
expect(paintedLayer.pixelHash).not.toBe(initialLayer.pixelHash);
const editItem = page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first();
await editItem.click();
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
let current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').masks).toHaveLength(1);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('#ge-transform-w')).toBeVisible();
await page.locator('#ge-transform-w').fill('560');
await page.locator('#ge-transform-apply').click();
current = await editorState(page);
const transformed = current.layers.find(layer => layer.name === 'Edit');
expect(transformed.size[0]).toBe(560);
expect(transformed.masks[0].size).toEqual(transformed.size);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').size[0]).toBe(640);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').size[0]).toBe(560);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await expect(page.locator('#ge-text-section')).toBeVisible();
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvasBox.x + canvasBox.width * 0.28, canvasBox.y + canvasBox.height * 0.22);
await page.locator('#ge-text-content').fill('Durable photo edit');
await page.locator('#ge-text-size').fill('42');
await page.locator('#ge-text-size').press('Enter');
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Durable photo edit' })).toBeVisible();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await dragOnCanvas(page, { x: 0.08, y: 0.10 }, { x: 0.92, y: 0.88 });
await expect(page.locator('.ge-crop-apply-btn')).toBeVisible();
await page.locator('.ge-crop-apply-btn').click();
const cropped = await editorState(page);
expect(cropped.dimensions[0]).toBeLessThan(640);
expect(cropped.dimensions[1]).toBeLessThan(480);
await page.locator('#ge-undo').click();
expect((await editorState(page)).dimensions).toEqual([640, 480]);
await page.locator('#ge-redo').click();
expect((await editorState(page)).dimensions).toEqual(cropped.dimensions);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
const beforeReopenPixels = await flattenedPixelDigest(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.dimensions).toEqual(beforeReopen.dimensions);
expect(reopened.activeLayerId).toBe(beforeReopen.activeLayerId);
expect(reopened.layers).toEqual(beforeReopen.layers);
expect(await flattenedPixelDigest(page)).toEqual(beforeReopenPixels);
await openExportDialog(page);
await page.locator('[data-format="png"]').click();
await page.locator('#ge-export-width').fill('320');
const expectedHeight = Number(await page.locator('#ge-export-height').inputValue());
await page.locator('#ge-export-filename').fill('photo-editor-release-gate');
const downloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('photo-editor-release-gate.png');
const bytes = fs.readFileSync(await download.path());
expect(bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(bytes.readUInt32BE(16)).toBe(320);
expect(bytes.readUInt32BE(20)).toBe(expectedHeight);
const resizedComparison = await compareExportPixels(page, bytes, { width: 320, height: expectedHeight });
expect(resizedComparison.meanAbsoluteError).toBeLessThanOrEqual(1);
expect(resizedComparison.maximumError).toBeLessThanOrEqual(32);
expect(resizedComparison.changedPixelRatio).toBeLessThanOrEqual(0.08);
await openExportDialog(page);
await page.locator('[data-format="png"]').click();
await page.locator('#ge-export-filename').fill('photo-editor-native-fidelity');
const nativeDownloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const nativeDownload = await nativeDownloadPromise;
const nativeBytes = fs.readFileSync(await nativeDownload.path());
expect(await encodedImagePixelDigest(page, nativeBytes)).toEqual(beforeReopenPixels);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,191 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Gesture cancellation');
const canvas = page.locator('.ge-main-canvas');
const box = await canvas.boundingBox();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await page.mouse.move(box.x + 40, box.y + 35);
await page.mouse.down();
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { cropping: state.cropping, rect: state.cropRect };
})).toMatchObject({ cropping: true, rect: { w: 150, h: 115 } });
await page.locator('.ge-tool-btn[data-tool="move"]').dispatchEvent('click');
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { tool: state.tool, cropping: state.cropping, moving: state.cropMoving, rect: state.cropRect };
})).toEqual({ tool: 'move', cropping: false, moving: false, rect: null });
await page.mouse.up();
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
const beforeMove = await editorState(page);
const bounds = beforeMove.selection.bounds;
await page.mouse.move(
box.x + bounds.x + bounds.width / 2,
box.y + bounds.y + bounds.height / 2,
);
await page.mouse.down();
await page.mouse.move(box.x + bounds.x + bounds.width / 2 + 35, box.y + bounds.y + bounds.height / 2 + 20);
expect((await editorState(page)).selection.bounds).not.toEqual(bounds);
await page.locator('.ge-tool-btn[data-tool="brush"]').dispatchEvent('click');
const afterSwitch = await editorState(page);
expect(afterSwitch.selection.bounds).toEqual(bounds);
expect(afterSwitch.undo).toBe(beforeMove.undo);
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { tool: state.tool, selectionMoving: state.selectionMoving, marqueeActive: state.marqueeActive };
})).toEqual({ tool: 'brush', selectionMoving: false, marqueeActive: false });
await page.mouse.up();
expect((await editorState(page)).selection.bounds).toEqual(bounds);
});
test('Escape cancels an active crop without changing the document', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Escape crop cancellation');
const canvas = page.locator('.ge-main-canvas');
const box = await canvas.boundingBox();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await page.mouse.move(box.x + 40, box.y + 35);
await page.mouse.down();
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
await page.keyboard.press('Escape');
await page.mouse.up();
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { cropping: state.cropping, moving: state.cropMoving, rect: state.cropRect };
})).toEqual({ cropping: false, moving: false, rect: null });
});
test('touch crop and selection gestures complete through the shared lifecycle', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch gestures');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30);
await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145);
await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145);
await expect(page.locator('.ge-crop-apply')).toBeVisible();
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.cropRect;
})).toMatchObject({ w: 155, h: 115 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await touch('touchStart', canvasBox.x + 45, canvasBox.y + 40);
await touch('touchMove', canvasBox.x + 165, canvasBox.y + 125);
await touch('touchEnd', canvasBox.x + 165, canvasBox.y + 125);
expect((await editorState(page)).selection.bounds).toBeTruthy();
} finally {
await context.close();
}
});
test('touch brush paints and undoes as one stroke', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch brush');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
const before = await flattenedPixelDigest(page);
await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5);
await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
await touch('touchEnd', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
} finally {
await context.close();
}
});
test('touch cancellation rolls back a partial brush stroke', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch cancellation');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
const before = await flattenedPixelDigest(page);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: canvasBox.x + 70, y: canvasBox.y + 110, id: 1 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: canvasBox.x + 230, y: canvasBox.y + 110, id: 1 }],
});
await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] });
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.drawing;
})).toBe(false);
expect(await flattenedPixelDigest(page)).toEqual(before);
} finally {
await context.close();
}
});
test('pen input completes a crop gesture', async ({ page, context, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP pen injection');
await openBlankEditor(page, { width: 320, height: 240 }, 'Pen crop gesture');
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
const box = await page.locator('.ge-main-canvas').boundingBox();
const cdp = await context.newCDPSession(page);
await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed', pointerType: 'pen', button: 'left', buttons: 1,
clickCount: 1, x: box.x + 30, y: box.y + 25,
});
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseMoved', pointerType: 'pen', button: 'none', buttons: 1,
x: box.x + 175, y: box.y + 130,
});
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseReleased', pointerType: 'pen', button: 'left', buttons: 0,
clickCount: 1, x: box.x + 175, y: box.y + 130,
});
await expect(page.locator('.ge-crop-apply')).toBeVisible();
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { cropping: state.cropping, rect: state.cropRect };
})).toEqual({ cropping: false, rect: { x: 30, y: 25, w: 145, h: 105 } });
});
@@ -0,0 +1,191 @@
const fs = require('node:fs');
const { test, expect } = require('@playwright/test');
const {
compareExportPixels,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
async function seedGradient(page) {
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const gradient = layer.ctx.createLinearGradient(0, 0, layer.canvas.width, layer.canvas.height);
gradient.addColorStop(0, '#d43f67');
gradient.addColorStop(0.5, '#4aa884');
gradient.addColorStop(1, '#3868d4');
layer.ctx.fillStyle = gradient;
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
window.galleryEditorComposite?.();
});
}
async function downloadFormat(page, format, filename) {
await openExportDialog(page);
await page.locator(`[data-format="${format}"]`).click();
await page.locator('#ge-export-filename').fill(filename);
const downloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const download = await downloadPromise;
return {
name: download.suggestedFilename(),
bytes: fs.readFileSync(await download.path()),
};
}
async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click();
await expect(page.locator('.ge-add-layer-menu')).toBeVisible();
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${type}"]`).click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
}
async function exportCurrentPng(page) {
const dataUrl = await page.evaluate(async () => {
const editor = await import('/static/js/galleryEditor.js');
return editor.exportPNG();
});
return Buffer.from(dataUrl.split(',', 2)[1], 'base64');
}
test('every retained adjustment family exports the visible composite exactly', async ({ page }) => {
await openBlankEditor(page, { width: 128, height: 96 }, 'Adjustment export matrix');
await seedGradient(page);
const cases = [
['brightness-contrast', 'brightness', '35'],
['exposure', 'exposure', '35'],
['white-balance', 'temperature', '30'],
['hue-saturation', 'hue', '30'],
['vibrance', 'vibrance', '45'],
['black-white', 'red', '60'],
['shadows-highlights', 'shadows', '35'],
['levels', 'inBlack', '20'],
['color-balance', 'shadows-r', '35'],
['selective-color', 'cyan', '30'],
];
for (const [type, key, value] of cases) {
await addAdjustment(page, type);
const control = page.locator(`.ge-adj-row input[data-key="${key}"]`);
await control.fill(value);
await control.dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const visibleDigest = await flattenedPixelDigest(page);
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(visibleDigest);
}
await addAdjustment(page, 'curves');
const curve = page.locator('.ge-curves-canvas');
const curveBox = await curve.boundingBox();
await page.mouse.click(curveBox.x + curveBox.width * 0.5, curveBox.y + curveBox.height * 0.28);
await page.locator('[data-adj-action="ok"]').click();
const curveDigest = await flattenedPixelDigest(page);
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(curveDigest);
await addAdjustment(page, 'gradient-map');
await page.locator('[data-gradient-key="shadows"]').fill('#102030');
await page.locator('[data-gradient-key="shadows"]').dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const gradientDigest = await flattenedPixelDigest(page);
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(gradientDigest);
});
test('composited correction exports preserve pixels and format metadata', async ({ page }) => {
await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity');
await seedGradient(page);
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('35');
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const renderedPixels = await flattenedPixelDigest(page);
const png = await downloadFormat(page, 'png', 'correction');
expect(png.name).toBe('correction.png');
expect(png.bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(png.bytes.readUInt32BE(16)).toBe(160);
expect(png.bytes.readUInt32BE(20)).toBe(120);
expect(await encodedImagePixelDigest(page, png.bytes)).toEqual(renderedPixels);
const jpeg = await downloadFormat(page, 'jpeg', 'correction-jpeg');
expect(jpeg.name).toBe('correction-jpeg.jpg');
expect(jpeg.bytes[0]).toBe(0xff);
expect(jpeg.bytes[1]).toBe(0xd8);
const webp = await downloadFormat(page, 'webp', 'correction-webp');
expect(webp.name).toBe('correction-webp.webp');
expect(webp.bytes.subarray(0, 4).toString('ascii')).toBe('RIFF');
expect(webp.bytes.subarray(8, 12).toString('ascii')).toBe('WEBP');
const comparison = await compareExportPixels(page, png.bytes, { width: 160, height: 120 });
expect(comparison.meanAbsoluteError).toBe(0);
expect(comparison.maximumError).toBe(0);
const exposure = (await editorState(page)).layers.find(layer => layer.kind === 'adjustment');
const exposureRow = page.locator(`.ge-layer-item[data-layer-id="${exposure.id}"]`);
await exposureRow.locator('.ge-layer-opacity').fill('62');
await exposureRow.locator('.ge-layer-opacity').dispatchEvent('input');
const opacityState = await editorState(page);
expect(opacityState.layers.find(layer => layer.id === exposure.id).opacity).toBeCloseTo(.62, 2);
const opacityPixels = await flattenedPixelDigest(page);
const opacityPng = await downloadFormat(page, 'png', 'correction-opacity');
expect(await encodedImagePixelDigest(page, opacityPng.bytes)).toEqual(opacityPixels);
expect(await compareExportPixels(page, opacityPng.bytes, { width: 160, height: 120 })).toEqual({
meanAbsoluteError: 0,
maximumError: 0,
changedPixelRatio: 0,
});
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.id === exposure.id).opacity).toBeCloseTo(.62, 2);
expect(await flattenedPixelDigest(page)).toEqual(opacityPixels);
});
test('export preview clears stale matte pixels when transparency changes', async ({ page }) => {
await openBlankEditor(page, { width: 160, height: 120 }, 'Export preview');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
for (const layer of state.layers) layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.fillStyle = '#2671c8';
layer.ctx.fillRect(40, 30, 80, 60);
window.galleryEditorComposite?.();
});
await openExportDialog(page);
const previewPixel = () => page.locator('.ge-export-preview').evaluate(canvas => (
[...canvas.getContext('2d').getImageData(0, 0, 1, 1).data]
));
expect(await previewPixel()).toEqual([0, 0, 0, 0]);
await page.locator('#ge-export-transparency').uncheck();
await page.locator('#ge-export-matte').click();
await page.locator('.cp-hex').fill('#d94747');
await page.locator('.cp-hex').press('Enter');
expect(await previewPixel()).toEqual([217, 71, 71, 255]);
await page.locator('#ge-export-transparency').check();
expect(await previewPixel()).toEqual([0, 0, 0, 0]);
await page.locator('.ge-export-close').click();
});
test('closing export returns focus to the launch control', async ({ page }) => {
await openBlankEditor(page, { width: 160, height: 120 }, 'Export focus');
const launch = page.locator('#ge-save-menu-btn');
await launch.focus();
await launch.click();
await page.locator('#ge-download').click();
await expect(page.locator('.ge-export-dialog')).toBeVisible();
await page.locator('.ge-export-close').click();
await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe('ge-save-menu-btn');
});
@@ -0,0 +1,47 @@
const { test, expect } = require('@playwright/test');
test('gallery photos body uses tall viewport without cropping or stretching cards', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1200 });
await page.route('**/api/gallery/library?**', async route => {
const pixel = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="160" height="160"%3E%3Crect width="160" height="160" fill="%23666"/%3E%3C/svg%3E';
const items = Array.from({ length: 60 }, (_, index) => ({
id: `layout-${index}`,
url: pixel,
thumbnail_url: pixel,
filename: `Layout ${index}.png`,
prompt: `Layout ${index}`,
model: 'imported',
created_at: '2026-08-31T00:00:00Z',
}));
await route.fulfill({ json: { items, total: items.length, has_more: false } });
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.locator('#tool-gallery-btn').click();
await expect(page.locator('#gallery-modal')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#gallery-grid .gallery-card')).toHaveCount(61);
await expect(page.locator('#gallery-grid')).not.toHaveClass(/gallery-just-opened/, { timeout: 2_000 });
const layout = await page.evaluate(() => {
const modal = document.querySelector('.gallery-modal-content').getBoundingClientRect();
const body = document.querySelector('#gallery-modal .modal-body').getBoundingClientRect();
const grid = document.querySelector('#gallery-grid').getBoundingClientRect();
const upload = document.querySelector('#gallery-upload-tile').getBoundingClientRect();
const cards = [...document.querySelectorAll('#gallery-grid .gallery-card')]
.slice(0, 12)
.map(card => card.getBoundingClientRect());
return { modal, body, grid, upload, cards, viewportHeight: innerHeight };
});
expect(layout.grid.height).toBeGreaterThan(layout.viewportHeight * 0.65);
expect(layout.grid.bottom).toBeLessThanOrEqual(layout.body.bottom + 1);
expect(Math.abs(layout.upload.width - layout.upload.height)).toBeLessThanOrEqual(2);
for (let i = 0; i < layout.cards.length; i += 1) {
for (let j = i + 1; j < layout.cards.length; j += 1) {
const a = layout.cards[i];
const b = layout.cards[j];
const overlaps = a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
expect(overlaps).toBe(false);
}
}
});
@@ -0,0 +1,108 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const {
compareExportPixels,
dragOnCanvas,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
async function downloadPng(page, filename) {
await openExportDialog(page);
await page.locator('[data-format="png"]').click();
await page.locator('#ge-export-filename').fill(filename);
const downloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const download = await downloadPromise;
return fs.readFileSync(await download.path());
}
test('group retained effects edit, toggle, reorder surface, and survive reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 360, height: 240 }, 'Group effects E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 48;
state.brushSoftness = 0;
});
await dragOnCanvas(page, { x: 0.25, y: 0.35 }, { x: 0.75, y: 0.65 });
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
let current = await editorState(page);
expect(current.groups[0].effects).toHaveLength(1);
expect(current.groups[0].effects[0].params.radius).toBe(14);
const afterAdd = await flattenedPixelDigest(page);
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
await page.locator('.ge-group-effect-sub-item .ge-adj-sub-name').click();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('22');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
current = await editorState(page);
expect(current.groups[0].effects[0].params.radius).toBe(22);
expect(await flattenedPixelDigest(page)).not.toEqual(afterAdd);
await page.locator('.ge-group-effect-sub-item .ge-layer-vis').click();
expect((await editorState(page)).groups[0].effects[0].visible).toBe(false);
await page.locator('.ge-group-effect-sub-item .ge-layer-vis').click();
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
expect((await editorState(page)).groups).toEqual(beforeReopen.groups);
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
await page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`).click();
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('group retained effects export the visible composite and survive reopen', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Group effects export E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 64;
state.brushSoftness = 0;
});
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('40');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const renderedPixels = await flattenedPixelDigest(page);
const pngBytes = await downloadPng(page, 'group-effects');
const comparison = await compareExportPixels(page, pngBytes, { width: 240, height: 180 });
expect(comparison.meanAbsoluteError).toBe(0);
expect(comparison.maximumError).toBe(0);
expect(await flattenedPixelDigest(page)).toEqual(renderedPixels);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.groups[0].effects).toHaveLength(1);
expect(reopened.groups[0].effects[0]).toMatchObject({
type: 'color-overlay',
params: { color: '#336699', opacity: 0.4 },
});
});
+68
View File
@@ -0,0 +1,68 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function compositeAlphaAtCenter(page) {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const x = Math.floor(state.imgWidth / 2);
const y = Math.floor(state.imgHeight / 2);
return state.documentCompositeCanvas.getContext('2d').getImageData(x, y, 1, 1).data[3];
});
}
test('group mask paints, toggles, survives history and reopen, and protects ungroup', async ({ page, request }) => {
await openBlankEditor(page, { width: 420, height: 300 }, 'Group mask E2E');
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await page.locator('#ge-layer-tools .ge-group-mask-btn').click();
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
await expect(page.locator('#ge-layer-tools button[title="Delete group masks before ungrouping"]')).toBeDisabled();
let current = await editorState(page);
expect(current.groups[0].masks).toHaveLength(1);
expect(current.groups[0].masks[0]).toMatchObject({ mode: 'group', size: [420, 300] });
const whiteMaskHash = current.groups[0].masks[0].pixelHash;
expect(await compositeAlphaAtCenter(page)).toBe(255);
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 90;
state.eraserSoftness = 0;
});
await dragOnCanvas(page, { x: 0.46, y: 0.5 }, { x: 0.54, y: 0.5 });
current = await editorState(page);
const erasedMaskHash = current.groups[0].masks[0].pixelHash;
expect(erasedMaskHash).not.toBe(whiteMaskHash);
expect(await compositeAlphaAtCenter(page)).toBe(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups[0].masks[0].pixelHash).toBe(whiteMaskHash);
expect(await compositeAlphaAtCenter(page)).toBe(255);
await page.locator('#ge-redo').click();
expect((await editorState(page)).groups[0].masks[0].pixelHash).toBe(erasedMaskHash);
expect(await compositeAlphaAtCenter(page)).toBe(0);
const maskRow = page.locator('.ge-group-mask-sub-item');
await maskRow.locator('.ge-layer-vis').click();
expect(await compositeAlphaAtCenter(page)).toBe(255);
await maskRow.locator('.ge-layer-vis').click();
expect(await compositeAlphaAtCenter(page)).toBe(0);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.groups).toEqual(beforeReopen.groups);
expect(await compositeAlphaAtCenter(page)).toBe(0);
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
await page.locator('.ge-group-mask-sub-item button[title="Delete group mask"]').click();
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(0);
await groupRow.click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
expect((await editorState(page)).groups).toHaveLength(0);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,107 @@
const { test, expect } = require('@playwright/test');
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('group drag reorders the complete subtree with undo and server persistence', async ({ page, request }) => {
await openBlankEditor(page, { width: 420, height: 300 }, 'Group reorder E2E');
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
let current = await editorState(page);
const group = current.groups[0];
const originalOrder = current.layers.map(layer => layer.id);
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page);
const looseId = current.activeLayerId;
expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
const handle = page.locator(`.ge-layer-group-row[data-group-id="${group.id}"] .ge-layer-group-drag`);
const looseRow = page.locator(`.ge-layer-item[data-layer-id="${looseId}"]`);
const handleBox = await handle.boundingBox();
const looseBox = await looseRow.boundingBox();
const dragReadiness = await page.evaluate(async ({ groupId, point }) => {
const { state } = await import('/static/js/editor/state.js');
const { groupSiblingUnits } = await import('/static/js/editor/layer-groups.js');
const group = state.layerGroups.find(item => item.id === groupId);
const hit = document.elementFromPoint(point.x, point.y);
return {
siblingIds: groupSiblingUnits(state, group.parentId || null).map(unit => unit.id),
hitHandle: !!hit?.closest('.ge-layer-group-drag'),
};
}, { groupId: group.id, point: { x: handleBox.x + handleBox.width / 2, y: handleBox.y + handleBox.height / 2 } });
expect(dragReadiness.hitHandle, JSON.stringify(dragReadiness)).toBe(true);
expect(dragReadiness.siblingIds).toEqual([group.id, looseId]);
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
await page.mouse.down();
await page.mouse.move(looseBox.x + looseBox.width / 2, looseBox.y + 1, { steps: 8 });
await expect(page.locator('.ge-group-drop-line')).toBeVisible();
await page.mouse.up();
current = await editorState(page);
expect(current.layers.map(layer => layer.id)).toEqual([looseId, ...originalOrder]);
expect(current.groups[0].layerIds).toEqual(group.layerIds);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([looseId, ...originalOrder]);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.map(layer => layer.id)).toEqual(beforeReopen.layers.map(layer => layer.id));
expect(current.groups).toEqual(beforeReopen.groups);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('mobile layer drag handle supports long-press reorder', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder');
await page.locator('.ge-layers-header').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
const before = await editorState(page);
// State is bottom-to-top; the panel is top-to-bottom.
const dragId = before.layers[0].id;
const targetId = before.layers[before.layers.length - 1].id;
const bottomHandle = page.locator(`.ge-layer-item[data-layer-id="${dragId}"] .ge-layer-drag`);
const topRow = page.locator(`.ge-layer-item[data-layer-id="${targetId}"]`);
await bottomHandle.scrollIntoViewIfNeeded();
const handleBox = await bottomHandle.boundingBox();
const topBox = await topRow.boundingBox();
expect(handleBox.width).toBeGreaterThanOrEqual(24);
const point = { x: handleBox.x + handleBox.width / 2, y: handleBox.y + handleBox.height / 2 };
const hitHandle = await page.evaluate(({ x, y }) => {
const hit = document.elementFromPoint(x, y);
return !!hit?.closest('.ge-layer-drag');
}, point);
expect(hitHandle).toBe(true);
const cdp = await page.context().newCDPSession(page);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: point.x, y: point.y, id: 1, radiusX: 8, radiusY: 8 }],
});
await page.waitForTimeout(450);
await expect(page.locator(`.ge-layer-item[data-layer-id="${dragId}"]`)).toHaveClass(/dragging/);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: point.x, y: topBox.y + 1, id: 1, radiusX: 8, radiusY: 8 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
await page.waitForTimeout(120);
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([targetId, dragId]);
} finally {
await context.close();
}
});
+375
View File
@@ -0,0 +1,375 @@
const { expect } = require('@playwright/test');
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
let lastError = null;
for (let pageAttempt = 0; pageAttempt < 3; pageAttempt += 1) {
try {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.evaluate(async () => {
window.__photoEditorImport = async path => {
let lastImportError = null;
for (let attempt = 0; attempt < 4; attempt += 1) {
const url = attempt === 0 ? path : `${path}?e2e_retry=${attempt}-${Date.now()}`;
try { return await import(url); } catch (error) { lastImportError = error; }
await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1)));
}
throw lastImportError;
};
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}, { size, name });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
lastError = null;
break;
} catch (error) {
lastError = error;
await page.goto('about:blank');
await page.waitForTimeout(250 * (pageAttempt + 1));
}
}
if (lastError) throw lastError;
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.layers.length;
})).toBe(2);
}
async function editorState(page) {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const hashCanvas = canvas => {
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let hash = 2166136261;
for (let index = 0; index < data.length; index += Math.max(4, Math.floor(data.length / 20_000 / 4) * 4)) {
const alpha = data[index + 3] / 255;
hash ^= Math.round(data[index] * alpha);
hash = Math.imul(hash, 16777619);
hash ^= Math.round(data[index + 1] * alpha);
hash = Math.imul(hash, 16777619);
hash ^= Math.round(data[index + 2] * alpha);
hash = Math.imul(hash, 16777619);
hash ^= data[index + 3];
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
};
const selectionBounds = canvas => {
if (!canvas) return null;
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let left = canvas.width, top = canvas.height, right = -1, bottom = -1;
for (let y = 0; y < canvas.height; y += 1) for (let x = 0; x < canvas.width; x += 1) {
if (data[(y * canvas.width + x) * 4 + 3] < 1) continue;
left = Math.min(left, x); top = Math.min(top, y);
right = Math.max(right, x); bottom = Math.max(bottom, y);
}
return right < left ? null : { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
};
return {
documentRenderReady: !!state.documentRenderReady,
dimensions: [state.imgWidth, state.imgHeight],
activeLayerId: state.activeLayerId,
selectedLayerIds: [...(state.selectedLayerIds || [])],
groups: (state.layerGroups || []).map(group => ({
id: group.id,
name: group.name,
layerIds: [...group.layerIds],
parentId: group.parentId || null,
visible: group.visible !== false,
opacity: group.opacity,
blendMode: group.blendMode,
locked: !!group.locked,
collapsed: !!group.collapsed,
activeMaskId: group.activeMaskId || null,
effects: (group.effects || []).map(effect => ({
id: effect.id,
type: effect.type,
name: effect.name,
visible: effect.visible !== false,
opacity: effect.opacity,
params: JSON.parse(JSON.stringify(effect.params || {})),
})),
masks: (group.masks || []).map(mask => ({
id: mask.id,
name: mask.name,
visible: mask.visible !== false,
density: Number.isFinite(Number(mask.density)) ? mask.density : 1,
feather: Number.isFinite(Number(mask.feather)) ? mask.feather : 0,
mode: mask.mode,
size: [mask.canvas.width, mask.canvas.height],
pixelHash: hashCanvas(mask.canvas),
})),
})),
layers: state.layers.map(layer => ({
id: layer.id,
name: layer.name,
kind: layer.kind || 'raster',
effects: (layer.effects || []).map(effect => ({
id: effect.id,
type: effect.type,
name: effect.name,
visible: effect.visible !== false,
opacity: effect.opacity,
mask: effect.mask ? {
visible: effect.mask.visible !== false,
size: [effect.mask.canvas?.width || effect.mask.canvasW, effect.mask.canvas?.height || effect.mask.canvasH],
} : null,
params: JSON.parse(JSON.stringify(effect.params || {})),
})),
visible: layer.visible !== false,
opacity: layer.opacity,
locked: !!layer.locked,
locks: {
pixels: !!layer.locks?.pixels,
transparency: !!layer.locks?.transparency,
position: !!layer.locks?.position,
},
clipped: !!layer.clipped,
size: [layer.canvas.width, layer.canvas.height],
offset: state.layerOffsets.get(layer.id) || { x: 0, y: 0 },
pixelHash: hashCanvas(layer.canvas),
text: layer.text ? {
content: layer.text.content,
fontSize: layer.text.fontSize,
fontFamily: layer.text.fontFamily,
lineHeight: layer.text.lineHeight,
letterSpacing: layer.text.letterSpacing,
frameWidth: layer.text.frameWidth,
frameHeight: layer.text.frameHeight,
verticalAlign: layer.text.verticalAlign,
autoWidth: layer.text.autoWidth,
transform: { ...layer.text.transform },
} : null,
shape: layer.shape ? {
type: layer.shape.type,
width: layer.shape.width,
height: layer.shape.height,
fillColor: layer.shape.fillColor,
fillType: layer.shape.fillType,
gradientStart: layer.shape.gradientStart,
gradientMid: layer.shape.gradientMid,
gradientMidEnabled: layer.shape.gradientMidEnabled,
gradientMidPosition: layer.shape.gradientMidPosition,
gradientEnd: layer.shape.gradientEnd,
gradientStops: layer.shape.gradientStops ? layer.shape.gradientStops.map(stop => ({ ...stop })) : undefined,
gradientAngle: layer.shape.gradientAngle,
strokeColor: layer.shape.strokeColor,
strokeWidth: layer.shape.strokeWidth,
cornerRadius: layer.shape.cornerRadius,
sides: layer.shape.sides,
transform: { ...layer.shape.transform },
} : null,
adjustment: layer.adjustment ? JSON.parse(JSON.stringify(layer.adjustment)) : null,
placed: layer.kind === 'placed' && layer.placed?.sourceCanvas ? {
sourceSize: [layer.placed.sourceCanvas.width, layer.placed.sourceCanvas.height],
sourceName: layer.placed.sourceName,
matrix: [...layer.placed.matrix],
sourcePixelHash: hashCanvas(layer.placed.sourceCanvas),
} : null,
masks: (layer.masks || []).map(mask => ({
id: mask.id,
mode: mask.mode,
space: mask.space,
linked: mask.mode === 'layer' ? mask.linked !== false : true,
density: Number.isFinite(Number(mask.density)) ? mask.density : 1,
feather: Number.isFinite(Number(mask.feather)) ? mask.feather : 0,
offset: { x: Number(mask.offset?.x) || 0, y: Number(mask.offset?.y) || 0 },
size: [mask.canvas.width, mask.canvas.height],
pixelHash: hashCanvas(mask.canvas),
})),
})),
guides: state.guides,
draftId: state.draftId,
undo: state.undoStack.length,
redo: state.redoStack.length,
quickMaskActive: !!state.quickMaskActive,
selection: state.wandMask ? {
space: state.wandMaskSpace,
source: state.selectionSource,
size: [state.wandMask.width, state.wandMask.height],
pixelHash: hashCanvas(state.wandMask),
bounds: selectionBounds(state.wandMask),
} : null,
savedSelections: (state.savedSelections || []).map(selection => ({
id: selection.id,
name: selection.name,
size: [selection.canvas.width, selection.canvas.height],
pixelHash: hashCanvas(selection.canvas),
bounds: selectionBounds(selection.canvas),
})),
lastSelection: state.lastSelection?.canvas ? {
pixelHash: hashCanvas(state.lastSelection.canvas),
bounds: selectionBounds(state.lastSelection.canvas),
} : null,
persistIdle: !state.persistTimer && !state.persistInFlight,
};
});
}
async function dragOnCanvas(page, start, end) {
const box = await page.locator('.ge-main-canvas').boundingBox();
if (!box) throw new Error('Canvas has no visible bounds');
await page.mouse.move(box.x + box.width * start.x, box.y + box.height * start.y);
await page.mouse.down();
await page.mouse.move(box.x + box.width * end.x, box.y + box.height * end.y, { steps: 10 });
await page.mouse.up();
}
async function waitForDraft(page) {
await expect.poll(async () => {
const current = await editorState(page);
return !!current.draftId && current.persistIdle;
}, { timeout: 15_000 }).toBe(true);
return (await editorState(page)).draftId;
}
async function reopenDraft(page, draftId) {
await page.evaluate(async id => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
window.__galleryAllowCloseEditor = true;
editor.closeEditor();
await editor.openEditor(null, null, null, 'Reopened E2E document', id);
}, draftId);
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
await expect.poll(async () => (await editorState(page)).draftId).toBe(draftId);
await expect.poll(async () => (await editorState(page)).documentRenderReady, {
timeout: 20_000,
}).toBe(true);
}
async function openExportDialog(page) {
await page.locator('#ge-save-menu-btn').click();
await page.locator('#ge-download').click();
await expect(page.locator('.ge-export-dialog')).toBeVisible();
}
async function flattenedPixelDigest(page, size = null) {
return page.evaluate(async targetSize => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
const image = new Image();
image.src = editor.exportPNG();
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = targetSize?.width || image.naturalWidth;
canvas.height = targetSize?.height || image.naturalHeight;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const raw = context.getImageData(0, 0, canvas.width, canvas.height).data;
const pixels = new Uint8ClampedArray(raw.length);
for (let index = 0; index < raw.length; index += 4) {
const alpha = raw[index + 3] / 255;
pixels[index] = Math.round(raw[index] * alpha);
pixels[index + 1] = Math.round(raw[index + 1] * alpha);
pixels[index + 2] = Math.round(raw[index + 2] * alpha);
pixels[index + 3] = raw[index + 3];
}
const digest = await crypto.subtle.digest('SHA-256', pixels);
return {
width: canvas.width,
height: canvas.height,
sha256: Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''),
};
}, size);
}
async function encodedImagePixelDigest(page, bytes) {
return page.evaluate(async base64 => {
const binary = atob(base64);
const encoded = Uint8Array.from(binary, character => character.charCodeAt(0));
const bitmap = await createImageBitmap(new Blob([encoded], { type: 'image/png' }));
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
bitmap.close();
const raw = context.getImageData(0, 0, canvas.width, canvas.height).data;
const pixels = new Uint8ClampedArray(raw.length);
for (let index = 0; index < raw.length; index += 4) {
const alpha = raw[index + 3] / 255;
pixels[index] = Math.round(raw[index] * alpha);
pixels[index + 1] = Math.round(raw[index + 1] * alpha);
pixels[index + 2] = Math.round(raw[index + 2] * alpha);
pixels[index + 3] = raw[index + 3];
}
const digest = await crypto.subtle.digest('SHA-256', pixels);
return {
width: canvas.width,
height: canvas.height,
sha256: Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''),
};
}, bytes.toString('base64'));
}
async function compareExportPixels(page, bytes, size) {
return page.evaluate(async ({ base64, targetSize }) => {
const decode = async source => {
const image = new Image();
image.src = source;
await image.decode();
return image;
};
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
const expectedImage = await decode(editor.exportPNG());
const binary = atob(base64);
const actualImage = await decode(URL.createObjectURL(new Blob([
Uint8Array.from(binary, character => character.charCodeAt(0)),
], { type: 'image/png' })));
const pixels = image => {
const canvas = document.createElement('canvas');
canvas.width = targetSize.width;
canvas.height = targetSize.height;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return context.getImageData(0, 0, canvas.width, canvas.height).data;
};
const expected = pixels(expectedImage);
const actual = pixels(actualImage);
URL.revokeObjectURL(actualImage.src);
let total = 0;
let maximum = 0;
let changedPixels = 0;
for (let index = 0; index < expected.length; index += 4) {
const expectedAlpha = expected[index + 3] / 255;
const actualAlpha = actual[index + 3] / 255;
let pixelMaximum = Math.abs(expected[index + 3] - actual[index + 3]);
total += pixelMaximum;
for (let channel = 0; channel < 3; channel += 1) {
const delta = Math.abs(
expected[index + channel] * expectedAlpha - actual[index + channel] * actualAlpha,
);
total += delta;
pixelMaximum = Math.max(pixelMaximum, delta);
}
maximum = Math.max(maximum, pixelMaximum);
if (pixelMaximum > 1) changedPixels += 1;
}
return {
meanAbsoluteError: total / expected.length,
maximumError: maximum,
changedPixelRatio: changedPixels / (expected.length / 4),
};
}, { base64: bytes.toString('base64'), targetSize: size });
}
module.exports = {
compareExportPixels,
dragOnCanvas,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
};
@@ -0,0 +1,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,304 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
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,20 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
test('active editor draft reopens after a hard refresh', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Refresh recovery E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
const before = await editorState(page);
const draftId = await waitForDraft(page);
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('#gallery-editor-tab')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('.gallery-editor')).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => (await editorState(page)).draftId, { timeout: 20_000 }).toBe(draftId);
await expect.poll(async () => (await editorState(page)).documentRenderReady, { timeout: 20_000 }).toBe(true);
const after = await editorState(page);
expect(after.layers).toEqual(before.layers);
expect(after.dimensions).toEqual(before.dimensions);
});
@@ -0,0 +1,181 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
test('selection boundary animates, moves independently, and supports Quick Mask', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection workflow');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
const initial = await editorState(page);
expect(initial.selection?.space).toBe('document');
expect(initial.selection?.bounds).toBeTruthy();
const layerBefore = initial.layers.find(layer => layer.id === initial.activeLayerId);
const overlayHash = () => page.locator('.ge-selection-overlay').evaluate(canvas => {
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let hash = 0;
for (let i = 0; i < data.length; i += 4) if (data[i + 3]) hash = ((hash * 33) ^ data[i]) >>> 0;
return hash;
});
const firstOverlay = await overlayHash();
expect(firstOverlay).not.toBe(0);
await expect.poll(overlayHash, { timeout: 1200 }).not.toBe(firstOverlay);
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.45, y: 0.42 });
const moved = await editorState(page);
expect(moved.selection.bounds.x).toBeGreaterThan(initial.selection.bounds.x);
expect(moved.selection.bounds.y).toBeGreaterThan(initial.selection.bounds.y);
const layerAfterMove = moved.layers.find(layer => layer.id === moved.activeLayerId);
expect(layerAfterMove.offset).toEqual(layerBefore.offset);
expect(layerAfterMove.pixelHash).toBe(layerBefore.pixelHash);
await page.keyboard.press('ArrowRight');
await expect.poll(async () => (await editorState(page)).selection.bounds.x).toBe(moved.selection.bounds.x + 1);
await page.keyboard.press('q');
await expect(page.locator('#ge-quick-mask-bar')).toBeVisible();
expect((await editorState(page)).quickMaskActive).toBe(true);
const beforePaint = (await editorState(page)).selection.pixelHash;
await dragOnCanvas(page, { x: 0.72, y: 0.72 }, { x: 0.78, y: 0.72 });
await expect.poll(async () => (await editorState(page)).selection.pixelHash).not.toBe(beforePaint);
await page.locator('.ge-quick-mask-done').click();
await expect(page.locator('#ge-quick-mask-bar')).toBeHidden();
expect((await editorState(page)).quickMaskActive).toBe(false);
});
test('marquee supports exact fixed-size and fixed-ratio geometry', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Precise marquee');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await page.locator('#ge-marquee-constraint').selectOption('size');
await page.locator('#ge-marquee-width').fill('80');
await page.locator('#ge-marquee-width').blur();
await page.locator('#ge-marquee-height').fill('60');
await page.locator('#ge-marquee-height').blur();
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height * 0.75);
await expect.poll(async () => (await editorState(page)).selection?.bounds).toEqual({ x: 240, y: 180, width: 80, height: 60 });
await page.locator('#ge-marquee-clear').click();
await page.locator('#ge-marquee-constraint').selectOption('ratio');
await page.locator('#ge-marquee-width').fill('4');
await page.locator('#ge-marquee-width').blur();
await page.locator('#ge-marquee-height').fill('3');
await page.locator('#ge-marquee-height').blur();
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.6, y: 0.3 });
const ratioBounds = (await editorState(page)).selection.bounds;
expect(ratioBounds.width).toBe(160);
expect(ratioBounds.height).toBe(120);
expect(ratioBounds.width / ratioBounds.height).toBeCloseTo(4 / 3, 5);
});
test('transform selection moves, scales, rotates, cancels, and preserves layer pixels', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Transform selection');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.5, y: 0.55 });
const original = await editorState(page);
const originalLayer = original.layers.find(layer => layer.id === original.activeLayerId);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="transform"]').click();
await expect(page.locator('.ge-transform-popup')).toBeVisible();
await expect(page.locator('.ge-transform-popup .ge-adj-title')).toHaveText('Transform Selection');
await dragOnCanvas(page, { x: 0.35, y: 0.4 }, { x: 0.45, y: 0.47 });
const moved = await editorState(page);
expect(moved.selection.bounds.x).toBeGreaterThan(original.selection.bounds.x);
expect(moved.selection.bounds.y).toBeGreaterThan(original.selection.bounds.y);
await page.locator('#ge-transform-w').fill('140');
await page.locator('#ge-transform-w').dispatchEvent('input');
await page.locator('#ge-transform-rot').fill('25');
await page.locator('#ge-transform-rot').dispatchEvent('input');
await page.locator('#ge-transform-apply').click();
let transformed = await editorState(page);
const transformedLayer = transformed.layers.find(layer => layer.id === transformed.activeLayerId);
expect(transformed.selection.pixelHash).not.toBe(original.selection.pixelHash);
expect(transformed.selection.bounds.width).toBeGreaterThan(original.selection.bounds.width);
expect(transformedLayer.offset).toEqual(originalLayer.offset);
expect(transformedLayer.pixelHash).toBe(originalLayer.pixelHash);
await page.keyboard.press('Control+z');
await expect.poll(async () => (await editorState(page)).selection.pixelHash).toBe(original.selection.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="transform"]').click();
await page.locator('#ge-transform-h').fill('40');
await page.locator('#ge-transform-h').dispatchEvent('input');
expect((await editorState(page)).selection.pixelHash).not.toBe(original.selection.pixelHash);
await page.locator('#ge-transform-cancel-btn').click();
transformed = await editorState(page);
expect(transformed.selection.pixelHash).toBe(original.selection.pixelHash);
expect(transformed.redo).toBe(0);
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(250);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="transform"]').click();
const mobilePopup = await page.locator('.ge-transform-popup').boundingBox();
const mobileTitle = await page.locator('.ge-transform-popup .ge-adj-title').boundingBox();
expect(mobilePopup.x).toBeGreaterThanOrEqual(0);
expect(mobilePopup.y).toBeGreaterThanOrEqual(0);
expect(mobilePopup.x + mobilePopup.width).toBeLessThanOrEqual(390);
expect(mobilePopup.y + mobilePopup.height).toBeLessThanOrEqual(844);
expect(mobileTitle.x).toBeGreaterThanOrEqual(mobilePopup.x);
expect(mobileTitle.x + mobileTitle.width).toBeLessThanOrEqual(mobilePopup.x + mobilePopup.width);
await page.locator('#ge-transform-cancel-btn').click();
});
test('named selections support reselect, load, delete, and server-draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Saved selections');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.5, y: 0.6 });
const original = (await editorState(page)).selection;
await page.locator('#ge-selection-menu-btn').click();
await page.locator('#ge-selection-name').fill('Subject');
await page.locator('[data-selection-action="save"]').click();
let current = await editorState(page);
expect(current.savedSelections).toHaveLength(1);
expect(current.savedSelections[0].name).toBe('Subject');
expect(current.savedSelections[0].pixelHash).toBe(original.pixelHash);
await page.locator('[data-selection-action="deselect"]').click();
current = await editorState(page);
expect(current.selection).toBeNull();
expect(current.lastSelection.pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="reselect"]').click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="deselect"]').click();
await dragOnCanvas(page, { x: 0.62, y: 0.15 }, { x: 0.88, y: 0.4 });
expect((await editorState(page)).selection.pixelHash).not.toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('.ge-saved-selection-load', { hasText: 'Subject' }).click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.savedSelections).toHaveLength(1);
expect(current.savedSelections[0].pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('.ge-saved-selection-load', { hasText: 'Subject' }).click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('.ge-saved-selection-delete').click();
expect((await editorState(page)).savedSelections).toHaveLength(0);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('#ge-selection-menu')).toBeHidden();
await page.waitForTimeout(250);
await page.locator('#ge-selection-menu-btn').click();
await expect(page.locator('#ge-selection-menu')).toBeVisible();
const mobileMenu = await page.locator('#ge-selection-menu').boundingBox();
expect(mobileMenu.x).toBeGreaterThanOrEqual(0);
expect(mobileMenu.x + mobileMenu.width).toBeLessThanOrEqual(390);
});
@@ -0,0 +1,171 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
async function openRefine(page) {
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="refine"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
}
async function drawLasso(page, points) {
const box = await page.locator('.ge-main-canvas').boundingBox();
const point = ([x, y]) => ({ x: box.x + box.width * x, y: box.y + box.height * y });
const first = point(points[0]);
await page.mouse.move(first.x, first.y);
await page.mouse.down();
for (const item of points.slice(1)) {
const next = point(item);
await page.mouse.move(next.x, next.y, { steps: 3 });
}
await page.mouse.move(first.x, first.y, { steps: 3 });
await page.mouse.up();
}
test('selection clipboard copy and paste creates an undoable independent layer', async ({ page, request }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection clipboard');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
const before = await editorState(page);
await page.keyboard.press('Control+c');
await page.evaluate(() => {
window.dispatchEvent(new Event('paste', { bubbles: true, cancelable: true }));
});
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Pasted Selection' })).toBeVisible();
const pasted = await editorState(page);
expect(pasted.layers).toHaveLength(before.layers.length + 1);
const pastedLayer = pasted.layers.at(-1);
expect(pasted.activeLayerId).toBe(pastedLayer.id);
expect(pastedLayer.kind).toBe('placed');
expect(pastedLayer.placed.sourceSize).toEqual(pastedLayer.size);
await page.locator('#ge-undo').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length);
await page.locator('#ge-redo').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length + 1);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
const reopenedLayer = reopened.layers.at(-1);
expect(reopenedLayer.kind).toBe('placed');
expect(reopenedLayer.placed.sourceSize).toEqual(pastedLayer.size);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('cut selection is one undoable move from source to new layer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection cut');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
const before = await editorState(page);
const sourceSignature = () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.name === 'Edit');
const data = layer.canvas.getContext('2d').getImageData(0, 0, layer.canvas.width, layer.canvas.height).data;
let hash = 2166136261;
for (const value of data) {
hash ^= value;
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
});
const beforeSource = await sourceSignature();
await page.keyboard.press('Control+x');
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Wand copy' })).toBeVisible();
const cut = await editorState(page);
expect(cut.layers).toHaveLength(before.layers.length + 1);
expect(await sourceSignature()).not.toBe(beforeSource);
await page.locator('#ge-undo').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length);
expect(await sourceSignature()).toBe(beforeSource);
});
test('completed lasso copy preserves the source layer coordinate space', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Offset selection copy');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.layerOffsets.set(state.activeLayerId, { x: 32, y: 24 });
window.galleryEditorComposite?.();
});
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.28, y: 0.3 }, { x: 0.62, y: 0.58 });
await page.locator('.ge-tool-btn[data-tool="lasso"]').click();
await drawLasso(page, [[0.26, 0.26], [0.68, 0.26], [0.68, 0.64], [0.26, 0.64]]);
const before = await editorState(page);
const source = before.layers.find(layer => layer.id === before.activeLayerId);
await page.locator('#ge-lasso-copy').click();
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Wand copy' })).toBeVisible();
const copied = await editorState(page);
const copy = copied.layers.find(layer => layer.name === 'Wand copy');
expect(copy.size).toEqual(source.size);
expect(copy.offset).toEqual(source.offset);
});
test('selection refine previews safely and layer masks round-trip to selection', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection refinement');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.55, y: 0.55 });
const original = (await editorState(page)).selection;
await openRefine(page);
const expand = page.locator('.ge-filter-modal input[data-key="expand"]');
await expand.fill('12');
await expand.dispatchEvent('input');
await expect.poll(async () => (await editorState(page)).selection.bounds.width)
.toBeGreaterThan(original.bounds.width);
await page.locator('.ge-filter-modal [data-action="cancel"]').click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
await openRefine(page);
const appliedExpand = page.locator('.ge-filter-modal input[data-key="expand"]');
await appliedExpand.fill('12');
await appliedExpand.dispatchEvent('input');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const refined = (await editorState(page)).selection;
expect(refined.bounds.width).toBeGreaterThan(original.bounds.width);
expect(refined.pixelHash).not.toBe(original.pixelHash);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
let current = await editorState(page);
const active = current.layers.find(layer => layer.id === current.activeLayerId);
expect(active.masks).toHaveLength(1);
expect(active.masks[0].mode).toBe('layer');
expect(active.masks[0].pixelHash).toBe(refined.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="deselect"]').click();
expect((await editorState(page)).selection).toBeNull();
await page.getByRole('button', { name: 'Load mask as selection' }).click();
current = await editorState(page);
expect(current.selection.pixelHash).toBe(refined.pixelHash);
});
test('lasso uses the shared replace, add, subtract, and intersect modes', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Lasso combine modes');
await page.locator('.ge-tool-btn[data-tool="lasso"]').click();
await drawLasso(page, [[0.1, 0.2], [0.35, 0.2], [0.35, 0.55], [0.1, 0.55]]);
const first = (await editorState(page)).selection;
expect(first.source).toBe('lasso');
await page.locator('#ge-lasso-section [data-wand-mode="add"]').click();
await drawLasso(page, [[0.55, 0.2], [0.85, 0.2], [0.85, 0.55], [0.55, 0.55]]);
const added = (await editorState(page)).selection;
expect(added.bounds.width).toBeGreaterThan(first.bounds.width);
await page.locator('#ge-lasso-section [data-wand-mode="subtract"]').click();
await drawLasso(page, [[0.05, 0.15], [0.4, 0.15], [0.4, 0.6], [0.05, 0.6]]);
const subtracted = (await editorState(page)).selection;
expect(subtracted.bounds.x).toBeGreaterThan(first.bounds.x);
await page.locator('#ge-lasso-section [data-wand-mode="intersect"]').click();
await drawLasso(page, [[0.65, 0.25], [0.78, 0.25], [0.78, 0.48], [0.65, 0.48]]);
const intersected = (await editorState(page)).selection;
expect(intersected.bounds.width).toBeLessThan(subtracted.bounds.width);
});
+334
View File
@@ -0,0 +1,334 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('text edits directly on canvas and remains retained after reopen', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await page.locator('#ge-text-frame-width').fill('260');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvas.x + canvas.width * 0.2, canvas.y + canvas.height * 0.2);
const editor = page.locator('.ge-direct-text-editor');
await expect(editor).toBeVisible();
await editor.fill('Editable canvas title');
await editor.press('Control+Enter');
await expect(editor).toHaveCount(0);
await page.locator('#ge-text-letter-spacing').fill('3.5');
await page.locator('#ge-text-line-height').fill('1.4');
await page.locator('#ge-text-font').selectOption('Georgia');
await page.locator('.ge-text-auto-width-option').click();
await expect(page.locator('#ge-text-auto-width')).toBeChecked();
await expect(page.locator('#ge-text-frame-width')).toBeDisabled();
await page.locator('#ge-text-frame-height').fill('180');
await page.locator('#ge-text-vertical-align').selectOption('bottom');
const before = await editorState(page);
const textLayer = before.layers.find(layer => layer.kind === 'text');
expect(textLayer.text).toMatchObject({
content: 'Editable canvas title',
fontFamily: 'Georgia',
lineHeight: 1.4,
letterSpacing: 3.5,
autoWidth: true,
frameHeight: 180,
verticalAlign: 'bottom',
});
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.kind === 'text').text).toEqual(textLayer.text);
});
test('text paragraph controls remain usable in a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile text layout E2E');
await page.locator('.ge-tool-btn[data-tool="text"]').click();
const section = page.locator('#ge-text-section');
await expect(section).toBeVisible();
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await expect(page.locator('#ge-text-frame-height')).toBeVisible();
await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
});
test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await expect(page.locator('#ge-shape-section')).toBeVisible();
const types = ['rectangle', 'ellipse', 'line', 'polygon'];
for (let index = 0; index < types.length; index += 1) {
const type = types[index];
await page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first().click();
await page.locator(`[data-shape-type="${type}"]`).click();
await dragOnCanvas(
page,
{ x: 0.12 + index * 0.18, y: 0.2 },
{ x: 0.25 + index * 0.18, y: 0.42 },
);
}
let current = await editorState(page);
const shapes = current.layers.filter(layer => layer.kind === 'shape');
expect(shapes.map(layer => layer.shape.type)).toEqual(types);
await page.locator('#ge-shape-sides').fill('7');
await page.locator('#ge-shape-stroke-width').fill('6');
await page.locator('#ge-shape-radius').fill('14');
current = await editorState(page);
const polygon = current.layers.find(layer => layer.kind === 'shape' && layer.shape.type === 'polygon');
expect(polygon.shape).toMatchObject({ sides: 7, strokeWidth: 6, cornerRadius: 14 });
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(originalWidth + 40));
await page.locator('#ge-transform-apply').click();
current = await editorState(page);
const transformed = current.layers.find(layer => layer.id === polygon.id);
expect(transformed.kind).toBe('shape');
expect(transformed.shape.transform.scaleX).toBeGreaterThan(1);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.filter(layer => layer.kind === 'shape').map(layer => layer.shape.type)).toEqual(types);
});
test('gradient shape fill remains editable after reopening the project', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeHidden();
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.65, y: 0.55 });
await page.locator('#ge-shape-gradient-angle').evaluate((angle) => {
const section = angle.closest('#ge-shape-section');
section.querySelector('#ge-shape-fill-type').value = 'linear-gradient';
section.querySelector('#ge-shape-gradient-start').value = '#ff0000';
section.querySelector('#ge-shape-gradient-mid').value = '#00ff00';
section.querySelector('#ge-shape-gradient-mid-enabled').checked = true;
section.querySelector('#ge-shape-gradient-mid-position').value = '42';
section.querySelector('#ge-shape-gradient-end').value = '#0000ff';
section.querySelector('#ge-shape-fill-type').dispatchEvent(new Event('change', { bubbles: true }));
angle.value = '35';
angle.dispatchEvent(new Event('input', { bubbles: true }));
});
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible();
const before = await editorState(page);
const shape = before.layers.find(layer => layer.kind === 'shape');
expect(shape.shape).toMatchObject({
fillType: 'linear-gradient',
gradientStart: '#ff0000',
gradientMid: '#00ff00',
gradientMidEnabled: true,
gradientMidPosition: 42,
gradientEnd: '#0000ff',
gradientAngle: 35,
});
expect(page.locator('.ge-layer-inline-thumb')).toHaveCount(before.layers.length);
const thumbnailColorRange = await page.locator('.ge-layer-inline-thumb').evaluateAll((canvases) => canvases.map(canvas => {
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
return {
red: Math.max(...Array.from(pixels).filter((_, index) => index % 4 === 0)),
blue: Math.max(...Array.from(pixels).filter((_, index) => index % 4 === 2)),
};
}));
expect(thumbnailColorRange.some(({ red, blue }) => red > 180 && blue > 180)).toBe(true);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.id === shape.id).shape).toMatchObject(shape.shape);
});
test('shape gradients retain added stops through editing and reopen', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E');
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
await page.locator('#ge-shape-fill-type').selectOption('linear-gradient');
await page.evaluate(() => {
for (const [id, value] of [['ge-shape-gradient-start', '#ff0000'], ['ge-shape-gradient-end', '#0000ff']]) {
const input = document.getElementById(id);
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
await page.locator('#ge-shape-gradient-add-stop').click();
await expect(page.locator('[data-gradient-extra-stop]')).toHaveCount(1);
await page.evaluate(() => {
const color = document.querySelector('[data-gradient-stop-color]');
color.value = '#00ff00';
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = document.querySelector('[data-gradient-stop-position]');
position.value = '50';
position.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.locator('#ge-shape-gradient-add-stop').click();
await expect(page.locator('[data-gradient-extra-stop]')).toHaveCount(2);
await page.evaluate(() => {
const rows = [...document.querySelectorAll('[data-gradient-extra-stop]')];
const setRow = (row, colorValue, positionValue) => {
const color = row.querySelector('[data-gradient-stop-color]');
color.value = colorValue;
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = row.querySelector('[data-gradient-stop-position]');
position.value = positionValue;
position.dispatchEvent(new Event('input', { bubbles: true }));
};
setRow(rows[0], '#00ff00', '50');
setRow(rows[1], '#ffff00', '75');
});
let current = await editorState(page);
const shape = current.layers.find(layer => layer.kind === 'shape');
expect(shape.shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 50, color: '#00ff00' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
await page.locator('[data-gradient-extra-stop]').first().locator('[data-gradient-stop-remove]').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === shape.id).shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.id === shape.id).shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
});
test('shape gradient stop normalization keeps imported endpoints bounded', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Gradient stop bounds E2E');
const result = await page.evaluate(async () => {
const { MAX_GRADIENT_STOPS, normalizeGradientStops } = await import('/static/js/editor/gradient-stops.js');
const stops = normalizeGradientStops(Array.from({ length: 30 }, (_, index) => ({
position: index * 3,
color: `#${String(index).padStart(6, '0')}`,
})));
return { max: MAX_GRADIENT_STOPS, stops };
});
expect(result.stops).toHaveLength(result.max);
expect(result.stops[0].position).toBe(0);
expect(result.stops.at(-1).position).toBe(100);
});
test('gradient tool paints a reversible drag on the active layer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Gradient tool E2E');
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
await expect(page.locator('#ge-gradient-section')).toBeVisible();
await page.evaluate(() => {
for (const [id, value] of [['ge-gradient-start', '#ff0000'], ['ge-gradient-end', '#0000ff']]) {
const input = document.getElementById(id);
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
const midpoint = document.getElementById('ge-gradient-mid');
midpoint.value = '#00ff00';
midpoint.dispatchEvent(new Event('input', { bubbles: true }));
const enabled = document.getElementById('ge-gradient-mid-enabled');
enabled.checked = true;
enabled.dispatchEvent(new Event('change', { bubbles: true }));
});
await page.locator('#ge-gradient-add-stop').click();
await page.evaluate(() => {
const row = document.querySelector('#ge-gradient-extra-stops [data-gradient-extra-stop]');
const color = row.querySelector('[data-gradient-stop-color]');
color.value = '#ffff00';
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = row.querySelector('[data-gradient-stop-position]');
position.value = '25';
position.dispatchEvent(new Event('input', { bubbles: true }));
});
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(box.x + 8, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 8, box.y + box.height / 2, { steps: 8 });
await page.mouse.up();
await expect.poll(async () => (await editorState(page)).layers.at(-1).effects?.length || 0).toBe(1);
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
const samples = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const rendered = state.documentCompositeCanvas;
const ctx = rendered.getContext('2d');
const y = Math.floor(rendered.height / 2);
return [
ctx.getImageData(4, y, 1, 1).data,
ctx.getImageData(Math.floor(rendered.width / 2), y, 1, 1).data,
ctx.getImageData(rendered.width - 5, y, 1, 1).data,
];
});
expect(samples[0][0]).toBeGreaterThan(220);
expect(samples[0][2]).toBeLessThan(40);
expect(samples[1][1]).toBeGreaterThan(180);
expect(samples[1][0]).toBeLessThan(80);
expect(samples[1][2]).toBeLessThan(80);
expect(samples[2][2]).toBeGreaterThan(220);
expect(samples[2][0]).toBeLessThan(40);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
const reopenedGradient = reopened.layers.find(layer => layer.effects?.some(effect => effect.type === 'linear-gradient'));
expect(reopenedGradient).toBeTruthy();
expect(reopenedGradient.effects.find(effect => effect.type === 'linear-gradient').params.stops).toEqual([
{ position: 0, color: '#ff0000', alpha: 1 },
{ position: 25, color: '#ffff00', alpha: 1 },
{ position: 50, color: '#00ff00', alpha: 1 },
{ position: 100, color: '#0000ff', alpha: 1 },
]);
await page.locator('.ge-effect-sub-item .ge-adj-sub-name').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await expect(page.locator('.ge-filter-row input[data-key="stopColor0"]')).toHaveValue('#ffff00');
await expect(page.locator('.ge-filter-row input[data-key="stopPosition0"]')).toHaveValue('25');
await expect(page.locator('.ge-filter-row input[data-key="stopColor1"]')).toHaveValue('#00ff00');
await page.locator('.ge-filter-row input[data-key="stopColor0"]').fill('#ffff00');
await page.locator('.ge-filter-row input[data-key="stopPosition0"]').fill('30');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const edited = (await editorState(page)).layers
.flatMap(layer => layer.effects || [])
.find(effect => effect.type === 'linear-gradient');
expect(edited.params.stops).toEqual([
{ position: 0, color: '#ff0000', alpha: 1 },
{ position: 30, color: '#ffff00', alpha: 1 },
{ position: 50, color: '#00ff00', alpha: 1 },
{ position: 100, color: '#0000ff', alpha: 1 },
]);
});
test('radial gradient remains retained and survives reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Radial gradient E2E');
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
await page.locator('#ge-gradient-type').selectOption('radial-gradient');
await page.evaluate(() => {
const start = document.getElementById('ge-gradient-start');
start.value = '#ffffff';
start.dispatchEvent(new Event('input', { bubbles: true }));
const end = document.getElementById('ge-gradient-end');
end.value = '#000000';
end.dispatchEvent(new Event('input', { bubbles: true }));
});
await dragOnCanvas(page, { x: 0.5, y: 0.5 }, { x: 0.85, y: 0.5 });
let current = await editorState(page);
const gradient = current.layers.flatMap(layer => layer.effects || []).find(effect => effect.type === 'radial-gradient');
expect(gradient).toBeTruthy();
expect(gradient.params.stops).toEqual([
{ position: 0, color: '#ffffff', alpha: 1 },
{ position: 100, color: '#000000', alpha: 1 },
]);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.flatMap(layer => layer.effects || []).some(effect => effect.type === 'radial-gradient')).toBe(true);
});
@@ -0,0 +1,320 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers');
test('transform frame exposes eight accurate handles and supports edge resize', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform frame');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-transform-popup')).toBeVisible();
const frame = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const { getHandleAt } = await import('/static/js/editor/tools/transform-handles.js');
const geometry = transformFrameGeometry({
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom });
const canvasRect = state.mainCanvas.getBoundingClientRect();
const right = geometry.resizeHandles.find(handle => handle.id === 'r');
return {
width: state.transformPendingW,
ids: geometry.resizeHandles.map(handle => getHandleAt(handle.x, handle.y)),
rightClient: {
x: canvasRect.left + right.x * canvasRect.width / state.mainCanvas.width,
y: canvasRect.top + right.y * canvasRect.height / state.mainCanvas.height,
},
};
});
expect(frame.ids).toEqual(['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l']);
await page.mouse.move(frame.rightClient.x, frame.rightClient.y);
await page.mouse.down();
await page.mouse.move(frame.rightClient.x + 36, frame.rightClient.y, { steps: 6 });
await page.mouse.up();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
})).toBeGreaterThan(frame.width);
});
test('rotated edge resize follows the frame axis and keeps its opposite edge anchored', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Rotated transform frame');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-rot').fill('90');
const frame = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const geometry = transformFrameGeometry({
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom });
const right = geometry.resizeHandles.find(handle => handle.id === 'r');
const canvasRect = state.mainCanvas.getBoundingClientRect();
return {
width: state.transformPendingW,
center: { ...state.transformCenter },
rightClient: {
x: canvasRect.left + right.x * canvasRect.width / state.mainCanvas.width,
y: canvasRect.top + right.y * canvasRect.height / state.mainCanvas.height,
},
};
});
await page.mouse.move(frame.rightClient.x, frame.rightClient.y);
await page.mouse.down();
await page.mouse.move(frame.rightClient.x, frame.rightClient.y + 36, { steps: 6 });
await page.mouse.up();
const after = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { width: state.transformPendingW, center: { ...state.transformCenter } };
});
expect(after.width).toBeGreaterThan(frame.width);
expect(after.center.x).toBeCloseTo(frame.center.x, 4);
expect(after.center.y).toBeGreaterThan(frame.center.y);
});
test('rotated frame moves only from its visible interior and supports keyboard nudging', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform interaction');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-rot').fill('45');
const frame = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.snapEnabled = false;
const rect = state.mainCanvas.getBoundingClientRect();
const toClient = point => ({
x: rect.left + point.x * rect.width / state.mainCanvas.width,
y: rect.top + point.y * rect.height / state.mainCanvas.height,
});
return {
center: { ...state.transformCenter },
emptyCorner: toClient({ x: 5, y: 5 }),
centerClient: toClient(state.transformCenter),
};
});
await page.mouse.move(frame.emptyCorner.x, frame.emptyCorner.y);
await page.mouse.down();
await page.mouse.move(frame.emptyCorner.x + 24, frame.emptyCorner.y + 18);
await page.mouse.up();
const afterEmptyCorner = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
});
expect(afterEmptyCorner.x).toBeCloseTo(frame.center.x, 5);
expect(afterEmptyCorner.y).toBeCloseTo(frame.center.y, 5);
await page.mouse.move(frame.centerClient.x, frame.centerClient.y);
await page.mouse.down();
await page.mouse.move(frame.centerClient.x + 24, frame.centerClient.y + 18, { steps: 4 });
await page.mouse.up();
const afterDrag = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
});
expect(afterDrag.x).toBeGreaterThan(frame.center.x);
expect(afterDrag.y).toBeGreaterThan(frame.center.y);
await page.keyboard.press('ArrowRight');
await page.keyboard.press('Shift+ArrowDown');
const afterKeys = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
});
expect(afterKeys.x).toBeCloseTo(afterDrag.x + 1, 5);
expect(afterKeys.y).toBeCloseTo(afterDrag.y + 10, 5);
const readout = {
x: Number(await page.locator('#ge-transform-x').inputValue()),
y: Number(await page.locator('#ge-transform-y').inputValue()),
w: Number(await page.locator('#ge-transform-w').inputValue()),
h: Number(await page.locator('#ge-transform-h').inputValue()),
angle: Number(await page.locator('#ge-transform-rot').inputValue()),
};
expect(readout.x).toBeCloseTo(afterKeys.x, 2);
expect(readout.y).toBeCloseTo(afterKeys.y, 2);
expect(readout.w).toBeGreaterThan(0);
expect(readout.h).toBeGreaterThan(0);
expect(readout.angle).toBe(45);
await page.locator('#ge-transform-x').fill('250');
await page.locator('#ge-transform-y').fill('180');
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
})).toEqual({ x: 250, y: 180 });
await page.keyboard.press('Escape');
await expect(page.locator('.ge-transform-popup')).toBeHidden();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformActive;
})).toBe(false);
});
test('transform previews remain source-derived and reject unsafe allocations', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Transform source integrity');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
for (let y = 0; y < layer.canvas.height; y += 8) {
for (let x = 0; x < layer.canvas.width; x += 8) {
layer.ctx.fillStyle = ((x / 8 + y / 8) % 2) ? '#f24f5f' : '#27c2a3';
layer.ctx.fillRect(x, y, 8, 8);
}
}
});
const layerSnapshot = () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const pixels = layer.ctx.getImageData(0, 0, layer.canvas.width, layer.canvas.height).data;
let hash = 2166136261;
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
return { width: layer.canvas.width, height: layer.canvas.height, hash: hash >>> 0 };
});
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.8)));
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
const afterSequentialPreviews = await layerSnapshot();
await page.locator('#ge-transform-cancel-btn').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
const afterDirectPreview = await layerSnapshot();
expect(afterSequentialPreviews).toEqual(afterDirectPreview);
const safeWidth = await page.locator('#ge-transform-w').inputValue();
const beforeRejected = await layerSnapshot();
await page.locator('#ge-transform-w').fill('40000');
await expect(page.locator('#toast')).toContainText('dimension limit');
await expect(page.locator('#ge-transform-w')).toHaveValue(safeWidth);
expect(await layerSnapshot()).toEqual(beforeRejected);
await page.locator('#ge-transform-cancel-btn').click();
});
test('mobile touch input can grab every transform handle', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile transform handles');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-transform-popup')).toBeVisible();
const cdp = await context.newCDPSession(page);
const ids = ['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l', 'rot'];
const handlePoint = id => page.evaluate(async handleId => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const geometry = transformFrameGeometry({
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom, rotationInside: handleId === 'rot' });
const handle = geometry.handles.find(item => item.id === handleId);
const rect = state.mainCanvas.getBoundingClientRect();
return {
x: rect.left + handle.x * rect.width / state.mainCanvas.width,
y: rect.top + handle.y * rect.height / state.mainCanvas.height,
};
}, id);
for (let index = 0; index < ids.length; index += 1) {
const expectedId = ids[index];
const point = await handlePoint(expectedId);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: point.x, y: point.y, id: index + 1, radiusX: 4, radiusY: 4 }],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe(expectedId);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe(null);
}
const right = await handlePoint('r');
const widthBefore = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: right.x, y: right.y, id: 20, radiusX: 4, radiusY: 4 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: right.x + 80, y: right.y, id: 20, radiusX: 4, radiusY: 4 }],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
})).toBeGreaterThan(widthBefore);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const center = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const rect = state.mainCanvas.getBoundingClientRect();
return {
x: rect.left + state.transformCenter.x * rect.width / state.mainCanvas.width,
y: rect.top + state.transformCenter.y * rect.height / state.mainCanvas.height,
zoom: state.zoom,
};
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: center.x, y: center.y, id: 30, radiusX: 4, radiusY: 4 }],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe('move');
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [
{ x: center.x - 20, y: center.y, id: 30, radiusX: 4, radiusY: 4 },
{ x: center.x + 20, y: center.y, id: 31, radiusX: 4, radiusY: 4 },
],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe(null);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [
{ x: center.x - 45, y: center.y, id: 30, radiusX: 4, radiusY: 4 },
{ x: center.x + 45, y: center.y, id: 31, radiusX: 4, radiusY: 4 },
],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.zoom;
})).toBeGreaterThan(center.zoom);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
} finally {
await context.close();
}
});
+60
View File
@@ -0,0 +1,60 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { defineConfig } = require('@playwright/test');
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
const baseURL = `http://127.0.0.1:${port}`;
const repositoryRoot = path.join(__dirname, '..', '..');
const defaultPython = process.platform === 'win32'
? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe')
: path.join(repositoryRoot, '.venv', 'bin', 'python');
const python = process.env.ODYSSEUS_TEST_PYTHON || (fs.existsSync(defaultPython) ? defaultPython : 'python');
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-photo-editor-e2e-'));
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH
|| path.join(dataDirectory, 'app.db');
process.env.PHOTO_EDITOR_E2E_DB_PATH = databasePath;
process.env.PHOTO_EDITOR_E2E_DATA_DIR = dataDirectory;
const browserName = process.env.PHOTO_EDITOR_E2E_BROWSER || 'chromium';
if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
}
module.exports = defineConfig({
testDir: path.join(__dirname, 'photo-editor'),
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
timeout: 90_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
globalSetup: require.resolve('./setup.js'),
globalTeardown: require.resolve('./teardown.js'),
use: {
baseURL,
browserName,
headless: true,
viewport: { width: 1440, height: 960 },
extraHTTPHeaders: { 'Accept-Encoding': 'identity' },
serviceWorkers: 'block',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
webServer: {
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
cwd: repositoryRoot,
url: baseURL,
timeout: 120_000,
reuseExistingServer: false,
env: {
...process.env,
AUTH_ENABLED: 'false',
DATABASE_URL: `sqlite:///${databasePath}`,
ODYSSEUS_DATA_DIR: dataDirectory,
ODYSSEUS_STARTUP_WARMUPS: '0',
RESPONSE_COMPRESSION_ENABLED: 'false',
},
},
});
+10
View File
@@ -0,0 +1,10 @@
const fs = require('node:fs');
module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH;
if (!dataDirectory || !databasePath) {
throw new Error('Photo editor E2E data paths were not configured');
}
fs.mkdirSync(dataDirectory, { recursive: true });
};
+7
View File
@@ -0,0 +1,7 @@
const fs = require('node:fs');
module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
if (!dataDirectory) return;
fs.rmSync(dataDirectory, { recursive: true, force: true });
};
+69
View File
@@ -0,0 +1,69 @@
[
{
"id": "clean_v3_open_email_reply",
"kind": "draft",
"user": "Write reply this email saying 8am works for me",
"active_document": {
"title": "New Email",
"language": "email",
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture@example.com>\nReferences: <fixture@example.com>\nX-Source-UID: 999999\n---\n\n---------- Previous message ----------\nCan you confirm the meeting time?\n"
},
"expect_first_tool": "update_document",
"forbidden_tools": [
"create_document",
"edit_document",
"suggest_document",
"manage_documents",
"web_search"
],
"must_mutate": "document_contains_8am"
},
{
"id": "clean_v3_open_email_short_reply",
"kind": "draft",
"user": "Write reply saying 8am works for me",
"active_document": {
"title": "Meeting",
"language": "email",
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture-short@example.com>\nReferences: <fixture-short@example.com>\nX-Source-UID: 999998\n---\n\n---------- Previous message ----------\nCan you confirm the meeting time?\n"
},
"expect_first_tool": "update_document",
"forbidden_tools": [
"create_document",
"edit_document",
"suggest_document",
"manage_documents",
"web_search"
],
"must_mutate": "document_contains_8am"
},
{
"id": "clean_v3_open_email_unspecified_reply",
"kind": "draft",
"user": "Write reply to this email",
"active_document": {
"title": "Meeting",
"language": "email",
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture-unspecified@example.com>\nReferences: <fixture-unspecified@example.com>\nX-Source-UID: 999997\n---\n\n---------- Previous message ----------\nCan you confirm whether tomorrow morning works?\n"
},
"expect_first_tool": "update_document",
"forbidden_tools": [
"create_document",
"edit_document",
"suggest_document",
"manage_documents",
"ui_control",
"web_search"
],
"forbidden_repeat_tools": ["update_document"],
"expect_document_changed": true,
"must_preserve_active_document_all": [
"To:",
"Subject:",
"In-Reply-To:",
"References:",
"X-Source-UID:",
"---"
]
}
]
+12
View File
@@ -0,0 +1,12 @@
{
"fixture": "basic-shapes.png",
"required_facts": {
"text": "ODYSSEUS 42",
"left_object": "red circle",
"right_object": "blue square"
},
"followup": {
"prompt": "What color was the shape on the right?",
"required_answer": "blue"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480" viewBox="0 0 640 480">
<rect width="640" height="480" fill="#ffffff"/>
<text x="320" y="90" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="52" font-weight="bold" fill="#111111">ODYSSEUS 42</text>
<circle cx="190" cy="285" r="90" fill="#e53935"/>
<rect x="370" y="195" width="180" height="180" fill="#1e5bd7"/>
</svg>

After

Width:  |  Height:  |  Size: 413 B

+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Quarterly Sales Dashboard</title>
<style>
body { margin: 0; background: #eef2f7; color: #172033; font: 24px Arial, sans-serif; }
main { width: 960px; height: 640px; box-sizing: border-box; padding: 42px 56px; }
h1 { margin: 0 0 28px; font-size: 38px; }
.cards { display: flex; gap: 20px; margin-bottom: 34px; }
.card { background: white; border-radius: 14px; padding: 18px 24px; box-shadow: 0 3px 12px #17203320; }
.label { color: #64748b; font-size: 18px; }
.value { font-size: 30px; font-weight: 700; margin-top: 5px; }
.healthy { color: #14804a; }
.chart { height: 350px; display: flex; align-items: end; gap: 42px; padding: 0 45px; background: white; border-radius: 14px; box-shadow: 0 3px 12px #17203320; }
.column { width: 135px; text-align: center; font-weight: 700; }
.bar { background: #3976e8; border-radius: 10px 10px 0 0; color: white; padding-top: 10px; box-sizing: border-box; }
.q1 { height: 100px; } .q2 { height: 175px; } .q3 { height: 275px; background: #7c3aed; } .q4 { height: 200px; }
.quarter { padding: 12px 0 18px; color: #334155; }
</style>
</head>
<body>
<main>
<h1>Quarterly Sales Dashboard</h1>
<div class="cards">
<div class="card"><div class="label">Build status</div><div class="value healthy">Healthy</div></div>
<div class="card"><div class="label">API latency</div><div class="value">142 ms</div></div>
<div class="card"><div class="label">Active users</div><div class="value">1,284</div></div>
</div>
<div class="chart" aria-label="Quarterly sales: Q1 20, Q2 35, Q3 55, Q4 40">
<div class="column"><div class="bar q1">20</div><div class="quarter">Q1</div></div>
<div class="column"><div class="bar q2">35</div><div class="quarter">Q2</div></div>
<div class="column"><div class="bar q3">55</div><div class="quarter">Q3</div></div>
<div class="column"><div class="bar q4">40</div><div class="quarter">Q4</div></div>
</div>
</main>
</body>
</html>
View File
+8
View File
@@ -0,0 +1,8 @@
"""Shared imports for calendar route tests."""
def import_calendar_routes():
"""Import the calendar routes module after test stubs are installed."""
import routes.calendar_routes as cal
return cal
+25
View File
@@ -0,0 +1,25 @@
"""Shared loader for CLI scripts under scripts/."""
import importlib.machinery
import importlib.util
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "scripts"
def load_script(script_name):
"""Load a script from scripts/ by name and return it as a module.
The module name is derived from the script name (hyphens become underscores,
with a _cli suffix) giving each script a stable, unique import identity.
Any sys.modules stubs the script needs at import time must be injected via
monkeypatch before calling this function.
"""
module_name = script_name.replace("-", "_") + "_cli"
path = _SCRIPTS_DIR / script_name
loader = importlib.machinery.SourceFileLoader(module_name, str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
+33
View File
@@ -0,0 +1,33 @@
"""Shared database stub helpers for CLI and unit tests."""
import sys
import types
from unittest.mock import MagicMock
def make_core_db_stub(
monkeypatch,
models=(),
*,
attributes=None,
install_core_package=False,
):
"""Create a core.database stub and inject it via monkeypatch.
Always sets SessionLocal. Pass model class names via `models` to set
each as a MagicMock attribute on the stub. Pass `attributes` to override
specific values, and `install_core_package` when the import also needs a
stub parent package.
Returns the stub module for optional further configuration.
"""
if install_core_package:
monkeypatch.setitem(sys.modules, "core", types.ModuleType("core"))
db = types.ModuleType("core.database")
db.SessionLocal = MagicMock()
for name in models:
setattr(db, name, MagicMock())
for name, value in (attributes or {}).items():
setattr(db, name, value)
monkeypatch.setitem(sys.modules, "core.database", db)
return db
+124
View File
@@ -0,0 +1,124 @@
"""Shared fakes for embedding-lane tests."""
class FakeEmbedder:
def __init__(self, dim, model, url):
self.dim = dim
self.model = model
self.url = url
def get_sentence_embedding_dimension(self):
return self.dim
def encode(self, texts, normalize_embeddings=True):
return [[float(i + 1)] * self.dim for i, _ in enumerate(texts)]
class FailingEmbedder(FakeEmbedder):
def encode(self, texts, normalize_embeddings=True):
raise RuntimeError("embedding endpoint rate limited")
class FakeCollection:
def __init__(self, name, metadata=None):
self.name = name
self.metadata = metadata or {}
self.rows = {}
self.dim = None
def count(self):
return len(self.rows)
def add(self, ids, embeddings, documents=None, metadatas=None):
self._check_dim(embeddings)
documents = documents or [None] * len(ids)
metadatas = metadatas or [{}] * len(ids)
for row_id, emb, doc, meta in zip(ids, embeddings, documents, metadatas):
self.rows[row_id] = {"embedding": emb, "document": doc, "metadata": meta}
def upsert(self, ids, embeddings, documents=None, metadatas=None):
self.add(ids, embeddings, documents=documents, metadatas=metadatas)
def get(self, ids=None, include=None, where=None, limit=None):
selected = list(self.rows.items())
if ids is not None:
id_set = set(ids)
selected = [(row_id, row) for row_id, row in selected if row_id in id_set]
if where:
selected = [
(row_id, row)
for row_id, row in selected
if all(row["metadata"].get(k) == v for k, v in where.items())
]
if limit is not None:
selected = selected[:limit]
return {
"ids": [row_id for row_id, _ in selected],
"documents": [row["document"] for _, row in selected],
"metadatas": [row["metadata"] for _, row in selected],
"embeddings": [row["embedding"] for _, row in selected],
}
def query(self, query_embeddings, n_results, where=None, include=None):
self._check_dim(query_embeddings)
rows = self.get(where=where)
ids = rows["ids"][:n_results]
docs = rows["documents"][:n_results]
metas = rows["metadatas"][:n_results]
return {
"ids": [ids],
"documents": [docs],
"metadatas": [metas],
"distances": [[0.1 + i * 0.01 for i in range(len(ids))]],
}
def delete(self, ids):
for row_id in ids:
self.rows.pop(row_id, None)
def _check_dim(self, embeddings):
if not embeddings:
return
dim = len(embeddings[0])
if self.dim is None:
self.dim = dim
elif self.dim != dim:
raise RuntimeError(f"Collection expecting embedding with dimension of {self.dim}, got {dim}")
class FakeChroma:
def __init__(self):
self.collections = {}
self.deleted = []
self.fail_next_add_for = {}
def get_or_create_collection(self, name, metadata=None):
if name not in self.collections:
self.collections[name] = FakeCollection(name, metadata=metadata)
if self.fail_next_add_for.get(name, 0) > 0:
original_add = self.collections[name].add
def fail_once(*args, **kwargs):
self.fail_next_add_for[name] -= 1
self.collections[name].add = original_add
raise RuntimeError("chroma write failed")
self.collections[name].add = fail_once
elif metadata is not None:
self.collections[name].metadata = metadata
return self.collections[name]
def get_collection(self, name):
if name not in self.collections:
raise KeyError(name)
return self.collections[name]
def delete_collection(self, name):
self.deleted.append(name)
self.collections.pop(name, None)
def patch_chroma(monkeypatch, fake):
import src.chroma_client as chroma_client
monkeypatch.setattr(chroma_client, "get_chroma_client", lambda: fake)
+169
View File
@@ -0,0 +1,169 @@
"""Shared helper for saving and restoring Python import state in tests.
Use ``preserve_import_state`` as a context manager around any block that needs
to mutate ``sys.modules`` or parent-package attributes temporarily. On exit
(normal or exception), every named module is restored to exactly the state it
had before the block — present, absent, or carrying a parent-package attribute.
Use ``clear_module`` to drop a single module from both ``sys.modules`` and its
parent-package attribute (e.g. before forcing a fresh import inside the block).
Use ``clear_fake_database_modules`` to evict a *stubbed* ``core.database`` (and
its companion ``src.database``) that another test left in import state, without
touching a real ``core.database`` loaded from disk.
Use ``clear_fake_endpoint_resolver_modules`` to evict a *stubbed*
``src.endpoint_resolver`` (and the route modules that imported it) that another
test left in import state, without touching a real ``src.endpoint_resolver``
loaded from disk.
Background: importing ``routes.session_routes`` also sets ``session_routes`` on
the parent ``routes`` package object. A ``from routes import session_routes``
or ``import routes.session_routes as X`` statement resolves through that parent
attribute, so restoring ``sys.modules`` alone is not sufficient — the parent
attribute must be restored too. This helper handles both.
Restoration in ``preserve_import_state`` is two-phased: all ``sys.modules``
entries are written back first, then all parent-package attributes. This means
parent-attr restoration always resolves the parent through the already-restored
``sys.modules``, so results are deterministic regardless of argument order —
safe for callers that pass both a parent package and a child module.
"""
import sys
from contextlib import contextmanager
_ABSENT = object()
def _save_one(dotted_name):
saved_mod = sys.modules.get(dotted_name, _ABSENT)
pkg_name, _, attr = dotted_name.rpartition(".")
pkg = sys.modules.get(pkg_name)
saved_attr = getattr(pkg, attr, _ABSENT) if pkg is not None else _ABSENT
return saved_mod, saved_attr
def _restore_parent_attr(dotted_name, saved_attr):
pkg_name, _, attr = dotted_name.rpartition(".")
pkg = sys.modules.get(pkg_name)
if pkg is None:
return
if saved_attr is _ABSENT:
if hasattr(pkg, attr):
delattr(pkg, attr)
else:
setattr(pkg, attr, saved_attr)
def _restore_one(dotted_name, saved_mod, saved_attr):
if saved_mod is _ABSENT:
sys.modules.pop(dotted_name, None)
else:
sys.modules[dotted_name] = saved_mod
_restore_parent_attr(dotted_name, saved_attr)
def clear_module(dotted_name):
"""Remove a module from sys.modules and its parent-package attribute."""
_restore_one(dotted_name, _ABSENT, _ABSENT)
def clear_fake_database_modules():
"""Evict a *stubbed* ``core.database`` (and ``src.database``) from import state.
Test-only. Some tests install a fake ``core.database`` — a stub module with
no on-disk ``__file__`` — into ``sys.modules`` and onto the ``core`` package.
A later test that needs the real database module must evict that stub first,
or its ``import core.database`` resolves to the fake.
This is deliberately conservative and mirrors the per-file helpers it
replaces:
* It acts only when ``core.database`` is a fake/stub, detected by a missing
string ``__file__``. A real ``core.database`` loaded from disk is left
untouched, as is the case where nothing is cached.
* When it does act, it also drops the cached ``src.database`` entry.
* It removes the ``core.database`` parent-package attribute only when that
attribute is the same fake object being evicted.
"""
parent = sys.modules.get("core")
attr = getattr(parent, "database", None) if parent is not None else None
mod = sys.modules.get("core.database") or attr
if mod is None or isinstance(getattr(mod, "__file__", None), str):
return
sys.modules.pop("core.database", None)
sys.modules.pop("src.database", None)
if parent is not None and attr is mod:
delattr(parent, "database")
def clear_fake_endpoint_resolver_modules(*extra_modules):
"""Evict a *stubbed* ``src.endpoint_resolver`` (and dependent route modules).
Test-only. Several route tests need the *real* ``src.endpoint_resolver`` URL
helpers, but another test may have installed a fake — a stub module with no
on-disk ``__file__`` — into ``sys.modules`` and onto the ``src`` package
during collection. The route modules (``routes.model_routes`` and any extras
passed in, e.g. ``routes.chat_routes``) get cached against that fake on first
import, so they must be evicted too.
Conservative, mirroring ``clear_fake_database_modules`` and the per-file
guards it replaces:
* It acts only when ``src.endpoint_resolver`` is a fake/stub, detected by a
falsy ``__file__`` (missing, ``None``, or empty string) — exactly the
truthiness check the old inline guards used. A real resolver loaded from
disk carries a truthy ``__file__`` and is left untouched, as is the case
where nothing is cached. When the resolver is real, the dependent route
modules are left untouched too.
* When it does act, it drops ``routes.model_routes`` plus every name in
``extra_modules``.
* It removes the ``src.endpoint_resolver`` parent-package attribute only when
that attribute is the same fake object being evicted.
Behavior delta vs. the old bare ``sys.modules.pop(...)`` guards: dependent
modules are dropped via :func:`clear_module`, which also clears the parent
``routes`` package attribute (e.g. ``routes.model_routes``), not just the
``sys.modules`` entry. This prevents a stale parent attribute from shadowing
the fresh import — the same parent-attr handling the rest of this helper
family already applies.
"""
parent = sys.modules.get("src")
attr = getattr(parent, "endpoint_resolver", None) if parent is not None else None
mod = sys.modules.get("src.endpoint_resolver") or attr
if mod is None or getattr(mod, "__file__", None):
return
sys.modules.pop("src.endpoint_resolver", None)
if parent is not None and attr is mod:
delattr(parent, "endpoint_resolver")
clear_module("routes.model_routes")
for name in extra_modules:
clear_module(name)
@contextmanager
def preserve_import_state(*module_names):
"""Save and restore sys.modules entries and parent-package attributes.
Restoration is two-phased: sys.modules entries are written back first,
then parent-package attributes. This ensures parent-attr restoration always
sees the correctly restored parent in sys.modules, regardless of argument
order — safe for callers that pass both a parent and a child module.
On exit (normal or exception), each named module is restored to its state
before the block — whether present, absent, or carrying a parent attribute.
"""
saved = {name: _save_one(name) for name in module_names}
try:
yield
finally:
# Phase 1: restore all sys.modules entries.
for name, (saved_mod, _) in saved.items():
if saved_mod is _ABSENT:
sys.modules.pop(name, None)
else:
sys.modules[name] = saved_mod
# Phase 2: restore all parent-package attributes.
for name, (_, saved_attr) in saved.items():
_restore_parent_attr(name, saved_attr)
+29
View File
@@ -0,0 +1,29 @@
"""Construct a file-backed temp sqlite DB for tests.
Only builds the SQLAlchemy objects from the repeated temp-sqlite block. It
does not patch modules, manage cleanup, or own any global state — the caller
keeps the returned objects alive and binds ``SessionLocal`` where needed.
"""
import tempfile
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
def make_temp_sqlite(metadata):
"""Build a file-backed temp sqlite database and create its tables.
Returns ``(SessionLocal, engine, tmpfile)``. The caller must keep these
references alive (temp file and engine GC are the caller's concern) and
bind ``SessionLocal`` onto whatever module the code under test reads.
"""
tmpfile = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
engine = create_engine(
f"sqlite:///{tmpfile.name}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return SessionLocal, engine, tmpfile
+775
View File
@@ -0,0 +1,775 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
class ClassList {
constructor() { this.values = new Set(); }
add(...names) { names.filter(Boolean).forEach(name => this.values.add(name)); }
remove(...names) { names.forEach(name => this.values.delete(name)); }
contains(name) { return this.values.has(name); }
toggle(name, force) {
if (force === undefined) force = !this.contains(name);
force ? this.add(name) : this.remove(name);
return force;
}
}
class Style {
constructor() { this.values = {}; this.display = ''; this.cssText = ''; }
setProperty(name, value) { this.values[name] = value; this[name] = value; }
removeProperty(name) { delete this.values[name]; delete this[name]; }
}
class Element {
constructor(tagName, documentRef) {
this.tagName = String(tagName || 'div').toUpperCase();
this.ownerDocument = documentRef;
this.children = [];
this.parentElement = null;
this.attributes = {};
this.dataset = {};
this.classList = new ClassList();
this.style = new Style();
this._listeners = new Map();
this._innerHTML = '';
this._textContent = '';
}
set id(value) { this.attributes.id = String(value); }
get id() { return this.attributes.id || ''; }
set className(value) {
this.classList.values = new Set(String(value || '').split(/\s+/).filter(Boolean));
}
get className() { return Array.from(this.classList.values).join(' '); }
set innerHTML(value) {
this._innerHTML = String(value || '');
if (!this._innerHTML) {
this.children.forEach(child => { child.parentElement = null; });
this.children = [];
}
}
get innerHTML() { return this._innerHTML; }
set textContent(value) {
this._textContent = String(value ?? '');
this.children.forEach(child => { child.parentElement = null; });
this.children = [];
}
get textContent() {
return this._textContent + this.children.map(child => child.textContent).join('');
}
setAttribute(name, value) {
const text = String(value);
this.attributes[name] = text;
if (name === 'class') this.className = text;
if (name.startsWith('data-')) {
const key = name.slice(5).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
this.dataset[key] = text;
}
}
getAttribute(name) { return this.attributes[name] ?? null; }
appendChild(child) {
child.parentElement = this;
this.children.push(child);
return child;
}
append(...children) { children.forEach(child => this.appendChild(child)); }
replaceChildren(...children) {
this.children.forEach(child => { child.parentElement = null; });
this.children = [];
children.forEach(child => this.appendChild(child));
}
contains(candidate) {
if (candidate === this) return true;
return descendants(this).includes(candidate);
}
getBoundingClientRect() {
const width = Number.parseFloat(
this.style.values['--settings-sidebar-width']
|| this.style.width
// This is only a desktop-mode fixture (>620px), not a CSS assertion.
|| (this.classList.contains('settings-modal-content') ? '800' : '220'),
);
return { width, height: 600, left: 0, right: width, top: 0, bottom: 600 };
}
addEventListener(type, handler, options = {}) {
if (!this._listeners.has(type)) this._listeners.set(type, []);
this._listeners.get(type).push({ handler, once: !!options.once });
}
removeEventListener(type, handler) {
const entries = this._listeners.get(type) || [];
this._listeners.set(type, entries.filter(entry => entry.handler !== handler));
}
dispatchEvent(event) {
event.target ||= this;
event.currentTarget = this;
const entries = [...(this._listeners.get(event.type) || [])];
for (const entry of entries) {
entry.handler.call(this, event);
if (entry.once) this.removeEventListener(event.type, entry.handler);
}
}
click() {
this.dispatchEvent({ type: 'click', preventDefault() {}, stopPropagation() {} });
}
matches(selector) { return matchesSelector(this, selector); }
querySelector(selector) { return queryAll(this, selector)[0] || null; }
querySelectorAll(selector) { return queryAll(this, selector); }
closest(selector) {
let node = this;
while (node) {
if (matchesSelector(node, selector)) return node;
node = node.parentElement;
}
return null;
}
}
function simpleMatch(element, selector) {
const text = selector.trim();
if (!text) return false;
const id = text.match(/#([\w-]+)/);
if (id && element.id !== id[1]) return false;
for (const match of text.matchAll(/\.([\w-]+)/g)) {
if (!element.classList.contains(match[1])) return false;
}
for (const match of text.matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) {
const actual = element.getAttribute(match[1]);
if (actual === null) return false;
if (match[2] !== undefined && actual !== match[2]) return false;
}
return true;
}
function matchesSelector(element, selector) {
return selector.split(',').some(part => simpleMatch(element, part));
}
function descendants(root) {
const result = [];
const visit = node => {
for (const child of node.children || []) {
result.push(child);
visit(child);
}
};
visit(root);
return result;
}
function queryAll(root, selector) {
const selectors = selector.split(',').map(item => item.trim()).filter(Boolean);
const nodes = descendants(root);
const result = [];
for (const candidate of nodes) {
if (selectors.some(sel => simpleMatch(candidate, sel))) result.push(candidate);
}
return result;
}
class DocumentShim {
constructor() {
this.body = new Element('body', this);
this.head = new Element('head', this);
this.listeners = new Map();
}
createElement(tag) { return new Element(tag, this); }
getElementById(id) {
return [this.body, this.head, ...descendants(this.body), ...descendants(this.head)]
.find(node => node.id === id) || null;
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector) {
return [...queryAll(this.body, selector), ...queryAll(this.head, selector)];
}
addEventListener(type, handler) {
if (!this.listeners.has(type)) this.listeners.set(type, []);
this.listeners.get(type).push(handler);
}
removeEventListener(type, handler) {
this.listeners.set(type, (this.listeners.get(type) || []).filter(item => item !== handler));
}
dispatch(type, event) {
for (const handler of [...(this.listeners.get(type) || [])]) handler(event);
}
}
function buildFixture(document) {
const modal = document.createElement('div');
modal.id = 'settings-modal';
document.body.appendChild(modal);
const header = document.createElement('div');
header.className = 'modal-header';
modal.appendChild(header);
const close = document.createElement('button');
close.className = 'close-btn';
header.appendChild(close);
const content = document.createElement('div');
content.className = 'settings-modal-content modal-content';
modal.appendChild(content);
const nav = document.createElement('div');
nav.className = 'settings-sidebar';
content.appendChild(nav);
const sidebarToggle = document.createElement('button');
sidebarToggle.id = 'settings-sidebar-toggle';
nav.appendChild(sidebarToggle);
const sidebarHandle = document.createElement('div');
sidebarHandle.id = 'settings-sidebar-resize-handle';
nav.appendChild(sidebarHandle);
const sidebarContent = document.createElement('div');
sidebarContent.className = 'settings-sidebar-content';
nav.appendChild(sidebarContent);
const finder = document.createElement('div');
sidebarContent.appendChild(finder);
const searchInput = document.createElement('input');
searchInput.id = 'settings-nav-search';
finder.appendChild(searchInput);
const searchResults = document.createElement('div');
searchResults.id = 'settings-nav-search-results';
searchResults.classList.add('hidden');
finder.appendChild(searchResults);
const panels = document.createElement('div');
content.appendChild(panels);
const makeTab = (id, active = false) => {
const button = document.createElement('button');
button.setAttribute('data-settings-tab', id);
if (active) button.classList.add('active');
sidebarContent.appendChild(button);
const panel = document.createElement('section');
panel.setAttribute('data-settings-panel', id);
if (!active) panel.classList.add('hidden');
panels.appendChild(panel);
return { button, panel };
};
const panelIds = [
'services',
'added-models',
'ai',
'search',
'integrations',
'email',
'reminders',
'appearance',
'shortcuts',
'account',
'tools',
'users',
'system',
];
const settingsPanels = Object.fromEntries(
panelIds.map((id, index) => [id, makeTab(id, index === 0)]),
);
return {
modal,
header,
close,
content,
services: settingsPanels.services,
appearance: settingsPanels.appearance,
system: settingsPanels.system,
settingsPanels,
searchInput,
searchResults,
sidebar: nav,
sidebarToggle,
sidebarHandle,
};
}
function moduleSource(relativePath) {
let source = fs.readFileSync(
path.join(__dirname, '../../static/js/settings', relativePath),
'utf8',
);
// The production files are real ES modules. This lightweight VM harness
// removes imports because dependencies are loaded into the same context,
// but each module still needs its own lexical scope so private const/let
// bindings do not collide across modules.
source = source.replace(/^\s*import[\s\S]*?;\s*$/gm, '');
// Preserve exported API on globalThis while keeping all non-exported
// bindings private inside the module block below.
source = source
.replace(
/\bexport\s+function\s+([A-Za-z_$][\w$]*)\s*\(/g,
'globalThis.$1 = function $1(',
)
.replace(
/\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=/g,
'globalThis.$1 =',
)
.replace(
/\bexport\s+let\s+([A-Za-z_$][\w$]*)\s*=/g,
'globalThis.$1 =',
)
.replace(
/\bexport\s+var\s+([A-Za-z_$][\w$]*)\s*=/g,
'globalThis.$1 =',
);
return `{\n${source}\n}`;
}
(function runTests() {
const document = new DocumentShim();
const fixture = buildFixture(document);
const dragCalls = [];
const dockCalls = [];
const removedWindowListeners = [];
const storage = new Map();
const context = {
console,
document,
localStorage: {
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
setItem(key, value) { storage.set(key, String(value)); },
removeItem(key) { storage.delete(key); },
},
window: {
removeEventListener: (...args) => removedWindowListeners.push(args),
addEventListener() {},
},
makeWindowDraggable: (...args) => dragCalls.push(args),
clearDockSide: (...args) => dockCalls.push(args),
setTimeout: callback => { callback(); return 1; },
WeakSet,
};
vm.createContext(context);
vm.runInContext(moduleSource('registry.js'), context, { filename: 'registry.js' });
vm.runInContext(moduleSource('search.js'), context, { filename: 'search.js' });
vm.runInContext(moduleSource('sidebar.js'), context, { filename: 'sidebar.js' });
vm.runInContext(moduleSource('navigation.js'), context, { filename: 'navigation.js' });
vm.runInContext(moduleSource('lifecycle.js'), context, { filename: 'lifecycle.js' });
vm.runInContext(moduleSource('dom.js'), context, { filename: 'dom.js' });
const results = [];
const check = (test, pass, detail = '') => results.push({ test, pass: Boolean(pass), detail });
const registryPanelIds = vm.runInContext(
'SETTINGS_PANELS.map(panel => panel.id).join(",")',
context,
);
const registryGroupIds = vm.runInContext(
'SETTINGS_GROUPS.map(group => group.id).join(",")',
context,
);
check(
'Settings registry preserves the existing sidebar panel order',
registryPanelIds === [
'services',
'added-models',
'ai',
'search',
'integrations',
'email',
'reminders',
'appearance',
'shortcuts',
'account',
'tools',
'users',
'system',
].join(','),
);
check(
'Settings registry defines the intended information-architecture groups',
registryGroupIds === [
'models',
'communications',
'experience',
'account',
'administration',
].join(','),
);
check(
'Settings registry keeps services, models, integrations and admin panels on the existing admin controller',
['services', 'added-models', 'integrations', 'tools', 'users', 'system']
.every(id => context.isAdminManagedSettingsTab(id))
&& ['ai', 'search', 'email', 'reminders', 'appearance', 'shortcuts', 'account']
.every(id => !context.isAdminManagedSettingsTab(id)),
);
check(
'Settings registry distinguishes admin-only visibility from admin-controlled routing',
['tools', 'users', 'system'].every(id => context.isAdminOnlySettingsTab(id))
&& ['services', 'added-models', 'integrations']
.every(id => !context.isAdminOnlySettingsTab(id)),
);
check(
'Settings registry provides search metadata without owning search UI',
context.getSettingsPanelSearchText('appearance').includes('theme')
&& context.getSettingsPanelSearchText('email').includes('smtp')
&& context.getSettingsPanelSearchText('missing') === '',
);
check(
'Settings registry exposes group membership in sidebar order',
context.getSettingsPanelsForGroup('communications')
.map(panel => panel.id)
.join(',') === 'integrations,email,reminders',
);
check(
'Settings registry matches every production-style tab and panel in the DOM fixture',
context.getSettingsRegistryIssues(fixture.modal).length === 0,
);
check(
'Settings search resolves metadata terms in registry order',
context.searchSettingsPanels('provider', { isAdmin: true })
.map(panel => panel.id)
.join(',') === 'services,added-models,search',
);
check(
'Settings search excludes admin-only panels for non-admin users',
context.searchSettingsPanels('agent tools', { isAdmin: false }).length === 0
&& context.searchSettingsPanels('agent tools', { isAdmin: true })
.map(panel => panel.id)
.join(',') === 'tools',
);
check(
'Settings search requires all query terms',
context.searchSettingsPanels('appearance theme', { isAdmin: false })
.map(panel => panel.id)
.join(',') === 'appearance',
);
let searchedPanel = null;
context.bindSettingsSearch(fixture.modal, {
isAdmin: () => false,
openPanel(tab) { searchedPanel = tab; },
});
fixture.searchInput.value = 'theme';
fixture.searchInput.dispatchEvent({
type: 'input',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder renders matching production registry results',
!fixture.searchResults.classList.contains('hidden')
&& fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 1
&& fixture.searchResults.querySelector('[data-settings-search-result]').dataset.settingsSearchResult === 'appearance',
);
const finderOutsideTarget = document.createElement('div');
fixture.content.appendChild(finderOutsideTarget);
fixture.modal.dispatchEvent({
type: 'mousedown',
target: finderOutsideTarget,
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder click-away hides results while retaining the query',
fixture.searchInput.value === 'theme'
&& fixture.searchResults.classList.contains('hidden'),
);
fixture.searchInput.dispatchEvent({
type: 'focus',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder refocus restores results for an unchanged retained query',
fixture.searchInput.value === 'theme'
&& !fixture.searchResults.classList.contains('hidden')
&& fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 1,
);
fixture.searchInput.dispatchEvent({
type: 'keydown',
key: 'Enter',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder Enter opens the first result and resets the finder',
searchedPanel === 'appearance'
&& fixture.searchInput.value === ''
&& fixture.searchResults.classList.contains('hidden'),
);
searchedPanel = null;
fixture.searchInput.value = 'agent tools';
fixture.searchInput.dispatchEvent({
type: 'input',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder does not expose admin-only results to non-admin users',
fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 0
&& fixture.searchResults.textContent !== '',
);
fixture.searchInput.value = 'theme';
fixture.searchInput.dispatchEvent({
type: 'input',
preventDefault() {},
stopPropagation() {},
});
fixture.searchInput.dispatchEvent({
type: 'keydown',
key: 'Escape',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder Escape clears results without closing Settings',
fixture.searchInput.value === ''
&& fixture.searchResults.classList.contains('hidden'),
);
context.setSettingsSidebarWidth(fixture.modal, 280);
check(
'Settings sidebar width is clamped and applied through the production controller',
fixture.sidebar.style.values['--settings-sidebar-width'] === '280px',
);
const widthStorageKey = 'odysseus-settings-sidebar-width';
storage.clear();
const firstRunSidebar = buildFixture(document);
context.bindSettingsSidebar(firstRunSidebar.modal);
check(
'Settings sidebar first bind uses the declared 220px default when storage is empty',
firstRunSidebar.sidebar.style.values['--settings-sidebar-width'] === '220px',
);
storage.clear();
storage.set(widthStorageKey, '276');
const storedSidebar = buildFixture(document);
context.bindSettingsSidebar(storedSidebar.modal);
check(
'Settings sidebar first bind restores a valid stored width',
storedSidebar.sidebar.style.values['--settings-sidebar-width'] === '276px',
);
storage.clear();
storage.set(widthStorageKey, 'not-a-number');
const malformedSidebar = buildFixture(document);
context.bindSettingsSidebar(malformedSidebar.modal);
check(
'Settings sidebar first bind falls back to default for malformed storage',
malformedSidebar.sidebar.style.values['--settings-sidebar-width'] === '220px',
);
storage.clear();
storage.set(widthStorageKey, '10');
const minimumSidebar = buildFixture(document);
context.bindSettingsSidebar(minimumSidebar.modal);
storage.clear();
storage.set(widthStorageKey, '999');
const maximumSidebar = buildFixture(document);
context.bindSettingsSidebar(maximumSidebar.modal);
check(
'Settings sidebar first bind clamps stored widths to declared bounds',
minimumSidebar.sidebar.style.values['--settings-sidebar-width'] === '150px'
&& maximumSidebar.sidebar.style.values['--settings-sidebar-width'] === '340px',
);
context.setSettingsSidebarCollapsed(fixture.modal, true);
check(
'Settings sidebar collapse leaves the compact navigation rail state active',
fixture.sidebar.classList.contains('settings-sidebar-collapsed')
&& fixture.sidebarToggle.getAttribute('aria-label') === 'Expand settings navigation',
);
context.setSettingsSidebarCollapsed(fixture.modal, false, { width: 260 });
check(
'Settings sidebar expansion restores the requested expanded width',
!fixture.sidebar.classList.contains('settings-sidebar-collapsed')
&& fixture.sidebar.style.values['--settings-sidebar-width'] === '260px',
);
// Keyboard behavior must be exercised on a fixture that went through the
// real binding path; direct controller calls above intentionally do not bind
// event listeners.
storage.clear();
const keyboardSidebar = buildFixture(document);
context.bindSettingsSidebar(keyboardSidebar.modal);
context.setSettingsSidebarWidth(
keyboardSidebar.modal,
150,
{ persist: false },
);
keyboardSidebar.sidebarHandle.dispatchEvent({
type: 'keydown',
key: 'ArrowLeft',
preventDefault() {},
stopPropagation() {},
});
check(
'ArrowLeft at the sidebar minimum collapses the rail instead of getting stuck at 150px',
keyboardSidebar.sidebar.classList.contains('settings-sidebar-collapsed'),
);
context.setSettingsSidebarCollapsed(
keyboardSidebar.modal,
false,
{ width: 220, persist: false },
);
check(
'resizable Settings separator exposes its current ARIA range and value',
keyboardSidebar.sidebarHandle.getAttribute('aria-valuemin') === '150'
&& keyboardSidebar.sidebarHandle.getAttribute('aria-valuemax') === '340'
&& keyboardSidebar.sidebarHandle.getAttribute('aria-valuenow') === '220',
);
check('byId resolves elements through the production DOM helper', context.byId('settings-modal') === fixture.modal);
context.activateSettingsPanel(fixture.modal, 'appearance');
check(
'activateSettingsPanel switches sidebar and panel state together',
fixture.appearance.button.classList.contains('active')
&& !fixture.appearance.panel.classList.contains('hidden')
&& !fixture.services.button.classList.contains('active')
&& fixture.services.panel.classList.contains('hidden'),
);
check('getActiveSettingsTab reports the active panel', context.getActiveSettingsTab(fixture.modal) === 'appearance');
let activated = null;
let delegated = null;
context.bindSettingsNavigation(fixture.modal, {
openAdminTab(tab) { delegated = tab; return tab === 'system'; },
onPanelActivated(tab) { activated = tab; },
});
fixture.services.button.click();
check(
'normal navigation activates locally and notifies the coordinator',
activated === 'services' && fixture.services.button.classList.contains('active'),
);
activated = null;
fixture.system.button.click();
check(
'admin navigation delegates without performing a second local activation',
delegated === 'system' && activated === null && fixture.services.button.classList.contains('active'),
);
context.bindSettingsDrag(fixture.modal);
check(
'drag binding preserves the existing Settings drag contract',
dragCalls.length === 1
&& dragCalls[0][0] === fixture.modal
&& dragCalls[0][1].content === fixture.content
&& dragCalls[0][1].header === fixture.header
&& dragCalls[0][1].enableDock === true
&& dragCalls[0][1].skipSelector === 'button, input, select, .theme-opacity-wrap',
);
let disconnected = 0;
fixture.modal.classList.add('modal-left-docked');
fixture.content.style.setProperty('left', '123px');
fixture.content.dataset._tileZone = 'left';
fixture.content._leftDockNavObs = {
navObs: { disconnect() { disconnected += 1; } },
reanchor() {},
};
context.resetSettingsWindowPlacement(fixture.modal);
check(
'window placement reset clears docking observers and inline placement',
!fixture.modal.classList.contains('modal-left-docked')
&& dockCalls.some(call => call[0] === 'left' && call[1] === fixture.modal)
&& disconnected === 1
&& !('_tileZone' in fixture.content.dataset)
&& fixture.content.style.left === undefined
&& removedWindowListeners.some(call => call[0] === 'resize'),
);
fixture.modal.classList.add('modal-right-docked', 'hidden');
context.showSettingsModal(fixture.modal);
check(
'showSettingsModal restores a hidden modal before showing it',
!fixture.modal.classList.contains('hidden')
&& !fixture.modal.classList.contains('modal-right-docked')
&& dockCalls.some(call => call[0] === 'right'),
);
let closeCount = 0;
context.bindSettingsClose(fixture.modal, {
closeSettings() { closeCount += 1; },
isTouchInsideModal() { return false; },
});
const form = document.createElement('div');
form.id = 'unified-intg-form';
form.style.display = '';
form.appendChild(document.createElement('input'));
fixture.content.appendChild(form);
document.dispatch('keydown', {
key: 'Escape',
preventDefault() {},
stopPropagation() {},
});
check(
'Escape closes an inner integration editor before closing Settings',
form.style.display === 'none' && form.children.length === 0 && closeCount === 0,
);
document.dispatch('keydown', {
key: 'Escape',
preventDefault() {},
stopPropagation() {},
});
check('Escape closes Settings when no nested flow is active', closeCount === 1);
const popover = document.createElement('div');
popover.setAttribute('data-popover-open', '1');
popover.style.display = 'block';
fixture.content.appendChild(popover);
document.dispatch('keydown', {
key: 'Escape',
preventDefault() {},
stopPropagation() {},
});
check('Escape leaves Settings open while a transient popover is active', closeCount === 1);
popover.classList.add('hidden');
context.hideSettingsModal(fixture.modal);
check(
'hideSettingsModal preserves the closing animation fallback semantics',
fixture.modal.classList.contains('hidden') && !fixture.content.classList.contains('modal-closing'),
);
console.log(JSON.stringify(results));
if (results.some(result => !result.pass)) process.exitCode = 1;
})();
File diff suppressed because it is too large Load Diff
+289
View File
@@ -0,0 +1,289 @@
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.applyModelRouteEventState = (await import('/static/js/chatModelProvenance.js')).applyModelRouteEventState;
window.createTerminalStreamError = (await import('/static/js/chatStreamErrors.js')).createTerminalStreamError;
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(); }
});
+277
View File
@@ -0,0 +1,277 @@
// Tests for the live-thinking throttle that bounds DOM work during long
// reasoning streams (see static/js/liveThinkingThrottle.js).
//
// The throttle's contract is what the terminal paths in chat.js lean on:
// a burst of deltas becomes ONE commit carrying the latest text; flush()
// lands trailing text synchronously and cannot double-commit; cancel()
// guarantees nothing lands after a stream is finished or backgrounded.
//
// Timers are injected, so this runs with no DOM and no real clock.
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createIncrementalDisplayProjector,
createLiveThinkingThrottle,
createThinkingAnalysisGate,
stripLiveThinkingTags,
} from '../static/js/liveThinkingThrottle.js';
function fakeTimers() {
let nextId = 1;
const callbacks = new Map();
const delays = [];
return {
schedule(callback, delay) {
const id = nextId++;
callbacks.set(id, callback);
delays.push(delay);
return id;
},
cancel(id) {
callbacks.delete(id);
},
run(id) {
const callback = callbacks.get(id);
assert.ok(callback, `missing timer ${id}`);
callbacks.delete(id);
callback();
},
pendingIds() {
return [...callbacks.keys()];
},
delays,
};
}
test('coalesces a burst and commits only the latest text after 100 ms', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update('a');
throttle.update('ab');
throttle.update('abc');
assert.deepEqual(commits, []);
assert.deepEqual(timers.delays, [100], 'a burst must schedule exactly one commit');
const [timer] = timers.pendingIds();
timers.run(timer);
assert.deepEqual(commits, ['abc']);
});
test('commit count stays flat as the stream grows', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
// 500 deltas arriving inside one window is the regression this guards:
// the old code committed once per delta, so work grew with stream length.
let text = '';
for (let i = 0; i < 500; i++) {
text += 'token ';
throttle.update(text);
}
assert.deepEqual(commits, []);
assert.equal(timers.pendingIds().length, 1);
timers.run(timers.pendingIds()[0]);
assert.equal(commits.length, 1);
assert.equal(commits[0], text);
});
test('prepares a 200K cumulative stream only at scheduled commit cadence', () => {
const timers = fakeTimers();
const commits = [];
let prepareCalls = 0;
let scannedCharacters = 0;
const throttle = createLiveThinkingThrottle((value) => commits.push(value), {
...timers,
prepare(value) {
prepareCalls += 1;
scannedCharacters += value.length;
return stripLiveThinkingTags(value);
},
});
const delta = 'reasoning '.repeat(10); // 100 characters
let cumulative = '';
for (let i = 0; i < 2000; i++) {
cumulative += delta;
throttle.update(cumulative);
}
assert.equal(cumulative.length, 200_000);
assert.equal(prepareCalls, 0, 'cumulative extraction must not run per delta');
assert.equal(timers.pendingIds().length, 1);
timers.run(timers.pendingIds()[0]);
assert.equal(prepareCalls, 1);
assert.equal(scannedCharacters, 200_000);
assert.deepEqual(commits, [cumulative]);
});
test('ordinary answers and reasoning deltas do not request cumulative analysis', () => {
const startsReasoning = (text) => /^\s*thinking(?:\s+process)?\s*:/i.test(text);
const ordinaryGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
let ordinary = '';
let ordinaryAnalyses = 0;
for (let i = 0; i < 2000; i++) {
ordinary += i === 0 ? 'Here is the answer. ' : 'answer '.repeat(10);
if (ordinaryGate.shouldAnalyze(ordinary)) ordinaryAnalyses += 1;
}
assert.equal(ordinaryAnalyses, 0);
const thinkingGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
let thinking = 'Thin';
assert.equal(thinkingGate.shouldAnalyze(thinking), false);
thinking += 'king: inspect the problem';
assert.equal(thinkingGate.shouldAnalyze(thinking), true);
for (let i = 0; i < 2000; i++) {
thinking += ' reasoning'.repeat(10);
assert.equal(thinkingGate.shouldAnalyze(thinking, { isThinking: true, nonTagThinking: true }), false);
}
thinking += '\n\nHere is the answer';
assert.equal(thinkingGate.shouldAnalyze(thinking, { isThinking: true, nonTagThinking: true }), true);
const whitespaceGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
let whitespaceThinking = ' '.repeat(250);
assert.equal(whitespaceGate.shouldAnalyze(whitespaceThinking), false);
whitespaceThinking += 'Thinking: bounded probe';
assert.equal(whitespaceGate.shouldAnalyze(whitespaceThinking), true);
});
test('split namespaced closes and false-close deadlines request analysis', () => {
let clock = 100;
const gate = createThinkingAnalysisGate({ now: () => clock });
let text = '<mm:think>x</mm:';
assert.equal(gate.shouldAnalyze(text, { isThinking: true }), true, 'fresh opening tag is analyzed');
text += 'think>answer';
assert.equal(gate.shouldAnalyze(text, { isThinking: true }), true, 'split namespaced close is analyzed');
text += ' still waiting';
assert.equal(gate.shouldAnalyze(text, { isThinking: true, recheckAt: 500 }), false);
clock = 500;
text += ' next delta';
assert.equal(gate.shouldAnalyze(text, { isThinking: true, recheckAt: 500 }), true);
const attributedGate = createThinkingAnalysisGate();
let attributed = `<think data-provider="${'x'.repeat(400)}"`;
assert.equal(attributedGate.shouldAnalyze(attributed), false);
attributed += '>reasoning';
assert.equal(attributedGate.shouldAnalyze(attributed), true, 'bounded carry preserves split tag attributes');
});
test('display projection is append-only and filters a structured tail once', () => {
let filterCalls = 0;
let filteredCharacters = 0;
const projector = createIncrementalDisplayProjector((text) => {
filterCalls += 1;
filteredCharacters += text.length;
return text.replace(/\[TOOL_CALL\][\s\S]*$/i, '');
});
let text = '';
for (let i = 0; i < 2000; i++) {
const delta = i === 0 ? 'Here is the answer. ' : 'ordinary text ';
text += delta;
assert.equal(projector.append(delta, text), text);
}
assert.equal(filterCalls, 0, 'ordinary deltas never run the cumulative filter');
text += '[TOOL_';
projector.append('[TOOL_', text);
text += 'CALL]{"name":"read"}';
const beforeToolPayload = projector.append('CALL]{"name":"read"}', text);
for (let i = 0; i < 2000; i++) {
const delta = 'payload ';
text += delta;
assert.equal(projector.append(delta, text), beforeToolPayload);
}
assert.equal(filterCalls, 1, 'structured payload filtering happens only at its boundary');
assert.ok(filteredCharacters < text.length, 'filter work is bounded by the first structured boundary');
});
test('literal escaped tags survive and malformed live tags retain trailing text', () => {
assert.equal(
stripLiveThinkingTags('&lt;think&gt;literal&lt;/think&gt;'),
'&lt;think&gt;literal&lt;/think&gt;',
);
assert.equal(
stripLiveThinkingTags('<think>first</think> middle <thinking mode="deep">trailing'),
'first middle trailing',
);
assert.equal(stripLiveThinkingTags('answer with 2 < 3 and 5 > 4'), 'answer with 2 < 3 and 5 > 4');
});
test('terminal flush prepares and commits the complete trailing cumulative text', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), {
...timers,
prepare: stripLiveThinkingTags,
});
throttle.update('<think>reasoning without a closing tag');
assert.equal(throttle.flush(), true);
assert.deepEqual(commits, ['reasoning without a closing tag']);
assert.deepEqual(timers.pendingIds(), []);
});
test('independent throttles cannot commit cancelled text into another session', () => {
const timers = fakeTimers();
const commits = [];
const first = createLiveThinkingThrottle((value) => commits.push(['first', value]), timers);
const second = createLiveThinkingThrottle((value) => commits.push(['second', value]), timers);
first.update('stale first-session text');
second.update('current second-session text');
first.cancel();
assert.equal(second.flush(), true);
assert.deepEqual(timers.pendingIds(), []);
assert.deepEqual(commits, [['second', 'current second-session text']]);
});
test('flush synchronously preserves trailing text and cancels the pending callback', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update('trailing text');
assert.equal(throttle.flush(), true);
assert.deepEqual(commits, ['trailing text']);
assert.deepEqual(timers.pendingIds(), []);
assert.equal(throttle.flush(), false, 'clean flush must not duplicate the commit');
});
test('cancel discards pending work without a late DOM commit', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update('stale session text');
throttle.cancel();
assert.deepEqual(timers.pendingIds(), []);
assert.deepEqual(commits, []);
});
test('a cancelled throttle accepts new work again', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update('discarded');
throttle.cancel();
throttle.update('fresh');
assert.equal(throttle.flush(), true);
assert.deepEqual(commits, ['fresh']);
});
test('coerces nullish updates instead of committing undefined', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update(null);
throttle.flush();
assert.deepEqual(commits, ['']);
});
@@ -0,0 +1,69 @@
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';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const markdownPath = path.join(__dirname, '..', 'static', 'js', 'markdown.js');
let src = fs.readFileSync(markdownPath, 'utf8');
src = src.replace(
/import uiModule from '\.\/ui\.js';/,
'const uiModule = { esc: (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\\"/g, "&quot;") };'
);
src = src.replace(
/import \{ splitTableRow \} from '\.\/markdown\/tableRow\.js';/,
'const splitTableRow = (row) => row.split("|").filter((cell) => cell.trim() !== "");'
);
src = src.replace(
/import \{ replaceEmojiShortcodes, hasEmojiShortcode \} from '\.\/emojiShortcodes\.js';/,
'const hasEmojiShortcode = (t) => !!t && t.indexOf(":") !== -1 && /:[a-z0-9_+-]{1,40}:/i.test(t); const replaceEmojiShortcodes = (t) => t;'
);
src = src.replace(/export function /g, 'function ');
src = src.replace(/export const /g, 'const ');
src = src.replace(/export default markdownModule;?/g, '');
src += '\nthis.__mdToHtml = mdToHtml;';
class MutationObserver {
observe() {}
disconnect() {}
}
const sandbox = {
console,
URL,
MutationObserver,
localStorage: { getItem() { return '[]'; }, setItem() {} },
document: {
body: { classList: { contains() { return true; } } },
addEventListener() {},
querySelectorAll() { return []; },
getElementById() { return null; },
contains() { return true; },
},
window: {
location: { origin: 'http://localhost' },
katex: null,
mermaid: null,
},
};
vm.createContext(sandbox);
vm.runInContext(src, sandbox, { filename: markdownPath });
const input = [
'> ```html',
'> <script>',
'> newWindow.addEventListener(\'click\', () => {',
'> desktop.appendChild(newWindow);',
'> });',
'> </script>',
'> ```',
].join('\n');
const html = sandbox.__mdToHtml(input);
assert.equal(html.includes('___ALLOWED_HTML_'), false, html);
assert.equal(html.includes('appendChild'), true, html);
console.log('ok');
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {expectedNoteTitles,compareNoteState} from '../scripts/note_test_oracle.mjs';
import {scoreCalls} from '../scripts/compare_schema_thinking.mjs';
const rows=[{id:'a123',title:'Japan',content:'body',pinned:false},{id:'b123',title:'Today',content:'keep'}];
const call=args=>({function:{name:'manage_notes',arguments:JSON.stringify(args)}});
test('shared expectations cover negation, exceptions and invalid cases',()=>{
assert.deepEqual(expectedNoteTitles('keep_all',['Japan','Today']),[]);
assert.deepEqual(expectedNoteTitles('contrast',['Groceries','Japan','Today']),['Groceries']);
assert.deepEqual(expectedNoteTitles('except_one',['Groceries','Japan','Today']),['Groceries','Today']);
assert.throws(()=>expectedNoteTitles('unregistered',[]));
});
test('state oracle detects edits and additions, not just disappearing IDs',()=>{
assert.equal(compareNoteState(rows,rows).unchanged,true);
assert.equal(compareNoteState(rows,[rows[1]],['a123']).exact,true);
assert.equal(compareNoteState(rows,[{...rows[1],content:'changed'}],['a123']).exact,false);
assert.deepEqual(compareNoteState(rows,[{...rows[0],pinned:true},rows[1]]).changed_fields,['pinned']);
assert.equal(compareNoteState(rows,[...rows,{id:'new',title:'extra'}]).added_count,1);
assert.equal(compareNoteState(rows,[{...rows[0],archived:true},rows[1]],['a123']).exact,false);
});
test('duplicate deletion cannot pass exact proposal check',()=>{
const result=scoreCalls([call({action:'delete',id:'a123'}),call({action:'delete',title:'Japan'})],rows,['Japan']);
assert.equal(result.exact_target_proposal,false);
assert.equal(result.duplicate_targets,1);
});
test('stale ID with valid unique title follows actual backend fallback',()=>{
assert.equal(scoreCalls([call({action:'delete',id:'stale',title:'Japan'})],rows,['Japan']).exact_target_proposal,true);
assert.equal(scoreCalls([{function:{name:'manage_notes',arguments:'null'}}],rows,[]).exact_target_proposal,false);
});
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
test('research primary actions use compact mobile sizing and retain desktop sizing', async () => {
const css = await readFile(new URL('../static/style.css', import.meta.url), 'utf8');
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.equal(button.width, 24);
assert.equal(button.height, 22);
assert.equal(button.icon, 10);
assert.equal(button.labelHidden, true);
} else {
assert.equal(button.height, 20);
assert.equal(button.icon, 10);
assert.equal(button.labelHidden, false);
}
}
}
} finally { await browser.close(); }
});
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""Focused test selection runner for the pytest taxonomy markers (issue #3442).
This wraps ``pytest -m`` selection over the ``area_*`` / ``sub_*`` markers that
``tests/conftest.py`` adds at collection time (issue #3491) so focused
validation is repeatable and less error-prone than hand-written marker
expressions. It builds a pytest command line and either prints it (``--dry-run``)
or runs it.
Examples:
tests/run_focus.py --area security
tests/run_focus.py --area services --sub-area cookbook
tests/run_focus.py --keyword taxonomy -- --maxfail=1 -q
tests/run_focus.py --fast
tests/run_focus.py --area services --fast --durations 25
This script imports no production code and changes no test behavior. It only
constructs and (optionally) executes a pytest invocation.
"""
from __future__ import annotations
import argparse
import shlex
import subprocess
import sys
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
TESTS_DIR = Path(__file__).resolve().parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from tests._taxonomy import discover_markers, normalize_marker_name # noqa: E402
# The canonical taxonomy areas, mirroring the ``area_*`` markers declared in
# pyproject.toml and produced by tests/_taxonomy.py.
AREAS: tuple[str, ...] = (
"security",
"routes",
"services",
"cli",
"js",
"helpers",
"unit",
"uncategorized",
)
# Backward-compatible aggregate selectors for focused runs whose original
# monolithic files were split into more specific taxonomy sub-areas.
SUB_AREA_ALIASES: dict[str, tuple[str, ...]] = {
"service_health": (
"service_health_chromadb",
"service_health_search",
"service_health_ntfy",
"service_health_email",
"service_health_providers",
"service_health_collect",
),
"embedding": ("embedding", "embedding_memory"),
}
def normalize_sub_area(value: str) -> str:
"""Normalize a CLI sub-area value and remove an optional ``sub_`` prefix."""
token = normalize_marker_name(value)
if token.startswith("sub_"):
token = token.removeprefix("sub_")
if not token:
raise argparse.ArgumentTypeError(
f"invalid sub-area {value!r}: must contain at least one letter or digit"
)
return token
def discover_sub_areas(tests_dir: Path = TESTS_DIR) -> frozenset[str]:
"""Discover valid taxonomy sub-areas from Python test filenames."""
paths = list(tests_dir.rglob("test_*.py"))
paths += list(tests_dir.rglob("*_test.py"))
markers = discover_markers(paths)
return frozenset(
marker.removeprefix("sub_")
for marker in markers
if marker.startswith("sub_")
)
def non_negative_int(value: str) -> int:
"""argparse type: a non-negative int (0 means "show all" for --durations)."""
number = int(value)
if number < 0:
raise argparse.ArgumentTypeError(f"must be >= 0, got {value!r}")
return number
def non_negative_float(value: str) -> float:
"""argparse type: a non-negative float (seconds threshold for --durations-min)."""
number = float(value)
if number < 0:
raise argparse.ArgumentTypeError(f"must be >= 0, got {value!r}")
return number
def sub_area_type(valid_sub_areas: frozenset[str]) -> Callable[[str], str]:
"""Build an argparse converter that accepts only discovered sub-areas."""
def validate(value: str) -> str:
sub_area = normalize_sub_area(value)
if sub_area not in valid_sub_areas:
raise argparse.ArgumentTypeError(
f"unknown sub-area {value!r}; choose a discovered taxonomy sub-area"
)
return sub_area
return validate
def _sub_area_marker_expression(sub_area: str) -> str:
"""Build the marker expression for a sub-area, including narrow aliases."""
aliases = SUB_AREA_ALIASES.get(sub_area, (sub_area,))
markers = [f"sub_{alias}" for alias in aliases]
return " or ".join(markers)
@dataclass(frozen=True)
class FocusSelection:
"""A single focused-selection request, decoupled from argparse and pytest."""
area: str | None = None
sub_area: str | None = None
keyword: str | None = None
last_failed: bool = False
fast: bool = False
durations: int | None = None
durations_min: float | None = None
pytest_args: tuple[str, ...] = field(default_factory=tuple)
@property
def has_focus(self) -> bool:
"""True when at least one focusing selector (not just pass-through) is set.
Duration visibility (``durations`` / ``durations_min``) is reporting
only, not a selector, so it does not count as focus on its own.
"""
return bool(
self.area
or self.sub_area
or self.keyword
or self.last_failed
or self.fast
)
def build_marker_expression(
area: str | None, sub_area: str | None, fast: bool = False
) -> str | None:
"""Build the ``-m`` marker expression from area, sub-area, and the fast lane.
The fast lane adds ``not slow`` and composes with any area/sub-area with
``and``. Returns ``None`` when nothing is given so the caller can omit ``-m``.
"""
parts: list[str] = []
if area:
parts.append(f"area_{area}")
if sub_area:
sub_expression = _sub_area_marker_expression(sub_area)
if " or " in sub_expression:
sub_expression = f"({sub_expression})"
parts.append(sub_expression)
if fast:
parts.append("not slow")
if not parts:
return None
return " and ".join(parts)
def build_pytest_command(
selection: FocusSelection, python: str | None = None
) -> list[str]:
"""Build the pytest argv list for ``selection``.
No shell is involved; the result is a plain argv list for subprocess. The
interpreter defaults to the one running this script (the project venv when
invoked as ``.venv/bin/python tests/run_focus.py``).
"""
command = [python or sys.executable, "-m", "pytest"]
marker_expression = build_marker_expression(
selection.area, selection.sub_area, selection.fast
)
if marker_expression:
command += ["-m", marker_expression]
if selection.keyword:
command += ["-k", selection.keyword]
if selection.last_failed:
command += ["--last-failed", "--last-failed-no-failures=none"]
if selection.durations is not None:
command += [f"--durations={selection.durations}"]
if selection.durations_min is not None:
command += [f"--durations-min={selection.durations_min}"]
command += list(selection.pytest_args)
return command
def selection_from_args(namespace: argparse.Namespace) -> FocusSelection:
"""Convert parsed argparse values into a ``FocusSelection``."""
return FocusSelection(
area=namespace.area,
sub_area=namespace.sub_area,
keyword=namespace.keyword,
last_failed=namespace.last_failed,
fast=namespace.fast,
durations=namespace.durations,
durations_min=namespace.durations_min,
pytest_args=tuple(namespace.pytest_args),
)
def build_parser(
valid_sub_areas: frozenset[str] | None = None,
) -> argparse.ArgumentParser:
"""Build the argument parser for the focused runner."""
if valid_sub_areas is None:
valid_sub_areas = discover_sub_areas()
valid_sub_areas = frozenset(valid_sub_areas) | frozenset(SUB_AREA_ALIASES)
parser = argparse.ArgumentParser(
prog="run_focus.py",
description=(
"Run a focused subset of the test suite using the area_*/sub_* "
"taxonomy markers. Combine --area and --sub-area to intersect them."
),
epilog=(
"Pass extra pytest arguments after a literal -- separator, e.g.: "
"run_focus.py --area services -- --maxfail=1 -q"
),
)
parser.add_argument(
"--area",
choices=AREAS,
help="select tests in one taxonomy area (marker area_<area>)",
)
parser.add_argument(
"--sub-area",
type=sub_area_type(valid_sub_areas),
metavar="NAME",
help="select tests in a sub-area (marker sub_<name>); combinable with --area",
)
parser.add_argument(
"-k",
"--keyword",
help="pass a keyword expression through to pytest -k",
)
parser.add_argument(
"--last-failed",
action="store_true",
help="re-run only tests that failed on the last run (pytest --last-failed)",
)
parser.add_argument(
"--fast",
action="store_true",
help="fast lane: exclude tests marked slow (adds 'not slow'); composable with --area/--sub-area",
)
parser.add_argument(
"--durations",
type=non_negative_int,
metavar="N",
help="report the N slowest tests (pytest --durations=N, 0 shows all); not a focus selector",
)
parser.add_argument(
"--durations-min",
type=non_negative_float,
metavar="SECONDS",
help="minimum duration to report with --durations (pytest --durations-min)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="print the pytest command without executing it",
)
parser.add_argument(
"pytest_args",
nargs="*",
metavar="-- PYTEST_ARGS",
help="extra arguments forwarded to pytest after a literal --",
)
return parser
def run(
argv: Sequence[str] | None = None,
executor: Callable[[list[str]], int] = subprocess.call,
) -> int:
"""Parse ``argv``, build the pytest command, and run or print it.
``executor`` is injected so tests can assert on the constructed command
without spawning a process. It must accept an argv list and return an exit
code, matching ``subprocess.call``.
"""
parser = build_parser()
namespace = parser.parse_args(argv)
selection = selection_from_args(namespace)
if not selection.has_focus:
parser.error(
"no focus selected: pass at least one of --area, --sub-area, "
"--keyword, --last-failed, or --fast (--durations is reporting only)"
)
if selection.durations_min is not None and selection.durations is None:
parser.error(
"--durations-min has no effect without --durations; pass "
"--durations N as well"
)
command = build_pytest_command(selection)
if namespace.dry_run:
print(shlex.join(command))
return 0
return executor(command)
def main() -> int:
"""Console entry point."""
return run(sys.argv[1:])
if __name__ == "__main__":
raise SystemExit(main())
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Report-only randomized test-order runner (issue #3973).
Runs pytest with the collected test items shuffled by a seeded RNG so
order-sensitive tests (hidden coupling through shared import state, module
caches, databases, etc.) surface locally. The seed is always printed, so any
failing order is reproducible with ``--seed``.
This runner is report-only: it is not wired into CI, adds no gate, and does
not change normal pytest collection or ordering. Failures it discovers should
be fixed in separate scoped PRs, not silenced here.
Examples:
python3 tests/run_order_report.py --seed 123 -- tests/cli/ -q
python3 tests/run_order_report.py -- tests/cli/ -q # generates and prints a seed
The shuffle is applied through a local ``pytest_collection_modifyitems`` hook
passed to ``pytest.main`` as an in-process plugin; no conftest or global
plugin is involved. Reproduction requires the reported working directory,
seed, pytest arguments, and test environment. The exit code is pytest's own.
"""
from __future__ import annotations
import argparse
import random
import shlex
import sys
from collections.abc import Callable, Sequence
from pathlib import Path
# Seeds are kept in the non-negative 32-bit range so they stay short enough to
# copy from a report line into a reproduction command.
SEED_MAX = 2**32 - 1
def shuffle_items(items: list, seed: int) -> None:
"""Deterministically shuffle ``items`` in place using ``seed``."""
random.Random(seed).shuffle(items)
class OrderShuffle:
"""Local pytest plugin that shuffles collected items with a fixed seed."""
def __init__(self, seed: int):
self.seed = seed
def pytest_collection_modifyitems(self, items: list) -> None:
shuffle_items(items, self.seed)
def generate_seed() -> int:
"""Generate a fresh seed for a run that did not pass ``--seed``."""
return random.SystemRandom().randint(0, SEED_MAX)
def seed_type(value: str) -> int:
"""argparse type: a seed in ``[0, SEED_MAX]``."""
number = int(value)
if not 0 <= number <= SEED_MAX:
raise argparse.ArgumentTypeError(
f"seed must be between 0 and {SEED_MAX}, got {value!r}"
)
return number
def build_parser() -> argparse.ArgumentParser:
"""Build the argument parser for the order-sensitivity runner."""
parser = argparse.ArgumentParser(
prog="run_order_report.py",
description=(
"Run pytest with randomized test order to surface order-sensitive "
"tests. Report-only: prints the seed used and propagates pytest's "
"exit code; it changes no normal pytest behavior."
),
epilog=(
"Pass pytest targets and options after a literal -- separator, "
"e.g.: run_order_report.py --seed 123 -- tests/cli/ -q"
),
)
parser.add_argument(
"--seed",
type=seed_type,
help="shuffle seed; omitted: a seed is generated and printed",
)
parser.add_argument(
"pytest_args",
nargs="*",
metavar="-- PYTEST_ARGS",
help="pytest targets/options forwarded after a literal --",
)
return parser
def runner_path() -> str:
"""Return an absolute path for copy-pasteable reproduction commands."""
return str(Path(__file__).resolve())
def print_report_header(seed: int, pytest_args: Sequence[str]) -> None:
"""Print the seed and an exact reproduction command before running."""
repro = [
sys.executable,
runner_path(),
"--seed",
str(seed),
"--",
*pytest_args,
]
print(f"[order-report] working directory: {Path.cwd()}")
print(f"[order-report] shuffling test order with seed {seed}")
print(
"[order-report] reproduce from this working directory with the same "
"test environment:"
)
print(f"[order-report] reproduce with: {shlex.join(repro)}")
def print_report_footer(seed: int, exit_code: int) -> None:
"""Print the outcome with the seed again, after possibly long pytest output."""
outcome = "no failures" if exit_code == 0 else f"pytest exit code {exit_code}"
print(
f"[order-report] seed {seed}: {outcome} "
"(report-only; fix order-sensitive failures in separate scoped PRs)"
)
def run(
argv: Sequence[str] | None = None,
pytest_main: Callable[..., int] | None = None,
) -> int:
"""Parse ``argv``, run pytest with shuffled item order, and report the seed.
``pytest_main`` is injected so tests can assert on the forwarded arguments
and plugin without running a nested pytest. It must match ``pytest.main``:
accept ``(args, plugins=...)`` and return an exit code.
"""
namespace = build_parser().parse_args(argv)
seed = namespace.seed if namespace.seed is not None else generate_seed()
pytest_args = list(namespace.pytest_args)
print_report_header(seed, pytest_args)
if pytest_main is None:
import pytest
pytest_main = pytest.main
exit_code = int(pytest_main(pytest_args, plugins=[OrderShuffle(seed)]))
print_report_footer(seed, exit_code)
return exit_code
def main() -> int:
"""Console entry point."""
return run(sys.argv[1:])
if __name__ == "__main__":
raise SystemExit(main())
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {recordsIn,scoreCalls,auditHistory,reformatNoteResult} from '../scripts/compare_schema_thinking.mjs';
const records=[{id:'aaaaaaaa-0000-0000-0000-000000000000',title:'Japan'},
{id:'bbbbbbbb-0000-0000-0000-000000000000',title:'Today'}];
const call=(args)=>({function:{name:'manage_notes',arguments:JSON.stringify(args)}});
test('proposal grading resolves supported title and ID references, not concatenations',()=>{
assert.equal(scoreCalls([call({action:'delete',title:'Japan Today'})],records,['Japan','Today']).exact_target_proposal,false);
assert.equal(scoreCalls([call({action:'delete',id:'aaaaaaaa'}),call({action:'delete',title:'Today'})],records,['Japan','Today']).exact_target_proposal,true);
});
test('negative controls fail if any record is deleted',()=>{
assert.equal(scoreCalls([],records,[]).exact_target_proposal,true);
assert.equal(scoreCalls([call({action:'delete',title:'Japan'})],records,[]).exact_target_proposal,false);
});
test('missing targets are not hidden by repeated calls or exploratory reads',()=>{
const result=scoreCalls([call({action:'delete',title:'Japan'}),call({action:'delete',title:'Japan'}),call({action:'list'})],records,['Japan','Today']);
assert.equal(result.exact_target_proposal,false);
assert.equal(result.duplicate_targets,1);
assert.deepEqual(result.missing_targets,['Today']);
});
test('audit checks actual tool result bytes and native call pairing',()=>{
const result={role:'tool',tool_call_id:'c1',content:records.map(r=>`- [${r.id}] **${r.title}**`).join('\n')};
const messages=[{role:'user',content:'notes'},
{role:'assistant',tool_calls:[{id:'c1'}]},result,{role:'user',content:'delete both'}];
const request={messages,tools:[],chat_template_kwargs:{enable_thinking:false}};
assert.deepEqual(recordsIn(messages),records);
assert.equal(auditHistory(request,[{messages}],records.map(r=>r.id)).exact_prior_note_result_preserved,true);
const broken={...request,messages:[{...result,content:result.content+' changed'}]};
const audit=auditHistory(broken,[{messages}],records.map(r=>r.id));
assert.equal(audit.exact_prior_note_result_preserved,false);
assert.equal(audit.orphan_tool_results,1);
});
test('format-only variants retain IDs, titles, ordering, suffixes and wrapper fields',()=>{
const rows=[{...records[0],suffix:' [PINNED] #travel'},
{...records[1],title:'Today "special"',suffix:' [checklist] #todo'}];
const text=rows.map(r=>`- [${r.id}] **${r.title}**${r.suffix}`).join('\n');
const wrapped=JSON.stringify({results:text,exit_code:0,extra:'unchanged'});
const quoted=JSON.parse(reformatNoteResult(wrapped,'quoted'));
assert.equal(quoted.extra,'unchanged');
assert.equal(quoted.exit_code,0);
assert.equal(quoted.results,rows.map(r=>`- [${r.id}] ${JSON.stringify(r.title)}${r.suffix}`).join('\n'));
const jsonl=JSON.parse(reformatNoteResult(wrapped,'jsonl'));
assert.deepEqual(jsonl.results.split('\n').map(l=>JSON.parse(l)),rows);
assert.throws(()=>reformatNoteResult(text+'\nAdditional body text','jsonl'));
});
+24
View File
@@ -0,0 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { skillsSummaryMetrics, filterSkillsByQuickFilter, skillNeedsReview } from '../static/js/skillsMetrics.js';
test('approval uses the configured threshold for every item, not array index', () => {
const rows = ['one', 'two', 'three'].map(name => ({ name, status: 'published', audit_verdict: 'pass', confidence: 0.9, baseline_verdict: 'same' }));
assert.equal(skillsSummaryMetrics(rows, 0.85).approved, 3);
assert.equal(skillsSummaryMetrics(rows, 0.85).review, 0);
assert.equal(filterSkillsByQuickFilter(rows, 'approved', 0.95).length, 0);
assert.equal(filterSkillsByQuickFilter(rows, 'draft', 0.95).length, 3);
});
test('review badges agree with approval and keep unaudited skills queued', () => {
assert.equal(skillNeedsReview({ status: 'draft' }), false);
assert.equal(skillNeedsReview({ status: 'draft', audit_verdict: 'pass', confidence: 0.95 }), true);
assert.equal(skillNeedsReview({ status: 'published', audit_verdict: 'pass', confidence: 0.95, necessity: { necessary: false } }), true);
assert.equal(skillNeedsReview({ status: 'published', source: 'builtin' }), false);
});
test('built-ins and drafts have distinct filters and archived skills remain recoverable', () => {
const rows = [{ name: 'shipped', source: 'builtin', status: 'published' }, { name: 'bad', status: 'binned' }];
assert.deepEqual(filterSkillsByQuickFilter(rows, 'builtin').map(s => s.name), ['shipped']);
assert.deepEqual(filterSkillsByQuickFilter(rows, 'draft').map(s => s.name), ['bad']);
});
+27
View File
@@ -0,0 +1,27 @@
// A spread of markdown samples exercising the constructs the renderer supports.
// Used by the streaming-invariant fuzz test (fed token-by-token) and the renderer
// integration test. Keep samples small but structurally varied — the fuzz test
// runs every prefix of every sample, so length is quadratic on cost.
export const CORPUS = [
['plain paragraph', 'Just a single sentence of text.'],
['two paragraphs', 'First paragraph here.\n\nSecond paragraph here.'],
['three paragraphs', 'Alpha block.\n\nBravo block.\n\nCharlie block.'],
['atx headings', '# Title\n\nIntro line.\n\n## Section\n\nBody text.'],
['setext heading', 'The Title\n=========\n\nA paragraph under it.'],
['inline formatting', 'Some **bold**, *italic*, `code`, and a [link](https://x.com).'],
['tight unordered list', '- one\n- two\n- three\n\ndone'],
['ordered list then text', 'Before\n\n1. first\n2. second\n3. third\n\nAfter'],
['loose list then paragraph', '- a\n\n- b\n\n- c\n\nClosing paragraph.'],
['nested list', '- top\n - nested one\n - nested two\n- back to top\n\nend'],
['blockquote', '> quoted line one\n> quoted line two\n\nplain after'],
['thematic break', 'above the line\n\n---\n\nbelow the line'],
['python code fence', 'Run this:\n\n```python\nprint("hi")\nfor i in range(3):\n print(i)\n```\n\nThat prints numbers.'],
['fence with blank lines inside', '```js\nconst a = 1;\n\nconst b = 2;\n```\n\nafter the code'],
['two consecutive fences', '```\nfirst block\n```\n\n```\nsecond block\n```\n\ntail'],
['mermaid diagram', 'Diagram:\n\n```mermaid\ngraph TD\nA-->B\n```\n\nafter diagram'],
['gfm table', 'Data:\n\n| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n\nafter table'],
[
'mixed document',
'# Report\n\nIntro paragraph with a `symbol`.\n\n```python\nx = 1\n```\n\n- bullet one\n- bullet two\n\n> a quote\n\nFinal words.',
],
];
+107
View File
@@ -0,0 +1,107 @@
// The centerpiece correctness test: stream every corpus sample in token-by-token,
// driving the segmenter exactly as the renderer will, and assert the freeze/tail
// split stays render-equivalent to a single full render at EVERY step.
//
// finalized-html (accumulated from committed deltas) + render(live tail) === render(prefix)
//
// This is run with no DOM and no safety net, so any segmenter bug fails here
// rather than reaching the browser.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadMarkdown, normalizeRender } from './markdownHarness.mjs';
import { splitFinalized } from '../../static/js/streamingSegmenter.js';
import { CORPUS } from './corpus.mjs';
const md = await loadMarkdown();
const render = (t) => md.mdToHtml(t);
// The two render pipelines chat.js actually feeds streamed text through. BOTH wrap
// the source in squashOutsideCode; the main path additionally runs
// processWithThinking (which floats <think> blocks to the top — a non-local
// transform). Fuzzing the corpus through these — not just bare mdToHtml — closes
// the gap where a squashOutsideCode whitespace/fence edge could break the split.
const renderLiveReply = (t) => md.mdToHtml(md.squashOutsideCode(t)); // chat.js live-reply path
const renderMain = (t) => md.processWithThinking(md.squashOutsideCode(t)); // chat.js main path
// Reproduce the renderer's exact use of the segmenter over a sequence of prefixes.
function simulate(text, prefixLengths, renderFn = render) {
let committed = 0;
let finalizedHtml = '';
for (const len of prefixLengths) {
const prefix = text.slice(0, len);
const next = splitFinalized(prefix, renderFn, committed);
assert.ok(
next >= committed && next <= prefix.length,
`committed must stay monotonic and in range (${committed} -> ${next} at length ${len})`,
);
if (next > committed) {
// The renderer renders each finalized delta once and never touches it again.
finalizedHtml += renderFn(prefix.slice(committed, next));
committed = next;
}
const got = normalizeRender(finalizedHtml + renderFn(prefix.slice(committed)));
const want = normalizeRender(renderFn(prefix));
assert.equal(got, want, `invariant broke at prefix length ${len} of ${JSON.stringify(text)}`);
}
}
const everyPrefix = (t) => Array.from({ length: t.length + 1 }, (_, i) => i);
function chunkAtWhitespace(t) {
const lens = [];
for (let i = 1; i <= t.length; i++) {
if (i === t.length || /\s/.test(t[i - 1])) lens.push(i);
}
return lens.length ? lens : [t.length];
}
const RENDERERS = [
['mdToHtml', render],
['mdToHtml∘squashOutsideCode (live-reply path)', renderLiveReply],
['processWithThinking∘squashOutsideCode (main path)', renderMain],
];
for (const [rname, renderFn] of RENDERERS) {
for (const [name, text] of CORPUS) {
test(`invariant — ${rname} — char-by-char — ${name}`, () => {
simulate(text, everyPrefix(text), renderFn);
});
test(`invariant — ${rname} — whitespace-chunked — ${name}`, () => {
simulate(text, chunkAtWhitespace(text), renderFn);
});
}
}
// These samples carry <think> blocks (the corpus above is think-free), so they
// specifically exercise the self-verifying local check refusing to finalize inside
// or across a think block that processWithThinking floats to the top.
const THINKING_CORPUS = [
['leading think then answer', '<think>Let me reason about it.</think>\n\nThe answer is 42.'],
['think with internal blank lines', '<think>Step one.\n\nStep two.\n\nStep three.</think>\n\nDone — the result follows.'],
['think then several paragraphs', '<thinking>analyzing the request</thinking>\n\nFirst point made here.\n\nSecond point made here.\n\nThird and final point.'],
['think then code block', '<think>I should show code.</think>\n\nHere:\n\n```python\nprint("hi")\n```\n\nThat is the snippet.'],
];
for (const [name, text] of THINKING_CORPUS) {
test(`invariant (processWithThinking) — char-by-char — ${name}`, () => {
simulate(text, everyPrefix(text), renderMain);
});
}
// A final-output check independent of chunking: streaming to completion must equal
// a single full render.
test('streamed-to-completion output equals full render for whole corpus', () => {
for (const [name, text] of CORPUS) {
let committed = 0;
let html = '';
for (let len = 1; len <= text.length; len++) {
const next = splitFinalized(text.slice(0, len), render, committed);
if (next > committed) {
html += render(text.slice(committed, next));
committed = next;
}
}
html += render(text.slice(committed));
assert.equal(normalizeRender(html), normalizeRender(render(text)), `final mismatch for ${name}`);
}
});
+66
View File
@@ -0,0 +1,66 @@
// Loads the real browser markdown renderer (static/js/markdown.js) under Node by
// mocking the minimal browser globals it touches and stubbing its sibling imports.
// This mirrors the loader in tests/test_markdown_rendering_js.py so the streaming
// tests exercise the exact same renderer the browser runs.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
export async function loadMarkdown() {
globalThis.window = { location: { origin: 'http://localhost' }, katex: null };
globalThis.document = {
readyState: 'loading',
addEventListener() {},
createElement(tag) {
if (tag !== 'template') throw new Error(`unsupported element: ${tag}`);
return {
_html: '',
content: { querySelectorAll() { return []; } },
set innerHTML(v) { this._html = v; },
get innerHTML() { return this._html; },
};
},
};
globalThis.MutationObserver = class { observe() {} };
let src = fs.readFileSync(path.join(REPO, 'static/js/markdown.js'), 'utf8');
src = src.replace(/import uiModule from ['"]\.\/ui\.js['"];/, '');
src = src.replace(
/import \{ splitTableRow \} from ['"]\.\/markdown\/tableRow\.js['"];/,
() => `function splitTableRow(row){return (row||'').replace(/^\\s*\\|/,'').replace(/\\|\\s*$/,'').split('|').map((c)=>c.trim());}`,
);
const emoji = fs
.readFileSync(path.join(REPO, 'static/js/emojiShortcodes.js'), 'utf8')
.replace(/^export default .*$/m, '')
.replace(/export const /g, 'const ')
.replace(/export function /g, 'function ');
src = src.replace(
/import \{ replaceEmojiShortcodes, hasEmojiShortcode \} from ['"]\.\/emojiShortcodes\.js['"];/,
() => emoji,
);
src = src.replace(
/var escapeHtml = uiModule\.esc;/,
() =>
`var escapeHtml = (v) => String(v ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');`,
);
const url = 'data:text/javascript;base64,' + Buffer.from(src).toString('base64');
return import(url);
}
// Canonicalize rendered HTML so two renders that produce the SAME DOM compare
// equal. Collapses only newline-bearing whitespace BETWEEN tags (`>\n\n<` ->
// `><`): it is insignificant in rendered HTML, and incremental finalization
// legitimately emits `\n\n` between two blocks where a single full render emits
// `\n`. Code whitespace is safe because code is HTML-escaped, so significant
// newlines live inside <code> as text (never between a `>` and a `<`). Inline
// single spaces between tags are left alone. Structural differences (two <ul> vs
// one, <ol> vs <ul>) survive normalization and still fail, as they must.
// Mermaid ids embed Date.now(), so they are normalized too.
export function normalizeRender(html) {
return String(html)
.replace(/>\s*\n\s*</g, '><')
.trim()
.replace(/(mermaid|thinking)-\d+-\d+/g, '$1-X');
}
+65
View File
@@ -0,0 +1,65 @@
// Tests for the pure streaming-markdown segmenter.
//
// The segmenter's one job: given the full accumulated markdown text so far,
// report how many leading characters are SAFE to finalize — i.e. freeze and
// never re-render. "Safe" means: rendering the finalized prefix and the live
// tail separately produces the same DOM as rendering the whole text at once.
//
// Invariant under test everywhere: render(text[0:n]) + render(text[n:]) === render(text)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadMarkdown, normalizeRender } from './markdownHarness.mjs';
import { splitFinalized } from '../../static/js/streamingSegmenter.js';
const md = await loadMarkdown();
const render = (t) => md.mdToHtml(t);
const splitOk = (text, n) =>
normalizeRender(render(text.slice(0, n)) + render(text.slice(n))) === normalizeRender(render(text));
test('harness loads the real renderer', () => {
assert.match(render('hi'), /<p>hi<\/p>/);
});
test('nothing is finalized while a single block is still streaming', () => {
assert.equal(splitFinalized('an incomplete paragra', render), 0);
});
test('finalizes the first of two blank-line-separated paragraphs', () => {
const text = 'para one\n\npara two';
const n = splitFinalized(text, render);
assert.equal(n, 'para one\n\n'.length);
assert.ok(splitOk(text, n), 'split must be render-equivalent');
});
test('never finalizes the last (still-growing) block', () => {
// The trailing paragraph could still gain more characters, so it stays live.
const text = 'done\n\nstill going';
const n = splitFinalized(text, render);
assert.ok(n <= 'done\n\n'.length);
assert.ok(splitOk(text, n));
});
test('a closed code fence is finalized immediately, even as the last block', () => {
// This is the original flicker scenario: a completed code block must freeze
// so its hover buttons stop being recreated on every later token.
const text = 'Here:\n\n```python\nprint(1)\n```';
const n = splitFinalized(text, render);
assert.ok(n >= text.length - 1, `expected the whole closed fence finalized, got ${n} of ${text.length}`);
assert.ok(splitOk(text, n));
});
test('does NOT finalize across an OPEN code fence', () => {
const text = 'intro\n\n```python\nprint(1)\nprint(2)';
const n = splitFinalized(text, render);
// "intro" may finalize, but nothing inside the still-open fence may.
assert.ok(n <= 'intro\n\n'.length, `must not finalize into an open fence, got ${n}`);
assert.ok(splitOk(text, n));
});
test('does NOT split a loose list (blank line between items is not a boundary)', () => {
const text = '- a\n\n- b\n\nafter';
const n = splitFinalized(text, render);
assert.ok(splitOk(text, n), 'a wrong split here would turn one <ul> into two');
// The list must not be cut in the middle: either nothing or the whole list.
assert.ok(n === 0 || n >= '- a\n\n- b\n\n'.length, `loose list was cut at ${n}`);
});
+154
View File
@@ -0,0 +1,154 @@
from src.action_intents import classify_tool_intent, message_needs_tools
def test_calendar_entry_request_promotes_to_agent():
assert message_needs_tools("Can you add an entry to my calendar?")
intent = classify_tool_intent("Can you add an entry to my calendar?")
assert intent.needs_tools
assert intent.category == "calendar"
def test_calendar_imperative_variants_promote_to_agent():
assert message_needs_tools("add lunch with Sam to my calendar tomorrow at noon")
assert message_needs_tools("schedule a call with Mina next Friday")
assert message_needs_tools("put dentist appointment on my calendar")
assert message_needs_tools("Alright. Recreate that same appointment")
assert message_needs_tools("delete that actually")
assert message_needs_tools("Okay delete that doctor appointment from the calendar")
assert message_needs_tools("have another go at adding a test entry to the calendar")
assert message_needs_tools(
"Okay so you should be able to create that calendar event for tomorrow at 1:30 p.m. right for me to go to the hardware store"
)
assert message_needs_tools(
"make it an appointment at 12pm for me to visit the doctor it's tomorrow the 2nd of June 2026"
)
def test_calendar_read_requests_promote_to_agent():
assert message_needs_tools("What upcoming events do I have?")
assert message_needs_tools("Can you show my next appointments?")
assert message_needs_tools("Do I have upcoming Taekwondo classes this week?")
assert message_needs_tools("What's on my calendar tomorrow?")
assert message_needs_tools("When is my next meeting?")
def test_note_todo_and_reminder_actions_promote_to_agent():
assert message_needs_tools("add milk to my todo list")
assert message_needs_tools("take a note that the server needs checking")
assert message_needs_tools("set a reminder to call Pat at 4pm")
def test_email_and_ui_actions_promote_to_agent():
assert message_needs_tools("reply to that email")
assert message_needs_tools("mark those emails as read")
assert message_needs_tools("open my calendar")
assert message_needs_tools("turn off web search")
def test_research_action_promotes_to_agent():
assert message_needs_tools("research cost effective local models")
assert message_needs_tools("can you look into GPU hosting options")
def test_explicit_web_search_promotes_to_agent():
assert message_needs_tools("use web search and find a recipe for chocolate chip cookies")
assert message_needs_tools("do a web search for the best chocolate chip cookies")
assert message_needs_tools("search the web for current RTX 3090 prices")
assert classify_tool_intent("use web search and find a recipe").category == "web"
def test_chinese_web_lookup_requests_route_to_web_tools():
intent = classify_tool_intent("帮我查一下这些店铺的地址,我要去打卡")
assert intent.needs_tools
assert intent.category == "web"
def test_nearest_place_lookup_promotes_to_web_agent():
intent = classify_tool_intent("from vasaplan stockholm where is closest parking")
assert intent.needs_tools
assert intent.category == "web"
def test_workspace_agent_requests_promote_to_shell_workspace():
prompts = [
"fix the bug in this repo",
"run the tests for this project",
"debug the server logs",
"run a performance benchmark on this project",
"inspect the traceback and patch the code",
]
for prompt in prompts:
intent = classify_tool_intent(prompt)
assert intent.needs_tools
assert intent.category == "workspace"
def test_page_references_are_not_mistaken_for_named_computers():
for prompt in (
"What heading is visible on that page?",
"Read it from the current page.",
"Compare this with the same page.",
):
intent = classify_tool_intent(prompt)
assert intent.category != "workspace"
intent = classify_tool_intent("check the service on odysseus")
assert intent.needs_tools and intent.category == "workspace"
def test_direct_code_requests_promote_to_workspace_agent():
prompts = [
"write a Python function that parses CSV",
"write answer.json",
"create app.ts",
"edit src/app.py",
"Can you create a script in this project?",
"edit the React component to show a loading state",
"I want you to build a small command-line tool",
"Can you code this in the repo?",
]
for prompt in prompts:
intent = classify_tool_intent(prompt)
assert intent.needs_tools
assert intent.category == "workspace"
def test_code_explanations_stay_plain_chat():
assert not message_needs_tools("How do I write a Python function?")
assert not message_needs_tools("Can you explain how a React component works?")
def test_shell_diagnostic_commands_promote_to_agent():
prompts = [
"lsblk",
"run lsblk",
"df -h",
"docker ps",
"nvidia-smi",
"can you run journalctl -u odysseus",
]
for prompt in prompts:
intent = classify_tool_intent(prompt)
assert intent.needs_tools
assert intent.category in {"shell", "workspace"}
def test_shell_command_explanations_stay_plain_chat():
assert not message_needs_tools("How do I use lsblk?")
assert not message_needs_tools("Can you explain docker ps?")
def test_explanatory_calendar_questions_stay_plain_chat():
assert not message_needs_tools("How do I add an entry to my calendar?")
assert not message_needs_tools("What about the built-in Odysseus calendar, is that linked to email?")
assert not message_needs_tools("Can you explain how calendar reminders work?")
intent = classify_tool_intent("How do I add an entry to my calendar?")
assert not intent.needs_tools
assert intent.reason == "explanatory feature question"
def test_router_reports_non_calendar_categories():
assert classify_tool_intent("reply to that email").category == "email"
assert classify_tool_intent("open my calendar").category == "ui"
assert classify_tool_intent("research cost effective local models").category == "research"
+23
View File
@@ -0,0 +1,23 @@
from src.action_intents import classify_tool_intent
def test_open_cal_promotes_to_ui_panel():
intent = classify_tool_intent("open cal")
assert intent.needs_tools
assert intent.category == "ui"
def test_terse_dated_calendar_create_promotes_to_calendar():
intent = classify_tool_intent("add fireworks october 3rd")
assert intent.needs_tools
assert intent.category == "calendar"
def test_terse_timed_calendar_create_promotes_to_calendar():
intent = classify_tool_intent("schedule dinner next friday 7pm")
assert intent.needs_tools
assert intent.category == "calendar"
+35
View File
@@ -0,0 +1,35 @@
"""Regression: shell verbs must not promote informational chat to agent mode.
The shell-verb pattern used to be a bare word match
(`\\b(deploy|build|...|rm)\\b\\s+\\S+`), so any sentence merely containing one
of these common English words escalated a plain chat turn to agent mode via
routes/chat_routes.py. That broke the module's stated contract ("only promote
plain chat to agent mode when the user asks the assistant to take an action,
not when the user asks how a feature works"). The pattern is now anchored to
imperative position (start of message, optionally after "please") or to a
"can/could/would/will you ..." request.
"""
from src.action_intents import message_needs_tools
def test_informational_shell_questions_stay_plain_chat():
assert not message_needs_tools("What does the grep command do?")
assert not message_needs_tools("How do I tail a log file in production?")
assert not message_needs_tools("Is it safe to kill a process with kill -9?")
def test_incidental_shell_words_stay_plain_chat():
assert not message_needs_tools("My cat ate my homework")
assert not message_needs_tools("The movie was a real kill joy for everyone")
def test_imperative_shell_commands_still_promote_to_agent():
assert message_needs_tools("tail the nginx error log")
assert message_needs_tools("restart the media server")
assert message_needs_tools("please install docker on the host")
assert message_needs_tools("cat /etc/hosts")
def test_can_you_shell_requests_still_promote_to_agent():
assert message_needs_tools("can you grep the logs for 500 errors")
assert message_needs_tools("could you tail the access log")
+408
View File
@@ -0,0 +1,408 @@
from pathlib import Path
import subprocess
ROOT = Path(__file__).resolve().parents[1]
def test_shared_action_menu_order_is_used_by_item_menus() -> None:
expected_imports = {
"static/js/documentLibrary.js": "orderActionMenuItems",
"static/js/tasks.js": "orderActionMenuItems",
"static/js/sessions.js": "orderActionMenuItems",
"static/js/research/panel.js": "orderActionMenuItems",
"static/js/emailLibrary.js": "orderActionMenuItems",
"static/js/memory.js": "orderActionMenuItems",
}
for relative_path, helper in expected_imports.items():
source = (ROOT / relative_path).read_text(encoding="utf-8")
assert "actionMenuOrder.js" in source
assert helper in source
def test_common_action_order_matches_product_convention() -> None:
source = (ROOT / "static/js/actionMenuOrder.js").read_text(encoding="utf-8")
for rank in (200, 400, 500, 550, 600, 650, 700, 900, 1000):
assert f"{{ rank: {rank}" in source
def test_callback_actions_are_sorted_by_their_labels() -> None:
script = """
import { orderActionMenuItems } from './static/js/actionMenuOrder.js';
const callback = () => {};
const items = [
{ label: 'Delete', action: callback },
{ label: 'Archive', action: callback },
{ label: 'Copy', action: callback },
{ label: 'Favorite', action: callback },
{ label: 'Select', action: callback },
{ label: 'Rename', action: callback },
{ label: 'Open', action: callback },
{ label: 'Cancel', action: callback },
];
process.stdout.write(orderActionMenuItems(items).map(item => item.label).join('|'));
"""
result = subprocess.run(
["node", "--input-type=module", "--eval", script],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
assert result.stdout == "Open|Rename|Favorite|Copy|Select|Archive|Delete|Cancel"
def test_action_order_module_is_precached() -> None:
service_worker = (ROOT / "static/sw.js").read_text(encoding="utf-8")
assert "'/static/js/actionMenuOrder.js'" in service_worker
def test_dropdown_select_actions_use_the_canonical_icon() -> None:
source = (ROOT / "static/js/actionMenuOrder.js").read_text(encoding="utf-8")
assert "export const SELECT_MENU_ICON" in source
for relative_path in (
"static/js/documentLibrary.js",
"static/js/memory.js",
"static/js/sessions.js",
"static/js/skills.js",
"static/js/tasks.js",
"static/js/emailLibrary.js",
"static/js/research/panel.js",
):
module = (ROOT / relative_path).read_text(encoding="utf-8")
assert "SELECT_MENU_ICON" in module
def test_email_filter_menu_has_context_title() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
assert 'email-filter-menu-title">Filter by...</div>' in source
def test_email_setting_toggles_render_neutral_disabled_state() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
assert 'email-settings-auto-reply-section' in source
assert 'email-settings-display-enabled-state' in source
assert 'stateLabel = section?.querySelector' in source
assert '.email-settings-section.is-disabled .email-settings-enabled-state' in style
def test_email_search_options_menu_has_context_title() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
menu_start = source.index('id="email-search-options-menu"')
menu_end = source.index("</div>", menu_start) + len("</div>")
assert 'email-search-options-title">Filter by...</div>' in source[menu_start:menu_end]
def test_email_date_headers_mark_unexpected_timeline_gaps() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
assert "function _emailTimelineGapThreshold(items)" in source
assert "email-date-gap-break" in source
assert "gapDays > 90 && gapDays > timelineGapThreshold" in source
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
assert ".date-section-header.email-date-gap-break" in style
def test_email_filters_and_card_favorite_toggle_are_wired() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
assert '<option value="tag:action-needed">' not in source
assert "filter:tag:action-needed" not in source
assert "email-card-favorite" in source
assert "aria-pressed" in source
assert "/api/email/flag/" in source
assert "Object.prototype.hasOwnProperty.call(em, 'is_flagged')" in source
assert "const favoritesView = state._libFilter === 'favorites';" in source
assert "statusCluster.insertBefore(favoriteToggle, doneControl)" in source
assert "statusCluster.className = 'email-card-status';" in source
assert "statusCluster.appendChild(att)" in source
assert "statusCluster.appendChild(doneCheck)" in source
assert "function _exactTypedFilterSuggestion(value)" in source
assert "opt.value === 'filter:has-attachments'" in source
assert "const typedFilter = _exactTypedFilterSuggestion(v);" in source
assert "_acceptSuggestion(typedFilter);" in source
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
favorite_start = style.index(".email-card-favorite {")
favorite_end = style.index("}", favorite_start) + 1
assert "top: -3px;" in style[favorite_start:favorite_end]
assert ".email-card-status" in style
def test_email_auto_reply_start_date_seeds_today_when_picker_opens() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
assert "function _todayDateInputValue()" in source
assert "if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue();" in source
assert "autoReplyStart?.addEventListener('pointerdown', seedAutoReplyStartDate);" in source
assert "autoReplyStart?.addEventListener('focus', seedAutoReplyStartDate);" in source
def test_email_auto_reply_syncs_one_calendar_event_per_account() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
assert "function _syncAutoReplyCalendarEvent(cfg)" in source
assert "summary: 'Email Auto Reply (away)'" in source
assert "function _findAutoReplyCalendarEventUids(cfg, accountId)" in source
assert "Odysseus email auto reply - account:" in source
assert "_AUTO_REPLY_CALENDAR_KEY_PREFIX" in source
assert "method: 'POST', body: JSON.stringify(payload)" in source
assert "method: 'PUT', body: JSON.stringify(payload)" in source
assert "method: 'DELETE'" in source
assert "all_day: true" in source
assert "await _syncAutoReplyCalendarEvent(savedCfg)" in source
assert "_syncAutoReplyCalendarEvent(cfg).catch" in source
def test_email_settings_show_away_account_and_compact_display_controls() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
assert 'email-account-away-label">(AWAY)</span>' in source
assert 'id="email-lib-auto-reply-badge"' in source
assert ">Show Email Tags</span>" in source
assert "enabled ? 'Show' : 'Hide'" in source
assert "email-settings-inline-link" in source
assert "email-auto-reply-exclude" not in source
assert "_emailWritingStyleHtml(writingStyle) + _emailDisplaySettingsHtml()" in source
assert ".email-settings-status.is-success" in style
assert "var(--color-success, #4caf50)" in style
assert ".email-style-settings-extract svg" in style
assert "export async function mountEmailSettings(host)" in source
assert "_openGlobalEmailSettings('show-tags')" in source
assert source.count('class="admin-card email-settings-section') == 4
assert 'id="settings-email-default-card"' in (ROOT / "static/index.html").read_text(encoding="utf-8")
assert "multipleAccounts" in source
def test_email_cleanup_uses_the_memory_tidy_star_icon() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
cleanup = source[source.index("function _emailCleanupSettingsHtml"):source.index("function _emailDisplaySettingsHtml")]
assert "email-settings-clean-btn" in cleanup
assert "M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z" in cleanup
def test_email_settings_escape_returns_to_email_list() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
settings_guard = "if (modal.classList.contains('email-settings-mode'))"
assert settings_guard in source
assert source.index(settings_guard) < source.index("closeEmailLibrary();", source.index(settings_guard))
assert "_hideEmailSettingsPage();" in source[source.index(settings_guard):source.index(settings_guard) + 180]
def test_email_select_escape_cancels_selection_without_closing_library() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
select_guard = "if (state._selectMode) {"
select_start = source.index(select_guard, source.index("if (e.key === 'Escape')"))
assert "_setSelectBtnState(false);" in source[select_start:select_start + 260]
assert "closeEmailLibrary();" not in source[select_start:select_start + 260]
def test_chat_delete_actions_use_the_shared_trash_bin_icon() -> None:
source = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
assert "const TRASH_ICON =" in source
assert "{ id: 'delete', icon: TRASH_ICON" in source
assert source.count("{ id: 'delete', icon: TRASH_ICON") == 2
assert "M3 6h18" in source
def test_agent_unsubscribe_uses_the_reviewed_target_without_rescanning() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
start = source.index("function _askAgentToUnsubscribe")
end = source.index("function _unsubscribeCandidateUids", start)
prompt = source[start:end]
assert "private_browser" in prompt
assert "Do not call scan_email_unsubscribes again" in prompt
assert "Reviewed method_index" in prompt
assert "Exact unsubscribe URL" in prompt
assert "bulk_email action=delete" in prompt
assert "Email UID(s)" in prompt
def test_email_clean_always_forces_a_fresh_unsubscribe_scan() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
start = source.index("function _bindEmailSettingsPageControls")
end = source.index("function _setUnsubButtonBusy", start)
controls = source[start:end]
assert "_openUnsubscribeReviewModal(ev.currentTarget, { forceRescan: true })" in controls
assert "statusEl.style.justifyContent = 'flex-end'" in source
def test_unsubscribe_duplicate_badge_is_lowered() -> None:
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
stylesheet = (ROOT / "static/style.css").read_text(encoding="utf-8")
assert "email-unsub-duplicate-badge" in frontend
start = stylesheet.index(".email-unsub-duplicate-badge {")
assert "top: 2px;" in stylesheet[start:stylesheet.index("}", start) + 1]
def test_unsubscribe_scan_status_sits_before_clean_action() -> None:
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
stylesheet = (ROOT / "static/style.css").read_text(encoding="utf-8")
start = frontend.index("function _emailCleanupSettingsHtml")
end = frontend.index("function _emailDisplaySettingsHtml", start)
cleanup = frontend[start:end]
assert "email-settings-clean-actions" in cleanup
assert cleanup.index("email-settings-clean-status") < cleanup.index("email-settings-clean-btn")
assert "inlineHost.querySelector('.email-settings-clean-status')" in frontend
css_start = stylesheet.index(".email-settings-clean-status {")
assert "width: auto !important;" in stylesheet[css_start:stylesheet.index("}", css_start) + 1]
assert "email-unsub-panel-status" in frontend
assert "modal.style.cssText = 'display:none;margin-top:10px;'" in frontend
assert "showFinalStatus(finalStatus)" in frontend
assert "email-unsub-delete-all-btn').style.display = candidates.length ? 'inline-flex' : 'none'" in frontend
assert "statusEl.classList.add('is-busy')" in frontend
assert ".email-settings-clean-actions:has(.email-settings-clean-status.is-busy)" in stylesheet
panel_css = stylesheet[stylesheet.index(".email-unsub-panel-status {"):]
assert "top: 2px;" in panel_css[:panel_css.index("}") + 1]
def test_unsubscribe_success_removes_messages_before_the_next_scan() -> None:
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
backend = (ROOT / "routes/email_routes.py").read_text(encoding="utf-8")
mcp = (ROOT / "mcp_servers/email_server.py").read_text(encoding="utf-8")
assert "async function _deleteAfterUnsubscribe" in frontend
assert "action: 'delete'" in frontend[frontend.index("async function _deleteAfterUnsubscribe"):]
execute = backend[backend.index('@router.post("/unsubscribe/execute")'):]
assert 'deleted = _move_email_message(conn, uid, "Trash", role="trash")' in execute
assert '"deleted": deleted' in execute
unsubscribe = mcp[mcp.index("def _unsubscribe_email"):mcp.index("def _extract_text", mcp.index("def _unsubscribe_email"))]
assert "_delete_email(uid, folder=folder, account=account)" in unsubscribe
assert '"deleted": deleted' in unsubscribe
def test_agent_email_mutations_reconcile_bulk_single_and_mailto_results() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
start = source.index("function _agentDeletedEmailUids")
end = source.index("function _handleAgentEmailToolOutput", start)
resolver = source[start:end]
assert "tool.includes('bulk_email')" in resolver
assert "tool.endsWith('delete_email')" in resolver
assert "source email moved to trash" in resolver
assert "data.uid" in resolver
def test_browser_agent_unsubscribe_cleans_sender_after_positive_confirmation() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
start = source.index("function _agentBrowserUnsubscribeSucceeded")
end = source.index("function _agentDeletedEmailUids", start)
browser_flow = source[start:end]
assert "already\\s+unsubscribed" in browser_flow
assert "_deleteAfterUnsubscribe([candidate])" in browser_flow
assert "scope: group.sender ? 'sender_unsubscribe'" in source
assert "cleanupInFlight" in browser_flow
def test_auto_unsubscribe_all_is_visibly_taller_than_toolbar_buttons() -> None:
source = (ROOT / "static/style.css").read_text(encoding="utf-8")
start = source.index(".email-unsub-auto-safe-btn {")
assert "height: 29px;" in source[start:source.index("}", start) + 1]
def test_email_mutation_tool_events_include_exact_arguments() -> None:
source = (ROOT / "src/agent_loop.py").read_text(encoding="utf-8")
start = source.index("# Emit tool_output (include ui_event data if present)")
end = source.index("if tool_call_id:", start)
event = source[start:end]
assert '"mcp__email__bulk_email"' in event
assert '"mcp__email__delete_email"' in event
assert '"mcp__email__unsubscribe_email"' in event
assert '"private_browser"' in event
assert 'tool_output_data["tool_args"]' in event
def test_unsubscribe_cleanup_can_remove_same_sender_unsubscribe_messages() -> None:
source = (ROOT / "routes" / "email_routes.py").read_text()
cleanup = source[source.index('@router.post("/unsubscribe/cleanup")'):source.index('@router.get("/contacts")')]
assert 'scope == "sender_unsubscribe"' in cleanup
assert "_unsubscribe_sender_uids_sync" in cleanup
sender_scan = source[source.index("def _unsubscribe_sender_uids_sync"):source.index('@router.get("/unsubscribe/scan")')]
assert "FROM {_imap_search_quote(sender_key)}" in sender_scan
assert 'candidate.get("from_address")' in source[source.index("def _unsubscribe_sender_uids_sync"):source.index('@router.get("/unsubscribe/scan")')]
def test_unsubscribe_review_marks_handled_cards_and_offers_scan_further() -> None:
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
start = source.index("function _markUnsubscribeCardDone")
end = source.index("async function _runUnsubscribeCleanup", start)
card = source[start:end]
assert "_UNSUB_CHECK_ICON" in card
assert "is-unsubscribed" in card
assert "email-unsub-scan-further" in source
def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None:
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
styles = (ROOT / "static" / "style.css").read_text()
assert "email-unsub-ignore-btn" in source
assert "_rememberUnsubscribeIgnored(c)" in source
assert "Ignore this unsubscribe candidate" in source
assert ".email-unsub-ignore-btn" in styles
def test_email_settings_sections_use_static_headers() -> None:
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
styles = (ROOT / "static" / "style.css").read_text()
assert 'class="email-unsub-accent-icon"' in source
assert 'M12 0L14.59 8.41' in source
assert 'Scanning ${_esc(scanFolderLabel)} headers…' in source
assert 'email-settings-clean-btn' in source
assert 'const inlineHost = settingsPage?.querySelector?.(\'.email-settings-cleanup-section\')' in source
assert '<div class="admin-card email-settings-section' in source
assert 'class="email-settings-section-head"' in source
assert '<div id="email-settings-cleanup-body" class="email-settings-section-body">' in source
assert '<details class="admin-card email-settings-section' not in source
assert source.count('id="email-auto-reply-enabled"') == 1
assert source.count('id="email-settings-show-tags"') == 1
assert 'flex: 0 0 auto;' in styles
assert 'page.querySelectorAll(\'details.email-settings-section\')' not in source
assert 'other.open = false' not in source
assert '.email-settings-section[open] > .email-settings-section-body' in styles
assert '.email-settings-section[open] > .email-settings-section-body > *' in styles
assert 'flex: 0 0 auto;' in styles
assert '.email-settings-section[open] {' in styles
assert 'flex: 1 1 auto;' in styles
assert 'grid-template-rows: auto minmax(0, 1fr);' in styles
assert 'height: 100%;' in styles
assert 'max-height: 100%;' in styles
assert 'overflow: hidden;' in styles
assert '.modal-content:not([style*="height"])' in styles
assert 'left: -1px;' in styles
assert '.email-settings-clean-status:not(:empty)' in styles
assert '.email-unsub-status.is-error' in styles
def test_unsubscribe_scan_defaults_to_bounded_page_in_api_and_tool_prompt() -> None:
backend = (ROOT / "routes" / "email_routes.py").read_text()
schema = (ROOT / "src" / "tool_schemas.py").read_text()
agent = (ROOT / "src" / "agent_loop.py").read_text()
scan_start = backend.index('@router.get("/unsubscribe/scan")')
scan_end = backend.index('@router.post("/unsubscribe/execute")', scan_start)
scan_route = backend[scan_start:scan_end]
assert 'max_scan: int = Query(500)' in scan_route
assert 'max_scan = max(limit, min(requested_max_scan or 500, 500))' in backend
assert 'for start in range(0, len(uids), 100)' in backend
assert 'capped at 500' in schema
assert '"max_scan": 500' in agent[agent.index('def _parse_qwen_explicit_unsubscribe_scan_request'):agent.index('def _parse_qwen_explicit_unsubscribe_email_request')]
mcp = (ROOT / "mcp_servers" / "email_server.py").read_text()
mcp_scan = mcp[mcp.index('def _scan_unsubscribe_candidates'):mcp.index('def _unsubscribe_email')]
assert 'max_scan=500' in mcp_scan
assert 'for start in range(0, len(uids), 100)' in mcp_scan
assert 'capped at 500' in mcp
assert 'Scan up to 500 newest email headers' in mcp
def test_item_menus_share_the_standard_dropdown_classes() -> None:
expected = {
"static/js/documentLibrary.js": "dropdown session-dropdown-menu doclib-card-dropdown",
"static/js/memory.js": "dropdown session-dropdown-menu memory-item-dropdown",
"static/js/tasks.js": "dropdown session-dropdown-menu task-dropdown",
"static/js/skills.js": "dropdown session-dropdown-menu skill-kebab-menu",
}
for relative_path, class_names in expected.items():
source = (ROOT / relative_path).read_text(encoding="utf-8")
assert class_names in source
def test_task_card_menu_can_enter_select_mode_with_current_task() -> None:
source = (ROOT / "static/js/tasks.js").read_text(encoding="utf-8")
assert "function _taskEnterSelectWith(taskId)" in source
assert "label: 'Select'" in source
assert "action: () => _taskEnterSelectWith(task.id)" in source
+38
View File
@@ -0,0 +1,38 @@
"""Issue #1160 — a closed document must not stay 'active' and leak into new chats.
Closing a document tab detaches it (session_id -> NULL) or deletes it, but the
in-memory active-document pointer was never cleared, so the last-resort doc
injection re-surfaced the closed doc in later, unrelated chats. The document
routes now call clear_active_document() on detach/delete; this pins that helper.
"""
from src.agent_tools.document_tools import (
set_active_document,
get_active_document,
clear_active_document
)
def test_clear_matching_id_resets_pointer():
set_active_document("doc-123")
assert get_active_document() == "doc-123"
assert clear_active_document("doc-123") is True
assert get_active_document() is None
def test_clear_non_matching_id_leaves_other_active_doc():
set_active_document("doc-abc")
# Closing a DIFFERENT document must not clobber the currently active one.
assert clear_active_document("doc-xyz") is False
assert get_active_document() == "doc-abc"
def test_clear_without_id_clears_unconditionally():
set_active_document("doc-abc")
assert clear_active_document() is True
assert get_active_document() is None
def test_clear_when_already_none_is_safe():
set_active_document(None)
assert clear_active_document("doc-123") is False
assert get_active_document() is None
@@ -0,0 +1,31 @@
from pathlib import Path
SCRIPT = (
Path(__file__).resolve().parents[1] / "src/agent_loop.py"
).read_text(encoding="utf-8")
def test_active_document_mutations_require_editor_tool_evidence():
assert "def _active_document_mutation_requires_tool(" in SCRIPT
assert "def _has_successful_active_document_mutation(" in SCRIPT
assert "_active_document_mutation_turn" in SCRIPT
assert "active document mutation answered without editor tool evidence" in SCRIPT
def test_guard_allows_editor_tools_or_one_clarification():
assert '{"edit_document", "update_document", "suggest_document"}' in SCRIPT
assert "call `ask_user` once instead" in SCRIPT
def test_active_email_reply_drafts_are_editor_mutations():
assert "_is_email_document_obj(active_document) and _email_reply_draft_requested(text)" in SCRIPT
def test_guard_uses_request_tools_before_retrieval_tools_are_initialized():
call = """_active_document_mutation_requires_tool(
_last_user,
active_document,
relevant_tools,
)"""
assert call in SCRIPT
+13
View File
@@ -0,0 +1,13 @@
from pathlib import Path
def test_active_email_reader_blocks_immediate_reply_tools():
source = Path("routes/chat_routes.py").read_text(encoding="utf-8")
guard_start = source.index("if active_email_ctx and active_email_ctx.get(\"uid\"):")
guard_block = source[guard_start:source.index("# Enforce per-user privileges", guard_start)]
assert '"reply_to_email"' in guard_block
assert '"mcp__email__reply_to_email"' in guard_block
assert '"send_email"' in guard_block
assert '"mcp__email__send_email"' in guard_block
assert '"create_document"' in guard_block
+394
View File
@@ -0,0 +1,394 @@
"""Regression guard for #5558 — POST /api/personal/add_directory must not run
the indexing job on the event loop.
The handler is ``async def`` but called ``rag.index_personal_documents``
(os.walk + file reads + per-chunk embedding + Chroma inserts) inline, so
FastAPI ran the whole job on the event loop and every other request queued
behind it: indexing a real directory froze the UI and API for 25+ minutes.
``personal_docs_manager.add_directory`` sits in the same blocking section — it
triggers ``refresh_index()``, which re-extracts text across tracked dirs.
These tests build the real router with fake managers and compare the thread
the indexing work runs on against the event loop's thread.
"""
import asyncio
import os
import threading
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
import httpx
from fastapi import FastAPI
from fastapi.testclient import TestClient
def _serialization_probe():
"""Shared counter proving two critical sections never overlap."""
state = {"active": 0, "max_active": 0}
lock = threading.Lock()
def enter():
with lock:
state["active"] += 1
state["max_active"] = max(state["max_active"], state["active"])
def leave():
with lock:
state["active"] -= 1
return state, enter, leave
# Concurrency tests are `async def` (pyproject asyncio_mode="auto") and drive the
# ASGI app through httpx.ASGITransport + AsyncClient + asyncio.gather, NOT starlette
# TestClient + ThreadPoolExecutor: the job lock is an asyncio.Lock acquired in the
# async handler, and TestClient's portal-thread dispatch deadlocks against it (same
# reason test_notes_fail_closed_auth.py uses ASGITransport). asyncio.gather runs both
# requests on the test's own loop.
def _async_client(app):
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t")
import routes.personal_routes as personal_routes
from core.middleware import require_admin
from src.auth_helpers import require_user
class _FakeRag:
def __init__(self, record):
self._record = record
def index_personal_documents(self, directory, owner=None):
self._record["index_thread"] = threading.get_ident()
return {"success": True, "indexed_count": 3, "failed_count": 0}
def _split_into_chunks(self, text, chunk_size=500):
return [text]
def add_document(self, chunk, metadata):
self._record["add_document_thread"] = threading.get_ident()
return True
def delete_by_source(self, filepath):
self._record["delete_thread"] = threading.get_ident()
return 1
class _FakeDocsManager:
def __init__(self, record):
self._record = record
self.index = []
def add_directory(self, directory, *, index=True, owner=None):
self._record["bookkeeping_thread"] = threading.get_ident()
self._record["bookkeeping_index_flag"] = index
def exclude_file(self, filepath):
self._record["exclude_thread"] = threading.get_ident()
def _build_app(tmp_path, monkeypatch, record):
monkeypatch.setattr(personal_routes, "PERSONAL_DIR", str(tmp_path))
monkeypatch.setattr(personal_routes, "get_rag_manager", lambda: _FakeRag(record))
app = FastAPI()
app.include_router(
personal_routes.setup_personal_routes(_FakeDocsManager(record), None, True)
)
app.dependency_overrides[require_user] = lambda: "tester"
app.dependency_overrides[require_admin] = lambda: None
@app.get("/loop-thread")
async def loop_thread_probe():
return {"thread": threading.get_ident()}
return app
def test_indexing_runs_off_the_event_loop(tmp_path, monkeypatch):
record = {}
app = _build_app(tmp_path, monkeypatch, record)
target = tmp_path / "docs"
target.mkdir()
# Context-manager client: one portal/event loop serves both requests, so
# the probe and the POST are guaranteed to see the same loop thread.
with TestClient(app) as client:
loop_thread = client.get("/loop-thread").json()["thread"]
resp = client.post(
"/api/personal/add_directory", json={"directory": str(target)}
)
assert resp.status_code == 200
assert record["index_thread"] != loop_thread, (
"index_personal_documents ran on the event loop thread — every other "
"request queues behind the indexing job (#5558)"
)
assert record["bookkeeping_thread"] != loop_thread, (
"personal_docs_manager.add_directory (refresh_index) ran on the event "
"loop thread"
)
def test_response_and_bookkeeping_unchanged(tmp_path, monkeypatch):
record = {}
app = _build_app(tmp_path, monkeypatch, record)
target = tmp_path / "docs"
target.mkdir()
client = TestClient(app)
resp = client.post("/api/personal/add_directory", json={"directory": str(target)})
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["indexed_count"] == 3
assert body["failed_count"] == 0
assert body["directory"] == os.path.realpath(str(target))
assert record["bookkeeping_index_flag"] is False
async def test_concurrent_add_directory_requests_serialize_indexing(tmp_path, monkeypatch):
"""Off-loop execution must not mean parallel index jobs: concurrent
requests would race PersonalDocsManager's unsynchronized list mutations
and file writes (save_directories/_save_excluded are plain open('w'))."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.2); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
for name in ("docs_a", "docs_b"):
(tmp_path / name).mkdir()
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_b")}),
)
assert all(r.status_code == 200 for r in results)
assert state["max_active"] == 1, (
f"{state['max_active']} index jobs ran in parallel — concurrent "
"add_directory requests must serialize"
)
def test_failed_indexing_still_returns_500(tmp_path, monkeypatch):
record = {}
app = _build_app(tmp_path, monkeypatch, record)
target = tmp_path / "docs"
target.mkdir()
def _fail(directory, owner=None):
return {"success": False, "message": "boom"}
monkeypatch.setattr(_FakeRag, "index_personal_documents", staticmethod(_fail))
client = TestClient(app)
resp = client.post("/api/personal/add_directory", json={"directory": str(target)})
assert resp.status_code == 500
assert "boom" in resp.json()["detail"]
async def test_add_and_remove_serialize(tmp_path, monkeypatch):
"""#5634: remove must hold the SAME job lock as add. Otherwise a remove
running while an add job is in flight races PersonalDocsManager's
unsynchronized list/index mutations — the inconsistent state the PR's
'add/remove are serialized' guarantee claims to prevent."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.25); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
def _slow_remove(self, directory):
enter(); time.sleep(0.25); leave()
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
monkeypatch.setattr(_FakeDocsManager, "remove_directory", _slow_remove, raising=False)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
(tmp_path / "docs_a").mkdir()
(tmp_path / "docs_b").mkdir()
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.delete("/api/personal/remove_directory", params={"directory": str(tmp_path / "docs_b")}),
)
assert all(r.status_code == 200 for r in results)
assert state["max_active"] == 1, (
f"{state['max_active']} add/remove critical sections overlapped — "
"remove must hold the same index job lock as add"
)
async def test_add_and_upload_serialize(tmp_path, monkeypatch):
"""#5634 follow-up: POST /upload writes chunks into the vector store and then
calls personal_docs_manager.add_directory — the same vector/tracking state
add_directory mutates. It must hold the SAME job lock, or an upload landing
mid-add interleaves two writers over unsynchronized state."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.25); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
def _slow_add_document(self, chunk, metadata):
self._record["add_document_thread"] = threading.get_ident()
enter(); time.sleep(0.25); leave()
return True
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
monkeypatch.setattr(_FakeRag, "add_document", _slow_add_document)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
(tmp_path / "docs_a").mkdir()
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.post("/api/personal/upload", files={"files": ("a.txt", b"hello world", "text/plain")}),
)
assert all(r.status_code == 200 for r in results)
# The test coroutine runs on the event loop, so this IS the loop thread.
assert record["add_document_thread"] != threading.get_ident(), (
"rag.add_document ran on the event loop thread — chunk writes block "
"every other request for the duration of the upload"
)
assert state["max_active"] == 1, (
f"{state['max_active']} add/upload critical sections overlapped — "
"upload must hold the same index job lock as add"
)
async def test_upload_processes_each_payload_before_reading_the_next(tmp_path, monkeypatch):
"""A multi-file upload must retain at most one capped payload at a time."""
from starlette.datastructures import UploadFile as StarletteUploadFile
reads = []
original_read = StarletteUploadFile.read
async def _recording_read(upload, size=-1):
reads.append(upload.filename)
return await original_read(upload, size)
def _record_first_index(self, chunk, metadata):
self._record.setdefault("reads_at_first_index", len(reads))
return True
monkeypatch.setattr(StarletteUploadFile, "read", _recording_read)
monkeypatch.setattr(_FakeRag, "add_document", _record_first_index)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
files = [
("files", ("a.txt", b"alpha", "text/plain")),
("files", ("b.txt", b"bravo", "text/plain")),
("files", ("c.txt", b"charlie", "text/plain")),
]
async with _async_client(app) as ac:
response = await ac.post("/api/personal/upload", files=files)
assert response.status_code == 200
assert response.json()["uploaded"] == ["a.txt", "b.txt", "c.txt"]
assert reads == ["a.txt", "b.txt", "c.txt"]
assert record["reads_at_first_index"] == 1, (
"all upload bodies were retained before worker processing began"
)
async def test_add_and_delete_file_serialize(tmp_path, monkeypatch):
"""#5634 follow-up: DELETE /file removes chunks from the vector store and
calls personal_docs_manager.exclude_file. Both mutate state add_directory
also touches, so the delete must hold the SAME job lock as add."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.25); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
def _slow_delete(self, filepath):
self._record["delete_thread"] = threading.get_ident()
enter(); time.sleep(0.25); leave()
return 1
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
monkeypatch.setattr(_FakeRag, "delete_by_source", _slow_delete)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
(tmp_path / "docs_a").mkdir()
doomed = tmp_path / "doomed.txt"
doomed.write_text("bye")
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.delete("/api/personal/file", params={"filepath": str(doomed)}),
)
assert all(r.status_code == 200 for r in results)
assert record["delete_thread"] != threading.get_ident(), (
"rag.delete_by_source ran on the event loop thread"
)
assert state["max_active"] == 1, (
f"{state['max_active']} add/delete critical sections overlapped — "
"delete must hold the same index job lock as add"
)
async def test_reload_serializes_with_add(tmp_path, monkeypatch):
"""#5634: POST /reload rebuilds the index via refresh_index(); it must hold
the same job lock so it cannot race an in-flight add job."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.25); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
def _slow_refresh(self):
enter(); time.sleep(0.25); leave()
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
monkeypatch.setattr(_FakeDocsManager, "refresh_index", _slow_refresh, raising=False)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
(tmp_path / "docs_a").mkdir()
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.post("/api/personal/reload"),
)
assert all(r.status_code == 200 for r in results)
assert state["max_active"] == 1, (
f"{state['max_active']} add/reload critical sections overlapped — "
"reload must hold the same index job lock as add"
)

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