mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-24 17:12:20 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { deliveryMessages, researchCardState } from '../static/js/backgroundToolJobs.js';
|
||||
|
||||
test('research cards distinguish live work, handoff, completion and no evidence', () => {
|
||||
assert.match(researchCardState({ status: 'running', rounds: 2, progress: { phase: 'reading', round: 1, total_sources: 3 } }).detail, /Round 1\/2 · 3 sources/);
|
||||
assert.equal(researchCardState({ status: 'ready' }).label, 'Preparing chat update');
|
||||
assert.equal(researchCardState({ status: 'delivered', source_count: 4 }).tone, 'done');
|
||||
assert.equal(researchCardState({ status: 'delivered', outcome: 'no_sources' }).label, 'No sources found');
|
||||
assert.equal(researchCardState({ status: 'ready', outcome: 'error' }).tone, 'error');
|
||||
});
|
||||
|
||||
test('only new delivered messages append; history reload and repeat polls deduplicate', () => {
|
||||
const job = { status: 'delivered', message: { content: 'Found it', metadata: { _db_id: 'result-1' } } };
|
||||
assert.deepEqual(deliveryMessages([job, job, { status: 'running' }], []), [job.message]);
|
||||
assert.deepEqual(deliveryMessages([job], ['result-1']), []);
|
||||
assert.deepEqual(deliveryMessages([{ status: 'ready', message: job.message }], []), []);
|
||||
});
|
||||
@@ -0,0 +1,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"
|
||||
@@ -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"}]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"]) == []
|
||||
@@ -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": "***"}
|
||||
@@ -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") == {}
|
||||
@@ -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"}]
|
||||
@@ -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"}]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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}."
|
||||
)
|
||||
@@ -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": {},
|
||||
}]]
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"] == ""
|
||||
@@ -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"}]
|
||||
@@ -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) + "…"
|
||||
@@ -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
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
|
||||
|
||||
async function canvasPoint(page, xRatio, yRatio) {
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
return { x: box.x + box.width * xRatio, y: box.y + box.height * yRatio };
|
||||
}
|
||||
|
||||
test('brush presets and shared stroke controls remain reusable', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await expect(page.locator('#ge-brush-spacing')).toBeVisible();
|
||||
await page.locator('#ge-brush-spacing').fill('28');
|
||||
await page.locator('#ge-brush-smoothing').fill('52');
|
||||
await page.locator('#ge-brush-blend').selectOption('multiply');
|
||||
await page.locator('.ge-pressure-option').filter({ hasText: 'Opacity' }).locator('.toggle-slider').click();
|
||||
await expect(page.locator('#ge-pressure-opacity')).toBeChecked();
|
||||
|
||||
page.once('dialog', dialog => dialog.accept('My Detail Brush'));
|
||||
await page.locator('#ge-brush-preset-save').click();
|
||||
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(1);
|
||||
|
||||
await page.locator('#ge-brush-spacing').fill('5');
|
||||
await page.locator('#ge-brush-preset').selectOption({ label: 'My Detail Brush' });
|
||||
await expect(page.locator('#ge-brush-spacing')).toHaveValue('28');
|
||||
await expect(page.locator('#ge-brush-smoothing')).toHaveValue('52');
|
||||
await expect(page.locator('#ge-brush-blend')).toHaveValue('multiply');
|
||||
await page.locator('#ge-brush-preset-delete').click();
|
||||
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('long sampled strokes stay responsive and undo atomically', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await page.locator('.ge-size-slider').fill('700');
|
||||
await page.locator('#ge-brush-spacing').fill('8');
|
||||
await page.locator('#ge-brush-smoothing').fill('65');
|
||||
const activeLayerId = (await editorState(page)).activeLayerId;
|
||||
const thumb = page.locator(`.ge-layer-item[data-layer-id="${activeLayerId}"] .ge-layer-inline-thumb`);
|
||||
const thumbBefore = await thumb.evaluate(canvas => canvas.toDataURL());
|
||||
const before = await flattenedPixelDigest(page);
|
||||
const start = await canvasPoint(page, 0.08, 0.5);
|
||||
const end = await canvasPoint(page, 0.92, 0.5);
|
||||
const startedAt = Date.now();
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(end.x, end.y, { steps: 240 });
|
||||
await page.mouse.up();
|
||||
expect(Date.now() - startedAt).toBeLessThan(5000);
|
||||
const after = await flattenedPixelDigest(page);
|
||||
expect(after).not.toEqual(before);
|
||||
await expect(thumb).toHaveAttribute('width', '68');
|
||||
expect(await thumb.evaluate(canvas => canvas.toDataURL())).not.toEqual(thumbBefore);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(before);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(after);
|
||||
});
|
||||
|
||||
test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await page.locator('.ge-color-picker').first().evaluate((input) => {
|
||||
input.value = '#33aa55';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
const sample = await canvasPoint(page, 0.3, 0.3);
|
||||
await page.mouse.click(sample.x, sample.y);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="eyedropper"]').click();
|
||||
await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
|
||||
await page.locator('#ge-eyedropper-sample').selectOption('composite');
|
||||
await page.mouse.move(sample.x, sample.y);
|
||||
await expect(page.locator('#ge-eyedropper-live-value')).toHaveText('#33aa55');
|
||||
await expect(page.locator('#ge-eyedropper-live-rgb')).toHaveText('RGB 51 170 85');
|
||||
await expect(page.locator('#ge-eyedropper-live-hsl')).toHaveText('HSL 137 54% 43%');
|
||||
await expect(page.locator('#ge-eyedropper-live-swatch')).not.toHaveClass(/empty/);
|
||||
await expect(page.locator('#ge-eyedropper-loupe')).toBeVisible();
|
||||
expect((await page.locator('#ge-eyedropper-loupe').evaluate(canvas => canvas.getContext('2d').getImageData(42, 42, 1, 1).data[3]))).toBe(255);
|
||||
await page.mouse.click(sample.x, sample.y);
|
||||
await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55');
|
||||
|
||||
for (const tool of ['dodge', 'burn']) {
|
||||
const before = await flattenedPixelDigest(page);
|
||||
await page.locator(`.ge-tool-btn[data-tool="${tool}"]`).click();
|
||||
const from = await canvasPoint(page, 0.35, tool === 'dodge' ? 0.45 : 0.6);
|
||||
const to = await canvasPoint(page, 0.65, tool === 'dodge' ? 0.45 : 0.6);
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to.x, to.y, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
expect((await editorState(page)).layers.length).toBeGreaterThan(0);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(before);
|
||||
}
|
||||
|
||||
const blemish = await canvasPoint(page, 0.72, 0.3);
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await page.locator('.ge-color-picker').first().evaluate((input) => {
|
||||
input.value = '#111111';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await page.mouse.click(blemish.x, blemish.y);
|
||||
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
|
||||
const beforeHeal = await flattenedPixelDigest(page);
|
||||
const healTarget = blemish;
|
||||
await page.mouse.move(healTarget.x, healTarget.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(healTarget.x + 50, healTarget.y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
expect(await flattenedPixelDigest(page)).not.toEqual(beforeHeal);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(beforeHeal);
|
||||
});
|
||||
|
||||
test('healing brush can use an optional sampled source', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Sampled healing E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await page.locator('.ge-size-slider').fill('420');
|
||||
await page.locator('.ge-color-picker').first().evaluate((input) => {
|
||||
input.value = '#d24b62';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
const source = await canvasPoint(page, 0.28, 0.5);
|
||||
await page.mouse.click(source.x, source.y);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
|
||||
await page.keyboard.down('Alt');
|
||||
await page.mouse.click(source.x, source.y);
|
||||
await page.keyboard.up('Alt');
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.cloneSourceX !== null && state.cloneSourceY !== null;
|
||||
})).toBe(true);
|
||||
const target = await canvasPoint(page, 0.72, 0.5);
|
||||
await page.mouse.click(target.x, target.y);
|
||||
|
||||
const result = await page.evaluate(async ({ xRatio, yRatio }) => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
const x = Math.round(layer.canvas.width * xRatio);
|
||||
const y = Math.round(layer.canvas.height * yRatio);
|
||||
const point = layer.ctx.getImageData(x, y, 1, 1).data;
|
||||
return { hasSource: !!state.cloneSourceSnapshot, pixel: [...point] };
|
||||
}, { xRatio: 0.72, yRatio: 0.5 });
|
||||
expect(result.hasSource).toBe(true);
|
||||
expect(result.pixel[0]).toBeGreaterThan(80);
|
||||
expect(result.pixel[3]).toBeGreaterThan(0);
|
||||
await expect(page.locator('#ge-clone-source-label')).toHaveText('Active layer sampled');
|
||||
await page.locator('#ge-clone-source-clear').click();
|
||||
await expect(page.locator('#ge-clone-source-label')).toHaveText('No source selected');
|
||||
});
|
||||
|
||||
test('smudge carries nearby pixels along a stroke and undoes atomically', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Smudge E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await page.locator('.ge-size-slider').fill('700');
|
||||
await page.locator('.ge-color-picker').first().evaluate((input) => {
|
||||
input.value = '#d24b62';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
const source = await canvasPoint(page, 0.28, 0.5);
|
||||
const target = await canvasPoint(page, 0.55, 0.5);
|
||||
await page.mouse.click(source.x, source.y);
|
||||
|
||||
const before = await page.evaluate(() => {
|
||||
const canvas = document.querySelector('.ge-main-canvas');
|
||||
const x = Math.round(canvas.width * 0.55);
|
||||
const y = Math.round(canvas.height * 0.5);
|
||||
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
|
||||
});
|
||||
await page.locator('.ge-tool-btn[data-tool="smudge"]').click();
|
||||
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
|
||||
await page.mouse.move(source.x, source.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(target.x, target.y, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
const after = await page.evaluate(() => {
|
||||
const canvas = document.querySelector('.ge-main-canvas');
|
||||
const x = Math.round(canvas.width * 0.55);
|
||||
const y = Math.round(canvas.height * 0.5);
|
||||
const ctx = canvas.getContext('2d');
|
||||
return [...ctx.getImageData(x, y, 1, 1).data];
|
||||
});
|
||||
expect(before[0]).toBeGreaterThan(220);
|
||||
expect(before[3]).toBe(255);
|
||||
expect(after[0]).toBeLessThan(220);
|
||||
expect(after[2]).toBeLessThan(220);
|
||||
expect(after[3]).toBeGreaterThan(0);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect(await page.evaluate(() => {
|
||||
const canvas = document.querySelector('.ge-main-canvas');
|
||||
const x = Math.round(canvas.width * 0.55);
|
||||
const y = Math.round(canvas.height * 0.5);
|
||||
return canvas.getContext('2d').getImageData(x, y, 1, 1).data[3];
|
||||
})).toBe(255);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, openBlankEditor } = require('./helpers.js');
|
||||
|
||||
async function mainCanvasHash(page) {
|
||||
return page.evaluate(() => {
|
||||
const canvas = document.querySelector('.ge-main-canvas');
|
||||
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
hash ^= data[index];
|
||||
hash = Math.imul(hash, 16777619);
|
||||
hash ^= data[index + 1];
|
||||
hash = Math.imul(hash, 16777619);
|
||||
hash ^= data[index + 2];
|
||||
hash = Math.imul(hash, 16777619);
|
||||
hash ^= data[index + 3];
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
});
|
||||
}
|
||||
|
||||
test('before compare is visual-only and toggles back to the edited document', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 180 }, 'Compare mode E2E');
|
||||
await expect(page.locator('#ge-compare-btn')).toBeVisible();
|
||||
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
|
||||
|
||||
const baselineHash = await mainCanvasHash(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
|
||||
const editedState = await editorState(page);
|
||||
const editedHash = await mainCanvasHash(page);
|
||||
expect(editedHash).not.toBe(baselineHash);
|
||||
|
||||
await page.locator('#ge-compare-btn').click();
|
||||
await expect(page.locator('#ge-compare-btn')).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(await mainCanvasHash(page)).toBe(baselineHash);
|
||||
expect((await editorState(page)).layers).toEqual(editedState.layers);
|
||||
|
||||
await page.locator('#ge-compare-btn').click();
|
||||
await expect(page.locator('#ge-compare-btn')).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(await mainCanvasHash(page)).toBe(editedHash);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
const fs = require('node:fs');
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const {
|
||||
compareExportPixels,
|
||||
dragOnCanvas,
|
||||
encodedImagePixelDigest,
|
||||
editorState,
|
||||
flattenedPixelDigest,
|
||||
openBlankEditor,
|
||||
openExportDialog,
|
||||
reopenDraft,
|
||||
waitForDraft,
|
||||
} = require('./helpers.js');
|
||||
|
||||
test('layered document survives edit, mask, transform, crop, reopen, and export', async ({ page, request }) => {
|
||||
await openBlankEditor(page);
|
||||
const initial = await editorState(page);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await dragOnCanvas(page, { x: 0.20, y: 0.30 }, { x: 0.72, y: 0.60 });
|
||||
const painted = await editorState(page);
|
||||
const paintedLayer = painted.layers.find(layer => layer.name === 'Edit');
|
||||
const initialLayer = initial.layers.find(layer => layer.name === 'Edit');
|
||||
expect(paintedLayer.pixelHash).not.toBe(initialLayer.pixelHash);
|
||||
|
||||
const editItem = page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first();
|
||||
await editItem.click();
|
||||
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
|
||||
let current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.name === 'Edit').masks).toHaveLength(1);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await expect(page.locator('#ge-transform-w')).toBeVisible();
|
||||
await page.locator('#ge-transform-w').fill('560');
|
||||
await page.locator('#ge-transform-apply').click();
|
||||
current = await editorState(page);
|
||||
const transformed = current.layers.find(layer => layer.name === 'Edit');
|
||||
expect(transformed.size[0]).toBe(560);
|
||||
expect(transformed.masks[0].size).toEqual(transformed.size);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').size[0]).toBe(640);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').size[0]).toBe(560);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="text"]').click();
|
||||
await expect(page.locator('#ge-text-section')).toBeVisible();
|
||||
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.click(canvasBox.x + canvasBox.width * 0.28, canvasBox.y + canvasBox.height * 0.22);
|
||||
await page.locator('#ge-text-content').fill('Durable photo edit');
|
||||
await page.locator('#ge-text-size').fill('42');
|
||||
await page.locator('#ge-text-size').press('Enter');
|
||||
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Durable photo edit' })).toBeVisible();
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
|
||||
await dragOnCanvas(page, { x: 0.08, y: 0.10 }, { x: 0.92, y: 0.88 });
|
||||
await expect(page.locator('.ge-crop-apply-btn')).toBeVisible();
|
||||
await page.locator('.ge-crop-apply-btn').click();
|
||||
const cropped = await editorState(page);
|
||||
expect(cropped.dimensions[0]).toBeLessThan(640);
|
||||
expect(cropped.dimensions[1]).toBeLessThan(480);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).dimensions).toEqual([640, 480]);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect((await editorState(page)).dimensions).toEqual(cropped.dimensions);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
const beforeReopen = await editorState(page);
|
||||
const beforeReopenPixels = await flattenedPixelDigest(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.dimensions).toEqual(beforeReopen.dimensions);
|
||||
expect(reopened.activeLayerId).toBe(beforeReopen.activeLayerId);
|
||||
expect(reopened.layers).toEqual(beforeReopen.layers);
|
||||
expect(await flattenedPixelDigest(page)).toEqual(beforeReopenPixels);
|
||||
|
||||
await openExportDialog(page);
|
||||
await page.locator('[data-format="png"]').click();
|
||||
await page.locator('#ge-export-width').fill('320');
|
||||
const expectedHeight = Number(await page.locator('#ge-export-height').inputValue());
|
||||
await page.locator('#ge-export-filename').fill('photo-editor-release-gate');
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.locator('.ge-export-dialog button[type="submit"]').click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('photo-editor-release-gate.png');
|
||||
const bytes = fs.readFileSync(await download.path());
|
||||
expect(bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
|
||||
expect(bytes.readUInt32BE(16)).toBe(320);
|
||||
expect(bytes.readUInt32BE(20)).toBe(expectedHeight);
|
||||
const resizedComparison = await compareExportPixels(page, bytes, { width: 320, height: expectedHeight });
|
||||
expect(resizedComparison.meanAbsoluteError).toBeLessThanOrEqual(1);
|
||||
expect(resizedComparison.maximumError).toBeLessThanOrEqual(32);
|
||||
expect(resizedComparison.changedPixelRatio).toBeLessThanOrEqual(0.08);
|
||||
|
||||
await openExportDialog(page);
|
||||
await page.locator('[data-format="png"]').click();
|
||||
await page.locator('#ge-export-filename').fill('photo-editor-native-fidelity');
|
||||
const nativeDownloadPromise = page.waitForEvent('download');
|
||||
await page.locator('.ge-export-dialog button[type="submit"]').click();
|
||||
const nativeDownload = await nativeDownloadPromise;
|
||||
const nativeBytes = fs.readFileSync(await nativeDownload.path());
|
||||
expect(await encodedImagePixelDigest(page, nativeBytes)).toEqual(beforeReopenPixels);
|
||||
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
|
||||
|
||||
test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Gesture cancellation');
|
||||
const canvas = page.locator('.ge-main-canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
|
||||
await page.mouse.move(box.x + 40, box.y + 35);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
|
||||
expect(await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { cropping: state.cropping, rect: state.cropRect };
|
||||
})).toMatchObject({ cropping: true, rect: { w: 150, h: 115 } });
|
||||
await page.locator('.ge-tool-btn[data-tool="move"]').dispatchEvent('click');
|
||||
expect(await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { tool: state.tool, cropping: state.cropping, moving: state.cropMoving, rect: state.cropRect };
|
||||
})).toEqual({ tool: 'move', cropping: false, moving: false, rect: null });
|
||||
await page.mouse.up();
|
||||
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
|
||||
const beforeMove = await editorState(page);
|
||||
const bounds = beforeMove.selection.bounds;
|
||||
await page.mouse.move(
|
||||
box.x + bounds.x + bounds.width / 2,
|
||||
box.y + bounds.y + bounds.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + bounds.x + bounds.width / 2 + 35, box.y + bounds.y + bounds.height / 2 + 20);
|
||||
expect((await editorState(page)).selection.bounds).not.toEqual(bounds);
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').dispatchEvent('click');
|
||||
const afterSwitch = await editorState(page);
|
||||
expect(afterSwitch.selection.bounds).toEqual(bounds);
|
||||
expect(afterSwitch.undo).toBe(beforeMove.undo);
|
||||
expect(await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { tool: state.tool, selectionMoving: state.selectionMoving, marqueeActive: state.marqueeActive };
|
||||
})).toEqual({ tool: 'brush', selectionMoving: false, marqueeActive: false });
|
||||
await page.mouse.up();
|
||||
expect((await editorState(page)).selection.bounds).toEqual(bounds);
|
||||
});
|
||||
|
||||
test('Escape cancels an active crop without changing the document', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Escape crop cancellation');
|
||||
const canvas = page.locator('.ge-main-canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
|
||||
await page.mouse.move(box.x + 40, box.y + 35);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.mouse.up();
|
||||
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { cropping: state.cropping, moving: state.cropMoving, rect: state.cropRect };
|
||||
})).toEqual({ cropping: false, moving: false, rect: null });
|
||||
});
|
||||
|
||||
test('touch crop and selection gestures complete through the shared lifecycle', async ({ browser, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch gestures');
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
|
||||
type,
|
||||
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
|
||||
});
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
|
||||
await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30);
|
||||
await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145);
|
||||
await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145);
|
||||
await expect(page.locator('.ge-crop-apply')).toBeVisible();
|
||||
expect(await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.cropRect;
|
||||
})).toMatchObject({ w: 155, h: 115 });
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
|
||||
await touch('touchStart', canvasBox.x + 45, canvasBox.y + 40);
|
||||
await touch('touchMove', canvasBox.x + 165, canvasBox.y + 125);
|
||||
await touch('touchEnd', canvasBox.x + 165, canvasBox.y + 125);
|
||||
expect((await editorState(page)).selection.bounds).toBeTruthy();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('touch brush paints and undoes as one stroke', async ({ browser, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch brush');
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
|
||||
type,
|
||||
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
|
||||
});
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
const before = await flattenedPixelDigest(page);
|
||||
await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5);
|
||||
await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
|
||||
await touch('touchEnd', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
|
||||
const after = await flattenedPixelDigest(page);
|
||||
expect(after).not.toEqual(before);
|
||||
await page.locator('#ge-undo').click();
|
||||
expect(await flattenedPixelDigest(page)).toEqual(before);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('touch cancellation rolls back a partial brush stroke', async ({ browser, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch cancellation');
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
const before = await flattenedPixelDigest(page);
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: canvasBox.x + 70, y: canvasBox.y + 110, id: 1 }],
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ x: canvasBox.x + 230, y: canvasBox.y + 110, id: 1 }],
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] });
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.drawing;
|
||||
})).toBe(false);
|
||||
expect(await flattenedPixelDigest(page)).toEqual(before);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('pen input completes a crop gesture', async ({ page, context, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP pen injection');
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Pen crop gesture');
|
||||
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed', pointerType: 'pen', button: 'left', buttons: 1,
|
||||
clickCount: 1, x: box.x + 30, y: box.y + 25,
|
||||
});
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseMoved', pointerType: 'pen', button: 'none', buttons: 1,
|
||||
x: box.x + 175, y: box.y + 130,
|
||||
});
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased', pointerType: 'pen', button: 'left', buttons: 0,
|
||||
clickCount: 1, x: box.x + 175, y: box.y + 130,
|
||||
});
|
||||
await expect(page.locator('.ge-crop-apply')).toBeVisible();
|
||||
expect(await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { cropping: state.cropping, rect: state.cropRect };
|
||||
})).toEqual({ cropping: false, rect: { x: 30, y: 25, w: 145, h: 105 } });
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
const fs = require('node:fs');
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const {
|
||||
compareExportPixels,
|
||||
encodedImagePixelDigest,
|
||||
editorState,
|
||||
flattenedPixelDigest,
|
||||
openBlankEditor,
|
||||
openExportDialog,
|
||||
reopenDraft,
|
||||
waitForDraft,
|
||||
} = require('./helpers.js');
|
||||
|
||||
async function seedGradient(page) {
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
const gradient = layer.ctx.createLinearGradient(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
gradient.addColorStop(0, '#d43f67');
|
||||
gradient.addColorStop(0.5, '#4aa884');
|
||||
gradient.addColorStop(1, '#3868d4');
|
||||
layer.ctx.fillStyle = gradient;
|
||||
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
window.galleryEditorComposite?.();
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadFormat(page, format, filename) {
|
||||
await openExportDialog(page);
|
||||
await page.locator(`[data-format="${format}"]`).click();
|
||||
await page.locator('#ge-export-filename').fill(filename);
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.locator('.ge-export-dialog button[type="submit"]').click();
|
||||
const download = await downloadPromise;
|
||||
return {
|
||||
name: download.suggestedFilename(),
|
||||
bytes: fs.readFileSync(await download.path()),
|
||||
};
|
||||
}
|
||||
|
||||
async function addAdjustment(page, type) {
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await expect(page.locator('.ge-add-layer-menu')).toBeVisible();
|
||||
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${type}"]`).click();
|
||||
await expect(page.locator('.ge-adj-popup')).toBeVisible();
|
||||
}
|
||||
|
||||
async function exportCurrentPng(page) {
|
||||
const dataUrl = await page.evaluate(async () => {
|
||||
const editor = await import('/static/js/galleryEditor.js');
|
||||
return editor.exportPNG();
|
||||
});
|
||||
return Buffer.from(dataUrl.split(',', 2)[1], 'base64');
|
||||
}
|
||||
|
||||
test('every retained adjustment family exports the visible composite exactly', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 128, height: 96 }, 'Adjustment export matrix');
|
||||
await seedGradient(page);
|
||||
|
||||
const cases = [
|
||||
['brightness-contrast', 'brightness', '35'],
|
||||
['exposure', 'exposure', '35'],
|
||||
['white-balance', 'temperature', '30'],
|
||||
['hue-saturation', 'hue', '30'],
|
||||
['vibrance', 'vibrance', '45'],
|
||||
['black-white', 'red', '60'],
|
||||
['shadows-highlights', 'shadows', '35'],
|
||||
['levels', 'inBlack', '20'],
|
||||
['color-balance', 'shadows-r', '35'],
|
||||
['selective-color', 'cyan', '30'],
|
||||
];
|
||||
|
||||
for (const [type, key, value] of cases) {
|
||||
await addAdjustment(page, type);
|
||||
const control = page.locator(`.ge-adj-row input[data-key="${key}"]`);
|
||||
await control.fill(value);
|
||||
await control.dispatchEvent('input');
|
||||
await page.locator('[data-adj-action="ok"]').click();
|
||||
const visibleDigest = await flattenedPixelDigest(page);
|
||||
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(visibleDigest);
|
||||
}
|
||||
|
||||
await addAdjustment(page, 'curves');
|
||||
const curve = page.locator('.ge-curves-canvas');
|
||||
const curveBox = await curve.boundingBox();
|
||||
await page.mouse.click(curveBox.x + curveBox.width * 0.5, curveBox.y + curveBox.height * 0.28);
|
||||
await page.locator('[data-adj-action="ok"]').click();
|
||||
const curveDigest = await flattenedPixelDigest(page);
|
||||
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(curveDigest);
|
||||
|
||||
await addAdjustment(page, 'gradient-map');
|
||||
await page.locator('[data-gradient-key="shadows"]').fill('#102030');
|
||||
await page.locator('[data-gradient-key="shadows"]').dispatchEvent('input');
|
||||
await page.locator('[data-adj-action="ok"]').click();
|
||||
const gradientDigest = await flattenedPixelDigest(page);
|
||||
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(gradientDigest);
|
||||
});
|
||||
|
||||
test('composited correction exports preserve pixels and format metadata', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity');
|
||||
await seedGradient(page);
|
||||
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
|
||||
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('35');
|
||||
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
|
||||
await page.locator('[data-adj-action="ok"]').click();
|
||||
const renderedPixels = await flattenedPixelDigest(page);
|
||||
|
||||
const png = await downloadFormat(page, 'png', 'correction');
|
||||
expect(png.name).toBe('correction.png');
|
||||
expect(png.bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
|
||||
expect(png.bytes.readUInt32BE(16)).toBe(160);
|
||||
expect(png.bytes.readUInt32BE(20)).toBe(120);
|
||||
expect(await encodedImagePixelDigest(page, png.bytes)).toEqual(renderedPixels);
|
||||
|
||||
const jpeg = await downloadFormat(page, 'jpeg', 'correction-jpeg');
|
||||
expect(jpeg.name).toBe('correction-jpeg.jpg');
|
||||
expect(jpeg.bytes[0]).toBe(0xff);
|
||||
expect(jpeg.bytes[1]).toBe(0xd8);
|
||||
|
||||
const webp = await downloadFormat(page, 'webp', 'correction-webp');
|
||||
expect(webp.name).toBe('correction-webp.webp');
|
||||
expect(webp.bytes.subarray(0, 4).toString('ascii')).toBe('RIFF');
|
||||
expect(webp.bytes.subarray(8, 12).toString('ascii')).toBe('WEBP');
|
||||
|
||||
const comparison = await compareExportPixels(page, png.bytes, { width: 160, height: 120 });
|
||||
expect(comparison.meanAbsoluteError).toBe(0);
|
||||
expect(comparison.maximumError).toBe(0);
|
||||
|
||||
const exposure = (await editorState(page)).layers.find(layer => layer.kind === 'adjustment');
|
||||
const exposureRow = page.locator(`.ge-layer-item[data-layer-id="${exposure.id}"]`);
|
||||
await exposureRow.locator('.ge-layer-opacity').fill('62');
|
||||
await exposureRow.locator('.ge-layer-opacity').dispatchEvent('input');
|
||||
const opacityState = await editorState(page);
|
||||
expect(opacityState.layers.find(layer => layer.id === exposure.id).opacity).toBeCloseTo(.62, 2);
|
||||
const opacityPixels = await flattenedPixelDigest(page);
|
||||
|
||||
const opacityPng = await downloadFormat(page, 'png', 'correction-opacity');
|
||||
expect(await encodedImagePixelDigest(page, opacityPng.bytes)).toEqual(opacityPixels);
|
||||
expect(await compareExportPixels(page, opacityPng.bytes, { width: 160, height: 120 })).toEqual({
|
||||
meanAbsoluteError: 0,
|
||||
maximumError: 0,
|
||||
changedPixelRatio: 0,
|
||||
});
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.layers.find(layer => layer.id === exposure.id).opacity).toBeCloseTo(.62, 2);
|
||||
expect(await flattenedPixelDigest(page)).toEqual(opacityPixels);
|
||||
});
|
||||
|
||||
test('export preview clears stale matte pixels when transparency changes', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 160, height: 120 }, 'Export preview');
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
for (const layer of state.layers) layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
layer.ctx.fillStyle = '#2671c8';
|
||||
layer.ctx.fillRect(40, 30, 80, 60);
|
||||
window.galleryEditorComposite?.();
|
||||
});
|
||||
|
||||
await openExportDialog(page);
|
||||
const previewPixel = () => page.locator('.ge-export-preview').evaluate(canvas => (
|
||||
[...canvas.getContext('2d').getImageData(0, 0, 1, 1).data]
|
||||
));
|
||||
expect(await previewPixel()).toEqual([0, 0, 0, 0]);
|
||||
|
||||
await page.locator('#ge-export-transparency').uncheck();
|
||||
await page.locator('#ge-export-matte').click();
|
||||
await page.locator('.cp-hex').fill('#d94747');
|
||||
await page.locator('.cp-hex').press('Enter');
|
||||
expect(await previewPixel()).toEqual([217, 71, 71, 255]);
|
||||
|
||||
await page.locator('#ge-export-transparency').check();
|
||||
expect(await previewPixel()).toEqual([0, 0, 0, 0]);
|
||||
await page.locator('.ge-export-close').click();
|
||||
});
|
||||
|
||||
test('closing export returns focus to the launch control', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 160, height: 120 }, 'Export focus');
|
||||
const launch = page.locator('#ge-save-menu-btn');
|
||||
await launch.focus();
|
||||
await launch.click();
|
||||
await page.locator('#ge-download').click();
|
||||
await expect(page.locator('.ge-export-dialog')).toBeVisible();
|
||||
await page.locator('.ge-export-close').click();
|
||||
await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe('ge-save-menu-btn');
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('gallery photos body uses tall viewport without cropping or stretching cards', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1200 });
|
||||
await page.route('**/api/gallery/library?**', async route => {
|
||||
const pixel = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="160" height="160"%3E%3Crect width="160" height="160" fill="%23666"/%3E%3C/svg%3E';
|
||||
const items = Array.from({ length: 60 }, (_, index) => ({
|
||||
id: `layout-${index}`,
|
||||
url: pixel,
|
||||
thumbnail_url: pixel,
|
||||
filename: `Layout ${index}.png`,
|
||||
prompt: `Layout ${index}`,
|
||||
model: 'imported',
|
||||
created_at: '2026-08-31T00:00:00Z',
|
||||
}));
|
||||
await route.fulfill({ json: { items, total: items.length, has_more: false } });
|
||||
});
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
|
||||
await page.locator('#tool-gallery-btn').click();
|
||||
await expect(page.locator('#gallery-modal')).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect(page.locator('#gallery-grid .gallery-card')).toHaveCount(61);
|
||||
await expect(page.locator('#gallery-grid')).not.toHaveClass(/gallery-just-opened/, { timeout: 2_000 });
|
||||
const layout = await page.evaluate(() => {
|
||||
const modal = document.querySelector('.gallery-modal-content').getBoundingClientRect();
|
||||
const body = document.querySelector('#gallery-modal .modal-body').getBoundingClientRect();
|
||||
const grid = document.querySelector('#gallery-grid').getBoundingClientRect();
|
||||
const upload = document.querySelector('#gallery-upload-tile').getBoundingClientRect();
|
||||
const cards = [...document.querySelectorAll('#gallery-grid .gallery-card')]
|
||||
.slice(0, 12)
|
||||
.map(card => card.getBoundingClientRect());
|
||||
return { modal, body, grid, upload, cards, viewportHeight: innerHeight };
|
||||
});
|
||||
|
||||
expect(layout.grid.height).toBeGreaterThan(layout.viewportHeight * 0.65);
|
||||
expect(layout.grid.bottom).toBeLessThanOrEqual(layout.body.bottom + 1);
|
||||
expect(Math.abs(layout.upload.width - layout.upload.height)).toBeLessThanOrEqual(2);
|
||||
for (let i = 0; i < layout.cards.length; i += 1) {
|
||||
for (let j = i + 1; j < layout.cards.length; j += 1) {
|
||||
const a = layout.cards[i];
|
||||
const b = layout.cards[j];
|
||||
const overlaps = a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||||
expect(overlaps).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const fs = require('node:fs');
|
||||
const {
|
||||
compareExportPixels,
|
||||
dragOnCanvas,
|
||||
editorState,
|
||||
flattenedPixelDigest,
|
||||
openBlankEditor,
|
||||
openExportDialog,
|
||||
reopenDraft,
|
||||
waitForDraft,
|
||||
} = require('./helpers.js');
|
||||
|
||||
async function downloadPng(page, filename) {
|
||||
await openExportDialog(page);
|
||||
await page.locator('[data-format="png"]').click();
|
||||
await page.locator('#ge-export-filename').fill(filename);
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.locator('.ge-export-dialog button[type="submit"]').click();
|
||||
const download = await downloadPromise;
|
||||
return fs.readFileSync(await download.path());
|
||||
}
|
||||
|
||||
test('group retained effects edit, toggle, reorder surface, and survive reopen', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 360, height: 240 }, 'Group effects E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
state.brushSize = 48;
|
||||
state.brushSoftness = 0;
|
||||
});
|
||||
await dragOnCanvas(page, { x: 0.25, y: 0.35 }, { x: 0.75, y: 0.65 });
|
||||
|
||||
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
|
||||
await backgroundRow.click({ modifiers: ['Control'] });
|
||||
await page.locator('#ge-group-selected').click();
|
||||
const groupId = (await editorState(page)).groups[0].id;
|
||||
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
|
||||
|
||||
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
|
||||
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
|
||||
let current = await editorState(page);
|
||||
expect(current.groups[0].effects).toHaveLength(1);
|
||||
expect(current.groups[0].effects[0].params.radius).toBe(14);
|
||||
const afterAdd = await flattenedPixelDigest(page);
|
||||
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
|
||||
|
||||
await page.locator('.ge-group-effect-sub-item .ge-adj-sub-name').click();
|
||||
await page.locator('.ge-filter-row input[data-key="radius"]').fill('22');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
current = await editorState(page);
|
||||
expect(current.groups[0].effects[0].params.radius).toBe(22);
|
||||
expect(await flattenedPixelDigest(page)).not.toEqual(afterAdd);
|
||||
|
||||
await page.locator('.ge-group-effect-sub-item .ge-layer-vis').click();
|
||||
expect((await editorState(page)).groups[0].effects[0].visible).toBe(false);
|
||||
await page.locator('.ge-group-effect-sub-item .ge-layer-vis').click();
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
const beforeReopen = await editorState(page);
|
||||
await reopenDraft(page, draftId);
|
||||
expect((await editorState(page)).groups).toEqual(beforeReopen.groups);
|
||||
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
|
||||
|
||||
await page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`).click();
|
||||
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
|
||||
test('group retained effects export the visible composite and survive reopen', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 180 }, 'Group effects export E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
state.brushSize = 64;
|
||||
state.brushSoftness = 0;
|
||||
});
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
|
||||
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
|
||||
await page.locator('#ge-group-selected').click();
|
||||
|
||||
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
|
||||
await page.locator('[data-filter-action="effect-color-overlay"]').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
|
||||
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('40');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
|
||||
const renderedPixels = await flattenedPixelDigest(page);
|
||||
const pngBytes = await downloadPng(page, 'group-effects');
|
||||
const comparison = await compareExportPixels(page, pngBytes, { width: 240, height: 180 });
|
||||
expect(comparison.meanAbsoluteError).toBe(0);
|
||||
expect(comparison.maximumError).toBe(0);
|
||||
expect(await flattenedPixelDigest(page)).toEqual(renderedPixels);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.groups[0].effects).toHaveLength(1);
|
||||
expect(reopened.groups[0].effects[0]).toMatchObject({
|
||||
type: 'color-overlay',
|
||||
params: { color: '#336699', opacity: 0.4 },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
async function compositeAlphaAtCenter(page) {
|
||||
return page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const x = Math.floor(state.imgWidth / 2);
|
||||
const y = Math.floor(state.imgHeight / 2);
|
||||
return state.documentCompositeCanvas.getContext('2d').getImageData(x, y, 1, 1).data[3];
|
||||
});
|
||||
}
|
||||
|
||||
test('group mask paints, toggles, survives history and reopen, and protects ungroup', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 420, height: 300 }, 'Group mask E2E');
|
||||
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
|
||||
await page.locator('#ge-group-selected').click();
|
||||
const groupId = (await editorState(page)).groups[0].id;
|
||||
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
|
||||
await page.locator('#ge-layer-tools .ge-group-mask-btn').click();
|
||||
|
||||
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
|
||||
await expect(page.locator('#ge-layer-tools button[title="Delete group masks before ungrouping"]')).toBeDisabled();
|
||||
let current = await editorState(page);
|
||||
expect(current.groups[0].masks).toHaveLength(1);
|
||||
expect(current.groups[0].masks[0]).toMatchObject({ mode: 'group', size: [420, 300] });
|
||||
const whiteMaskHash = current.groups[0].masks[0].pixelHash;
|
||||
expect(await compositeAlphaAtCenter(page)).toBe(255);
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
state.brushSize = 90;
|
||||
state.eraserSoftness = 0;
|
||||
});
|
||||
await dragOnCanvas(page, { x: 0.46, y: 0.5 }, { x: 0.54, y: 0.5 });
|
||||
current = await editorState(page);
|
||||
const erasedMaskHash = current.groups[0].masks[0].pixelHash;
|
||||
expect(erasedMaskHash).not.toBe(whiteMaskHash);
|
||||
expect(await compositeAlphaAtCenter(page)).toBe(0);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).groups[0].masks[0].pixelHash).toBe(whiteMaskHash);
|
||||
expect(await compositeAlphaAtCenter(page)).toBe(255);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect((await editorState(page)).groups[0].masks[0].pixelHash).toBe(erasedMaskHash);
|
||||
expect(await compositeAlphaAtCenter(page)).toBe(0);
|
||||
|
||||
const maskRow = page.locator('.ge-group-mask-sub-item');
|
||||
await maskRow.locator('.ge-layer-vis').click();
|
||||
expect(await compositeAlphaAtCenter(page)).toBe(255);
|
||||
await maskRow.locator('.ge-layer-vis').click();
|
||||
expect(await compositeAlphaAtCenter(page)).toBe(0);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
const beforeReopen = await editorState(page);
|
||||
await reopenDraft(page, draftId);
|
||||
current = await editorState(page);
|
||||
expect(current.groups).toEqual(beforeReopen.groups);
|
||||
expect(await compositeAlphaAtCenter(page)).toBe(0);
|
||||
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
|
||||
|
||||
await page.locator('.ge-group-mask-sub-item button[title="Delete group mask"]').click();
|
||||
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(0);
|
||||
await groupRow.click();
|
||||
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
|
||||
expect((await editorState(page)).groups).toHaveLength(0);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
test('group drag reorders the complete subtree with undo and server persistence', async ({ page, request }) => {
|
||||
await openBlankEditor(page, { width: 420, height: 300 }, 'Group reorder E2E');
|
||||
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
|
||||
await page.locator('#ge-group-selected').click();
|
||||
let current = await editorState(page);
|
||||
const group = current.groups[0];
|
||||
const originalOrder = current.layers.map(layer => layer.id);
|
||||
|
||||
await page.locator('#ge-add-layer').click();
|
||||
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||
current = await editorState(page);
|
||||
const looseId = current.activeLayerId;
|
||||
expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
|
||||
|
||||
const handle = page.locator(`.ge-layer-group-row[data-group-id="${group.id}"] .ge-layer-group-drag`);
|
||||
const looseRow = page.locator(`.ge-layer-item[data-layer-id="${looseId}"]`);
|
||||
const handleBox = await handle.boundingBox();
|
||||
const looseBox = await looseRow.boundingBox();
|
||||
const dragReadiness = await page.evaluate(async ({ groupId, point }) => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const { groupSiblingUnits } = await import('/static/js/editor/layer-groups.js');
|
||||
const group = state.layerGroups.find(item => item.id === groupId);
|
||||
const hit = document.elementFromPoint(point.x, point.y);
|
||||
return {
|
||||
siblingIds: groupSiblingUnits(state, group.parentId || null).map(unit => unit.id),
|
||||
hitHandle: !!hit?.closest('.ge-layer-group-drag'),
|
||||
};
|
||||
}, { groupId: group.id, point: { x: handleBox.x + handleBox.width / 2, y: handleBox.y + handleBox.height / 2 } });
|
||||
expect(dragReadiness.hitHandle, JSON.stringify(dragReadiness)).toBe(true);
|
||||
expect(dragReadiness.siblingIds).toEqual([group.id, looseId]);
|
||||
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(looseBox.x + looseBox.width / 2, looseBox.y + 1, { steps: 8 });
|
||||
await expect(page.locator('.ge-group-drop-line')).toBeVisible();
|
||||
await page.mouse.up();
|
||||
|
||||
current = await editorState(page);
|
||||
expect(current.layers.map(layer => layer.id)).toEqual([looseId, ...originalOrder]);
|
||||
expect(current.groups[0].layerIds).toEqual(group.layerIds);
|
||||
|
||||
await page.locator('#ge-undo').click();
|
||||
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
|
||||
await page.locator('#ge-redo').click();
|
||||
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([looseId, ...originalOrder]);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
const beforeReopen = await editorState(page);
|
||||
await reopenDraft(page, draftId);
|
||||
current = await editorState(page);
|
||||
expect(current.layers.map(layer => layer.id)).toEqual(beforeReopen.layers.map(layer => layer.id));
|
||||
expect(current.groups).toEqual(beforeReopen.groups);
|
||||
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
|
||||
});
|
||||
|
||||
test('mobile layer drag handle supports long-press reorder', async ({ browser, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder');
|
||||
await page.locator('.ge-layers-header').click();
|
||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
|
||||
const before = await editorState(page);
|
||||
// State is bottom-to-top; the panel is top-to-bottom.
|
||||
const dragId = before.layers[0].id;
|
||||
const targetId = before.layers[before.layers.length - 1].id;
|
||||
const bottomHandle = page.locator(`.ge-layer-item[data-layer-id="${dragId}"] .ge-layer-drag`);
|
||||
const topRow = page.locator(`.ge-layer-item[data-layer-id="${targetId}"]`);
|
||||
await bottomHandle.scrollIntoViewIfNeeded();
|
||||
const handleBox = await bottomHandle.boundingBox();
|
||||
const topBox = await topRow.boundingBox();
|
||||
expect(handleBox.width).toBeGreaterThanOrEqual(24);
|
||||
const point = { x: handleBox.x + handleBox.width / 2, y: handleBox.y + handleBox.height / 2 };
|
||||
const hitHandle = await page.evaluate(({ x, y }) => {
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
return !!hit?.closest('.ge-layer-drag');
|
||||
}, point);
|
||||
expect(hitHandle).toBe(true);
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: point.x, y: point.y, id: 1, radiusX: 8, radiusY: 8 }],
|
||||
});
|
||||
await page.waitForTimeout(450);
|
||||
await expect(page.locator(`.ge-layer-item[data-layer-id="${dragId}"]`)).toHaveClass(/dragging/);
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ x: point.x, y: topBox.y + 1, id: 1, radiusX: 8, radiusY: 8 }],
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchEnd',
|
||||
touchPoints: [],
|
||||
});
|
||||
await page.waitForTimeout(120);
|
||||
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([targetId, dragId]);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
const { expect } = require('@playwright/test');
|
||||
|
||||
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
|
||||
let lastError = null;
|
||||
for (let pageAttempt = 0; pageAttempt < 3; pageAttempt += 1) {
|
||||
try {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
|
||||
await page.evaluate(async () => {
|
||||
window.__photoEditorImport = async path => {
|
||||
let lastImportError = null;
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
const url = attempt === 0 ? path : `${path}?e2e_retry=${attempt}-${Date.now()}`;
|
||||
try { return await import(url); } catch (error) { lastImportError = error; }
|
||||
await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1)));
|
||||
}
|
||||
throw lastImportError;
|
||||
};
|
||||
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
|
||||
gallery.openGallery();
|
||||
});
|
||||
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
|
||||
await page.locator('#gallery-editor-tab').click();
|
||||
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
|
||||
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
|
||||
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
|
||||
}, { size, name });
|
||||
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await page.goto('about:blank');
|
||||
await page.waitForTimeout(250 * (pageAttempt + 1));
|
||||
}
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.layers.length;
|
||||
})).toBe(2);
|
||||
}
|
||||
|
||||
async function editorState(page) {
|
||||
return page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const hashCanvas = canvas => {
|
||||
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < data.length; index += Math.max(4, Math.floor(data.length / 20_000 / 4) * 4)) {
|
||||
const alpha = data[index + 3] / 255;
|
||||
hash ^= Math.round(data[index] * alpha);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
hash ^= Math.round(data[index + 1] * alpha);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
hash ^= Math.round(data[index + 2] * alpha);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
hash ^= data[index + 3];
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
};
|
||||
const selectionBounds = canvas => {
|
||||
if (!canvas) return null;
|
||||
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let left = canvas.width, top = canvas.height, right = -1, bottom = -1;
|
||||
for (let y = 0; y < canvas.height; y += 1) for (let x = 0; x < canvas.width; x += 1) {
|
||||
if (data[(y * canvas.width + x) * 4 + 3] < 1) continue;
|
||||
left = Math.min(left, x); top = Math.min(top, y);
|
||||
right = Math.max(right, x); bottom = Math.max(bottom, y);
|
||||
}
|
||||
return right < left ? null : { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
|
||||
};
|
||||
return {
|
||||
documentRenderReady: !!state.documentRenderReady,
|
||||
dimensions: [state.imgWidth, state.imgHeight],
|
||||
activeLayerId: state.activeLayerId,
|
||||
selectedLayerIds: [...(state.selectedLayerIds || [])],
|
||||
groups: (state.layerGroups || []).map(group => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
layerIds: [...group.layerIds],
|
||||
parentId: group.parentId || null,
|
||||
visible: group.visible !== false,
|
||||
opacity: group.opacity,
|
||||
blendMode: group.blendMode,
|
||||
locked: !!group.locked,
|
||||
collapsed: !!group.collapsed,
|
||||
activeMaskId: group.activeMaskId || null,
|
||||
effects: (group.effects || []).map(effect => ({
|
||||
id: effect.id,
|
||||
type: effect.type,
|
||||
name: effect.name,
|
||||
visible: effect.visible !== false,
|
||||
opacity: effect.opacity,
|
||||
params: JSON.parse(JSON.stringify(effect.params || {})),
|
||||
})),
|
||||
masks: (group.masks || []).map(mask => ({
|
||||
id: mask.id,
|
||||
name: mask.name,
|
||||
visible: mask.visible !== false,
|
||||
density: Number.isFinite(Number(mask.density)) ? mask.density : 1,
|
||||
feather: Number.isFinite(Number(mask.feather)) ? mask.feather : 0,
|
||||
mode: mask.mode,
|
||||
size: [mask.canvas.width, mask.canvas.height],
|
||||
pixelHash: hashCanvas(mask.canvas),
|
||||
})),
|
||||
})),
|
||||
layers: state.layers.map(layer => ({
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
kind: layer.kind || 'raster',
|
||||
effects: (layer.effects || []).map(effect => ({
|
||||
id: effect.id,
|
||||
type: effect.type,
|
||||
name: effect.name,
|
||||
visible: effect.visible !== false,
|
||||
opacity: effect.opacity,
|
||||
mask: effect.mask ? {
|
||||
visible: effect.mask.visible !== false,
|
||||
size: [effect.mask.canvas?.width || effect.mask.canvasW, effect.mask.canvas?.height || effect.mask.canvasH],
|
||||
} : null,
|
||||
params: JSON.parse(JSON.stringify(effect.params || {})),
|
||||
})),
|
||||
visible: layer.visible !== false,
|
||||
opacity: layer.opacity,
|
||||
locked: !!layer.locked,
|
||||
locks: {
|
||||
pixels: !!layer.locks?.pixels,
|
||||
transparency: !!layer.locks?.transparency,
|
||||
position: !!layer.locks?.position,
|
||||
},
|
||||
clipped: !!layer.clipped,
|
||||
size: [layer.canvas.width, layer.canvas.height],
|
||||
offset: state.layerOffsets.get(layer.id) || { x: 0, y: 0 },
|
||||
pixelHash: hashCanvas(layer.canvas),
|
||||
text: layer.text ? {
|
||||
content: layer.text.content,
|
||||
fontSize: layer.text.fontSize,
|
||||
fontFamily: layer.text.fontFamily,
|
||||
lineHeight: layer.text.lineHeight,
|
||||
letterSpacing: layer.text.letterSpacing,
|
||||
frameWidth: layer.text.frameWidth,
|
||||
frameHeight: layer.text.frameHeight,
|
||||
verticalAlign: layer.text.verticalAlign,
|
||||
autoWidth: layer.text.autoWidth,
|
||||
transform: { ...layer.text.transform },
|
||||
} : null,
|
||||
shape: layer.shape ? {
|
||||
type: layer.shape.type,
|
||||
width: layer.shape.width,
|
||||
height: layer.shape.height,
|
||||
fillColor: layer.shape.fillColor,
|
||||
fillType: layer.shape.fillType,
|
||||
gradientStart: layer.shape.gradientStart,
|
||||
gradientMid: layer.shape.gradientMid,
|
||||
gradientMidEnabled: layer.shape.gradientMidEnabled,
|
||||
gradientMidPosition: layer.shape.gradientMidPosition,
|
||||
gradientEnd: layer.shape.gradientEnd,
|
||||
gradientStops: layer.shape.gradientStops ? layer.shape.gradientStops.map(stop => ({ ...stop })) : undefined,
|
||||
gradientAngle: layer.shape.gradientAngle,
|
||||
strokeColor: layer.shape.strokeColor,
|
||||
strokeWidth: layer.shape.strokeWidth,
|
||||
cornerRadius: layer.shape.cornerRadius,
|
||||
sides: layer.shape.sides,
|
||||
transform: { ...layer.shape.transform },
|
||||
} : null,
|
||||
adjustment: layer.adjustment ? JSON.parse(JSON.stringify(layer.adjustment)) : null,
|
||||
placed: layer.kind === 'placed' && layer.placed?.sourceCanvas ? {
|
||||
sourceSize: [layer.placed.sourceCanvas.width, layer.placed.sourceCanvas.height],
|
||||
sourceName: layer.placed.sourceName,
|
||||
matrix: [...layer.placed.matrix],
|
||||
sourcePixelHash: hashCanvas(layer.placed.sourceCanvas),
|
||||
} : null,
|
||||
masks: (layer.masks || []).map(mask => ({
|
||||
id: mask.id,
|
||||
mode: mask.mode,
|
||||
space: mask.space,
|
||||
linked: mask.mode === 'layer' ? mask.linked !== false : true,
|
||||
density: Number.isFinite(Number(mask.density)) ? mask.density : 1,
|
||||
feather: Number.isFinite(Number(mask.feather)) ? mask.feather : 0,
|
||||
offset: { x: Number(mask.offset?.x) || 0, y: Number(mask.offset?.y) || 0 },
|
||||
size: [mask.canvas.width, mask.canvas.height],
|
||||
pixelHash: hashCanvas(mask.canvas),
|
||||
})),
|
||||
})),
|
||||
guides: state.guides,
|
||||
draftId: state.draftId,
|
||||
undo: state.undoStack.length,
|
||||
redo: state.redoStack.length,
|
||||
quickMaskActive: !!state.quickMaskActive,
|
||||
selection: state.wandMask ? {
|
||||
space: state.wandMaskSpace,
|
||||
source: state.selectionSource,
|
||||
size: [state.wandMask.width, state.wandMask.height],
|
||||
pixelHash: hashCanvas(state.wandMask),
|
||||
bounds: selectionBounds(state.wandMask),
|
||||
} : null,
|
||||
savedSelections: (state.savedSelections || []).map(selection => ({
|
||||
id: selection.id,
|
||||
name: selection.name,
|
||||
size: [selection.canvas.width, selection.canvas.height],
|
||||
pixelHash: hashCanvas(selection.canvas),
|
||||
bounds: selectionBounds(selection.canvas),
|
||||
})),
|
||||
lastSelection: state.lastSelection?.canvas ? {
|
||||
pixelHash: hashCanvas(state.lastSelection.canvas),
|
||||
bounds: selectionBounds(state.lastSelection.canvas),
|
||||
} : null,
|
||||
persistIdle: !state.persistTimer && !state.persistInFlight,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function dragOnCanvas(page, start, end) {
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
if (!box) throw new Error('Canvas has no visible bounds');
|
||||
await page.mouse.move(box.x + box.width * start.x, box.y + box.height * start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * end.x, box.y + box.height * end.y, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function waitForDraft(page) {
|
||||
await expect.poll(async () => {
|
||||
const current = await editorState(page);
|
||||
return !!current.draftId && current.persistIdle;
|
||||
}, { timeout: 15_000 }).toBe(true);
|
||||
return (await editorState(page)).draftId;
|
||||
}
|
||||
|
||||
async function reopenDraft(page, draftId) {
|
||||
await page.evaluate(async id => {
|
||||
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
|
||||
window.__galleryAllowCloseEditor = true;
|
||||
editor.closeEditor();
|
||||
await editor.openEditor(null, null, null, 'Reopened E2E document', id);
|
||||
}, draftId);
|
||||
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
|
||||
await expect.poll(async () => (await editorState(page)).draftId).toBe(draftId);
|
||||
await expect.poll(async () => (await editorState(page)).documentRenderReady, {
|
||||
timeout: 20_000,
|
||||
}).toBe(true);
|
||||
}
|
||||
|
||||
async function openExportDialog(page) {
|
||||
await page.locator('#ge-save-menu-btn').click();
|
||||
await page.locator('#ge-download').click();
|
||||
await expect(page.locator('.ge-export-dialog')).toBeVisible();
|
||||
}
|
||||
|
||||
async function flattenedPixelDigest(page, size = null) {
|
||||
return page.evaluate(async targetSize => {
|
||||
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
|
||||
const image = new Image();
|
||||
image.src = editor.exportPNG();
|
||||
await image.decode();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = targetSize?.width || image.naturalWidth;
|
||||
canvas.height = targetSize?.height || image.naturalHeight;
|
||||
const context = canvas.getContext('2d');
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
const raw = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const pixels = new Uint8ClampedArray(raw.length);
|
||||
for (let index = 0; index < raw.length; index += 4) {
|
||||
const alpha = raw[index + 3] / 255;
|
||||
pixels[index] = Math.round(raw[index] * alpha);
|
||||
pixels[index + 1] = Math.round(raw[index + 1] * alpha);
|
||||
pixels[index + 2] = Math.round(raw[index + 2] * alpha);
|
||||
pixels[index + 3] = raw[index + 3];
|
||||
}
|
||||
const digest = await crypto.subtle.digest('SHA-256', pixels);
|
||||
return {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
sha256: Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''),
|
||||
};
|
||||
}, size);
|
||||
}
|
||||
|
||||
async function encodedImagePixelDigest(page, bytes) {
|
||||
return page.evaluate(async base64 => {
|
||||
const binary = atob(base64);
|
||||
const encoded = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
const bitmap = await createImageBitmap(new Blob([encoded], { type: 'image/png' }));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
const raw = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const pixels = new Uint8ClampedArray(raw.length);
|
||||
for (let index = 0; index < raw.length; index += 4) {
|
||||
const alpha = raw[index + 3] / 255;
|
||||
pixels[index] = Math.round(raw[index] * alpha);
|
||||
pixels[index + 1] = Math.round(raw[index + 1] * alpha);
|
||||
pixels[index + 2] = Math.round(raw[index + 2] * alpha);
|
||||
pixels[index + 3] = raw[index + 3];
|
||||
}
|
||||
const digest = await crypto.subtle.digest('SHA-256', pixels);
|
||||
return {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
sha256: Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''),
|
||||
};
|
||||
}, bytes.toString('base64'));
|
||||
}
|
||||
|
||||
async function compareExportPixels(page, bytes, size) {
|
||||
return page.evaluate(async ({ base64, targetSize }) => {
|
||||
const decode = async source => {
|
||||
const image = new Image();
|
||||
image.src = source;
|
||||
await image.decode();
|
||||
return image;
|
||||
};
|
||||
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
|
||||
const expectedImage = await decode(editor.exportPNG());
|
||||
const binary = atob(base64);
|
||||
const actualImage = await decode(URL.createObjectURL(new Blob([
|
||||
Uint8Array.from(binary, character => character.charCodeAt(0)),
|
||||
], { type: 'image/png' })));
|
||||
const pixels = image => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = targetSize.width;
|
||||
canvas.height = targetSize.height;
|
||||
const context = canvas.getContext('2d');
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
};
|
||||
const expected = pixels(expectedImage);
|
||||
const actual = pixels(actualImage);
|
||||
URL.revokeObjectURL(actualImage.src);
|
||||
let total = 0;
|
||||
let maximum = 0;
|
||||
let changedPixels = 0;
|
||||
for (let index = 0; index < expected.length; index += 4) {
|
||||
const expectedAlpha = expected[index + 3] / 255;
|
||||
const actualAlpha = actual[index + 3] / 255;
|
||||
let pixelMaximum = Math.abs(expected[index + 3] - actual[index + 3]);
|
||||
total += pixelMaximum;
|
||||
for (let channel = 0; channel < 3; channel += 1) {
|
||||
const delta = Math.abs(
|
||||
expected[index + channel] * expectedAlpha - actual[index + channel] * actualAlpha,
|
||||
);
|
||||
total += delta;
|
||||
pixelMaximum = Math.max(pixelMaximum, delta);
|
||||
}
|
||||
maximum = Math.max(maximum, pixelMaximum);
|
||||
if (pixelMaximum > 1) changedPixels += 1;
|
||||
}
|
||||
return {
|
||||
meanAbsoluteError: total / expected.length,
|
||||
maximumError: maximum,
|
||||
changedPixelRatio: changedPixels / (expected.length / 4),
|
||||
};
|
||||
}, { base64: bytes.toString('base64'), targetSize: size });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compareExportPixels,
|
||||
dragOnCanvas,
|
||||
encodedImagePixelDigest,
|
||||
editorState,
|
||||
flattenedPixelDigest,
|
||||
openBlankEditor,
|
||||
openExportDialog,
|
||||
reopenDraft,
|
||||
waitForDraft,
|
||||
};
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||
|
||||
test('text edits directly on canvas and remains retained after reopen', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="text"]').click();
|
||||
await page.locator('#ge-text-frame-width').fill('260');
|
||||
const canvas = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.click(canvas.x + canvas.width * 0.2, canvas.y + canvas.height * 0.2);
|
||||
const editor = page.locator('.ge-direct-text-editor');
|
||||
await expect(editor).toBeVisible();
|
||||
await editor.fill('Editable canvas title');
|
||||
await editor.press('Control+Enter');
|
||||
await expect(editor).toHaveCount(0);
|
||||
|
||||
await page.locator('#ge-text-letter-spacing').fill('3.5');
|
||||
await page.locator('#ge-text-line-height').fill('1.4');
|
||||
await page.locator('#ge-text-font').selectOption('Georgia');
|
||||
await page.locator('.ge-text-auto-width-option').click();
|
||||
await expect(page.locator('#ge-text-auto-width')).toBeChecked();
|
||||
await expect(page.locator('#ge-text-frame-width')).toBeDisabled();
|
||||
await page.locator('#ge-text-frame-height').fill('180');
|
||||
await page.locator('#ge-text-vertical-align').selectOption('bottom');
|
||||
const before = await editorState(page);
|
||||
const textLayer = before.layers.find(layer => layer.kind === 'text');
|
||||
expect(textLayer.text).toMatchObject({
|
||||
content: 'Editable canvas title',
|
||||
fontFamily: 'Georgia',
|
||||
lineHeight: 1.4,
|
||||
letterSpacing: 3.5,
|
||||
autoWidth: true,
|
||||
frameHeight: 180,
|
||||
verticalAlign: 'bottom',
|
||||
});
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.layers.find(layer => layer.kind === 'text').text).toEqual(textLayer.text);
|
||||
});
|
||||
|
||||
test('text paragraph controls remain usable in a narrow phone viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 700 });
|
||||
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile text layout E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="text"]').click();
|
||||
const section = page.locator('#ge-text-section');
|
||||
await expect(section).toBeVisible();
|
||||
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
|
||||
await expect(page.locator('#ge-text-frame-height')).toBeVisible();
|
||||
await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
|
||||
await expect(page.locator('#ge-shape-section')).toBeVisible();
|
||||
|
||||
const types = ['rectangle', 'ellipse', 'line', 'polygon'];
|
||||
for (let index = 0; index < types.length; index += 1) {
|
||||
const type = types[index];
|
||||
await page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first().click();
|
||||
await page.locator(`[data-shape-type="${type}"]`).click();
|
||||
await dragOnCanvas(
|
||||
page,
|
||||
{ x: 0.12 + index * 0.18, y: 0.2 },
|
||||
{ x: 0.25 + index * 0.18, y: 0.42 },
|
||||
);
|
||||
}
|
||||
|
||||
let current = await editorState(page);
|
||||
const shapes = current.layers.filter(layer => layer.kind === 'shape');
|
||||
expect(shapes.map(layer => layer.shape.type)).toEqual(types);
|
||||
await page.locator('#ge-shape-sides').fill('7');
|
||||
await page.locator('#ge-shape-stroke-width').fill('6');
|
||||
await page.locator('#ge-shape-radius').fill('14');
|
||||
current = await editorState(page);
|
||||
const polygon = current.layers.find(layer => layer.kind === 'shape' && layer.shape.type === 'polygon');
|
||||
expect(polygon.shape).toMatchObject({ sides: 7, strokeWidth: 6, cornerRadius: 14 });
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
|
||||
await page.locator('#ge-transform-w').fill(String(originalWidth + 40));
|
||||
await page.locator('#ge-transform-apply').click();
|
||||
current = await editorState(page);
|
||||
const transformed = current.layers.find(layer => layer.id === polygon.id);
|
||||
expect(transformed.kind).toBe('shape');
|
||||
expect(transformed.shape.transform.scaleX).toBeGreaterThan(1);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
current = await editorState(page);
|
||||
expect(current.layers.filter(layer => layer.kind === 'shape').map(layer => layer.shape.type)).toEqual(types);
|
||||
});
|
||||
|
||||
test('gradient shape fill remains editable after reopening the project', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
|
||||
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeHidden();
|
||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.65, y: 0.55 });
|
||||
await page.locator('#ge-shape-gradient-angle').evaluate((angle) => {
|
||||
const section = angle.closest('#ge-shape-section');
|
||||
section.querySelector('#ge-shape-fill-type').value = 'linear-gradient';
|
||||
section.querySelector('#ge-shape-gradient-start').value = '#ff0000';
|
||||
section.querySelector('#ge-shape-gradient-mid').value = '#00ff00';
|
||||
section.querySelector('#ge-shape-gradient-mid-enabled').checked = true;
|
||||
section.querySelector('#ge-shape-gradient-mid-position').value = '42';
|
||||
section.querySelector('#ge-shape-gradient-end').value = '#0000ff';
|
||||
section.querySelector('#ge-shape-fill-type').dispatchEvent(new Event('change', { bubbles: true }));
|
||||
angle.value = '35';
|
||||
angle.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible();
|
||||
|
||||
const before = await editorState(page);
|
||||
const shape = before.layers.find(layer => layer.kind === 'shape');
|
||||
expect(shape.shape).toMatchObject({
|
||||
fillType: 'linear-gradient',
|
||||
gradientStart: '#ff0000',
|
||||
gradientMid: '#00ff00',
|
||||
gradientMidEnabled: true,
|
||||
gradientMidPosition: 42,
|
||||
gradientEnd: '#0000ff',
|
||||
gradientAngle: 35,
|
||||
});
|
||||
expect(page.locator('.ge-layer-inline-thumb')).toHaveCount(before.layers.length);
|
||||
const thumbnailColorRange = await page.locator('.ge-layer-inline-thumb').evaluateAll((canvases) => canvases.map(canvas => {
|
||||
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
return {
|
||||
red: Math.max(...Array.from(pixels).filter((_, index) => index % 4 === 0)),
|
||||
blue: Math.max(...Array.from(pixels).filter((_, index) => index % 4 === 2)),
|
||||
};
|
||||
}));
|
||||
expect(thumbnailColorRange.some(({ red, blue }) => red > 180 && blue > 180)).toBe(true);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.layers.find(layer => layer.id === shape.id).shape).toMatchObject(shape.shape);
|
||||
});
|
||||
|
||||
test('shape gradients retain added stops through editing and reopen', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
|
||||
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
|
||||
await page.locator('#ge-shape-fill-type').selectOption('linear-gradient');
|
||||
await page.evaluate(() => {
|
||||
for (const [id, value] of [['ge-shape-gradient-start', '#ff0000'], ['ge-shape-gradient-end', '#0000ff']]) {
|
||||
const input = document.getElementById(id);
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
await page.locator('#ge-shape-gradient-add-stop').click();
|
||||
await expect(page.locator('[data-gradient-extra-stop]')).toHaveCount(1);
|
||||
await page.evaluate(() => {
|
||||
const color = document.querySelector('[data-gradient-stop-color]');
|
||||
color.value = '#00ff00';
|
||||
color.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const position = document.querySelector('[data-gradient-stop-position]');
|
||||
position.value = '50';
|
||||
position.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await page.locator('#ge-shape-gradient-add-stop').click();
|
||||
await expect(page.locator('[data-gradient-extra-stop]')).toHaveCount(2);
|
||||
await page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll('[data-gradient-extra-stop]')];
|
||||
const setRow = (row, colorValue, positionValue) => {
|
||||
const color = row.querySelector('[data-gradient-stop-color]');
|
||||
color.value = colorValue;
|
||||
color.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const position = row.querySelector('[data-gradient-stop-position]');
|
||||
position.value = positionValue;
|
||||
position.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
};
|
||||
setRow(rows[0], '#00ff00', '50');
|
||||
setRow(rows[1], '#ffff00', '75');
|
||||
});
|
||||
|
||||
let current = await editorState(page);
|
||||
const shape = current.layers.find(layer => layer.kind === 'shape');
|
||||
expect(shape.shape.gradientStops).toEqual([
|
||||
{ position: 0, color: '#ff0000' },
|
||||
{ position: 50, color: '#00ff00' },
|
||||
{ position: 75, color: '#ffff00' },
|
||||
{ position: 100, color: '#0000ff' },
|
||||
]);
|
||||
|
||||
await page.locator('[data-gradient-extra-stop]').first().locator('[data-gradient-stop-remove]').click();
|
||||
current = await editorState(page);
|
||||
expect(current.layers.find(layer => layer.id === shape.id).shape.gradientStops).toEqual([
|
||||
{ position: 0, color: '#ff0000' },
|
||||
{ position: 75, color: '#ffff00' },
|
||||
{ position: 100, color: '#0000ff' },
|
||||
]);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
expect(reopened.layers.find(layer => layer.id === shape.id).shape.gradientStops).toEqual([
|
||||
{ position: 0, color: '#ff0000' },
|
||||
{ position: 75, color: '#ffff00' },
|
||||
{ position: 100, color: '#0000ff' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('shape gradient stop normalization keeps imported endpoints bounded', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 180 }, 'Gradient stop bounds E2E');
|
||||
const result = await page.evaluate(async () => {
|
||||
const { MAX_GRADIENT_STOPS, normalizeGradientStops } = await import('/static/js/editor/gradient-stops.js');
|
||||
const stops = normalizeGradientStops(Array.from({ length: 30 }, (_, index) => ({
|
||||
position: index * 3,
|
||||
color: `#${String(index).padStart(6, '0')}`,
|
||||
})));
|
||||
return { max: MAX_GRADIENT_STOPS, stops };
|
||||
});
|
||||
expect(result.stops).toHaveLength(result.max);
|
||||
expect(result.stops[0].position).toBe(0);
|
||||
expect(result.stops.at(-1).position).toBe(100);
|
||||
});
|
||||
|
||||
test('gradient tool paints a reversible drag on the active layer', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Gradient tool E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
|
||||
await expect(page.locator('#ge-gradient-section')).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (const [id, value] of [['ge-gradient-start', '#ff0000'], ['ge-gradient-end', '#0000ff']]) {
|
||||
const input = document.getElementById(id);
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
const midpoint = document.getElementById('ge-gradient-mid');
|
||||
midpoint.value = '#00ff00';
|
||||
midpoint.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const enabled = document.getElementById('ge-gradient-mid-enabled');
|
||||
enabled.checked = true;
|
||||
enabled.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
await page.locator('#ge-gradient-add-stop').click();
|
||||
await page.evaluate(() => {
|
||||
const row = document.querySelector('#ge-gradient-extra-stops [data-gradient-extra-stop]');
|
||||
const color = row.querySelector('[data-gradient-stop-color]');
|
||||
color.value = '#ffff00';
|
||||
color.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const position = row.querySelector('[data-gradient-stop-position]');
|
||||
position.value = '25';
|
||||
position.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.move(box.x + 8, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width - 8, box.y + box.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(async () => (await editorState(page)).layers.at(-1).effects?.length || 0).toBe(1);
|
||||
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
|
||||
|
||||
const samples = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
const rendered = state.documentCompositeCanvas;
|
||||
const ctx = rendered.getContext('2d');
|
||||
const y = Math.floor(rendered.height / 2);
|
||||
return [
|
||||
ctx.getImageData(4, y, 1, 1).data,
|
||||
ctx.getImageData(Math.floor(rendered.width / 2), y, 1, 1).data,
|
||||
ctx.getImageData(rendered.width - 5, y, 1, 1).data,
|
||||
];
|
||||
});
|
||||
expect(samples[0][0]).toBeGreaterThan(220);
|
||||
expect(samples[0][2]).toBeLessThan(40);
|
||||
expect(samples[1][1]).toBeGreaterThan(180);
|
||||
expect(samples[1][0]).toBeLessThan(80);
|
||||
expect(samples[1][2]).toBeLessThan(80);
|
||||
expect(samples[2][2]).toBeGreaterThan(220);
|
||||
expect(samples[2][0]).toBeLessThan(40);
|
||||
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
const reopened = await editorState(page);
|
||||
const reopenedGradient = reopened.layers.find(layer => layer.effects?.some(effect => effect.type === 'linear-gradient'));
|
||||
expect(reopenedGradient).toBeTruthy();
|
||||
expect(reopenedGradient.effects.find(effect => effect.type === 'linear-gradient').params.stops).toEqual([
|
||||
{ position: 0, color: '#ff0000', alpha: 1 },
|
||||
{ position: 25, color: '#ffff00', alpha: 1 },
|
||||
{ position: 50, color: '#00ff00', alpha: 1 },
|
||||
{ position: 100, color: '#0000ff', alpha: 1 },
|
||||
]);
|
||||
|
||||
await page.locator('.ge-effect-sub-item .ge-adj-sub-name').click();
|
||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||
await expect(page.locator('.ge-filter-row input[data-key="stopColor0"]')).toHaveValue('#ffff00');
|
||||
await expect(page.locator('.ge-filter-row input[data-key="stopPosition0"]')).toHaveValue('25');
|
||||
await expect(page.locator('.ge-filter-row input[data-key="stopColor1"]')).toHaveValue('#00ff00');
|
||||
await page.locator('.ge-filter-row input[data-key="stopColor0"]').fill('#ffff00');
|
||||
await page.locator('.ge-filter-row input[data-key="stopPosition0"]').fill('30');
|
||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||
const edited = (await editorState(page)).layers
|
||||
.flatMap(layer => layer.effects || [])
|
||||
.find(effect => effect.type === 'linear-gradient');
|
||||
expect(edited.params.stops).toEqual([
|
||||
{ position: 0, color: '#ff0000', alpha: 1 },
|
||||
{ position: 30, color: '#ffff00', alpha: 1 },
|
||||
{ position: 50, color: '#00ff00', alpha: 1 },
|
||||
{ position: 100, color: '#0000ff', alpha: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('radial gradient remains retained and survives reopen', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Radial gradient E2E');
|
||||
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
|
||||
await page.locator('#ge-gradient-type').selectOption('radial-gradient');
|
||||
await page.evaluate(() => {
|
||||
const start = document.getElementById('ge-gradient-start');
|
||||
start.value = '#ffffff';
|
||||
start.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const end = document.getElementById('ge-gradient-end');
|
||||
end.value = '#000000';
|
||||
end.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await dragOnCanvas(page, { x: 0.5, y: 0.5 }, { x: 0.85, y: 0.5 });
|
||||
let current = await editorState(page);
|
||||
const gradient = current.layers.flatMap(layer => layer.effects || []).find(effect => effect.type === 'radial-gradient');
|
||||
expect(gradient).toBeTruthy();
|
||||
expect(gradient.params.stops).toEqual([
|
||||
{ position: 0, color: '#ffffff', alpha: 1 },
|
||||
{ position: 100, color: '#000000', alpha: 1 },
|
||||
]);
|
||||
const draftId = await waitForDraft(page);
|
||||
await reopenDraft(page, draftId);
|
||||
current = await editorState(page);
|
||||
expect(current.layers.flatMap(layer => layer.effects || []).some(effect => effect.type === 'radial-gradient')).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openBlankEditor } = require('./helpers');
|
||||
|
||||
test('transform frame exposes eight accurate handles and supports edge resize', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform frame');
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await expect(page.locator('.ge-transform-popup')).toBeVisible();
|
||||
|
||||
const frame = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
|
||||
const { getHandleAt } = await import('/static/js/editor/tools/transform-handles.js');
|
||||
const geometry = transformFrameGeometry({
|
||||
centerX: state.transformCenter.x,
|
||||
centerY: state.transformCenter.y,
|
||||
width: state.transformPendingW,
|
||||
height: state.transformPendingH,
|
||||
rotation: state.transformPendingRot,
|
||||
}, { zoom: state.zoom });
|
||||
const canvasRect = state.mainCanvas.getBoundingClientRect();
|
||||
const right = geometry.resizeHandles.find(handle => handle.id === 'r');
|
||||
return {
|
||||
width: state.transformPendingW,
|
||||
ids: geometry.resizeHandles.map(handle => getHandleAt(handle.x, handle.y)),
|
||||
rightClient: {
|
||||
x: canvasRect.left + right.x * canvasRect.width / state.mainCanvas.width,
|
||||
y: canvasRect.top + right.y * canvasRect.height / state.mainCanvas.height,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
expect(frame.ids).toEqual(['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l']);
|
||||
await page.mouse.move(frame.rightClient.x, frame.rightClient.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(frame.rightClient.x + 36, frame.rightClient.y, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformPendingW;
|
||||
})).toBeGreaterThan(frame.width);
|
||||
});
|
||||
|
||||
test('rotated edge resize follows the frame axis and keeps its opposite edge anchored', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Rotated transform frame');
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await page.locator('#ge-transform-rot').fill('90');
|
||||
|
||||
const frame = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
|
||||
const geometry = transformFrameGeometry({
|
||||
centerX: state.transformCenter.x,
|
||||
centerY: state.transformCenter.y,
|
||||
width: state.transformPendingW,
|
||||
height: state.transformPendingH,
|
||||
rotation: state.transformPendingRot,
|
||||
}, { zoom: state.zoom });
|
||||
const right = geometry.resizeHandles.find(handle => handle.id === 'r');
|
||||
const canvasRect = state.mainCanvas.getBoundingClientRect();
|
||||
return {
|
||||
width: state.transformPendingW,
|
||||
center: { ...state.transformCenter },
|
||||
rightClient: {
|
||||
x: canvasRect.left + right.x * canvasRect.width / state.mainCanvas.width,
|
||||
y: canvasRect.top + right.y * canvasRect.height / state.mainCanvas.height,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await page.mouse.move(frame.rightClient.x, frame.rightClient.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(frame.rightClient.x, frame.rightClient.y + 36, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
|
||||
const after = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { width: state.transformPendingW, center: { ...state.transformCenter } };
|
||||
});
|
||||
expect(after.width).toBeGreaterThan(frame.width);
|
||||
expect(after.center.x).toBeCloseTo(frame.center.x, 4);
|
||||
expect(after.center.y).toBeGreaterThan(frame.center.y);
|
||||
});
|
||||
|
||||
test('rotated frame moves only from its visible interior and supports keyboard nudging', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform interaction');
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await page.locator('#ge-transform-rot').fill('45');
|
||||
|
||||
const frame = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
state.snapEnabled = false;
|
||||
const rect = state.mainCanvas.getBoundingClientRect();
|
||||
const toClient = point => ({
|
||||
x: rect.left + point.x * rect.width / state.mainCanvas.width,
|
||||
y: rect.top + point.y * rect.height / state.mainCanvas.height,
|
||||
});
|
||||
return {
|
||||
center: { ...state.transformCenter },
|
||||
emptyCorner: toClient({ x: 5, y: 5 }),
|
||||
centerClient: toClient(state.transformCenter),
|
||||
};
|
||||
});
|
||||
|
||||
await page.mouse.move(frame.emptyCorner.x, frame.emptyCorner.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(frame.emptyCorner.x + 24, frame.emptyCorner.y + 18);
|
||||
await page.mouse.up();
|
||||
const afterEmptyCorner = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { ...state.transformCenter };
|
||||
});
|
||||
expect(afterEmptyCorner.x).toBeCloseTo(frame.center.x, 5);
|
||||
expect(afterEmptyCorner.y).toBeCloseTo(frame.center.y, 5);
|
||||
|
||||
await page.mouse.move(frame.centerClient.x, frame.centerClient.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(frame.centerClient.x + 24, frame.centerClient.y + 18, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
const afterDrag = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { ...state.transformCenter };
|
||||
});
|
||||
expect(afterDrag.x).toBeGreaterThan(frame.center.x);
|
||||
expect(afterDrag.y).toBeGreaterThan(frame.center.y);
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
const afterKeys = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { ...state.transformCenter };
|
||||
});
|
||||
expect(afterKeys.x).toBeCloseTo(afterDrag.x + 1, 5);
|
||||
expect(afterKeys.y).toBeCloseTo(afterDrag.y + 10, 5);
|
||||
const readout = {
|
||||
x: Number(await page.locator('#ge-transform-x').inputValue()),
|
||||
y: Number(await page.locator('#ge-transform-y').inputValue()),
|
||||
w: Number(await page.locator('#ge-transform-w').inputValue()),
|
||||
h: Number(await page.locator('#ge-transform-h').inputValue()),
|
||||
angle: Number(await page.locator('#ge-transform-rot').inputValue()),
|
||||
};
|
||||
expect(readout.x).toBeCloseTo(afterKeys.x, 2);
|
||||
expect(readout.y).toBeCloseTo(afterKeys.y, 2);
|
||||
expect(readout.w).toBeGreaterThan(0);
|
||||
expect(readout.h).toBeGreaterThan(0);
|
||||
expect(readout.angle).toBe(45);
|
||||
|
||||
await page.locator('#ge-transform-x').fill('250');
|
||||
await page.locator('#ge-transform-y').fill('180');
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return { ...state.transformCenter };
|
||||
})).toEqual({ x: 250, y: 180 });
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.ge-transform-popup')).toBeHidden();
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformActive;
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
test('transform previews remain source-derived and reject unsafe allocations', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 240, height: 160 }, 'Transform source integrity');
|
||||
await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
for (let y = 0; y < layer.canvas.height; y += 8) {
|
||||
for (let x = 0; x < layer.canvas.width; x += 8) {
|
||||
layer.ctx.fillStyle = ((x / 8 + y / 8) % 2) ? '#f24f5f' : '#27c2a3';
|
||||
layer.ctx.fillRect(x, y, 8, 8);
|
||||
}
|
||||
}
|
||||
});
|
||||
const layerSnapshot = () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
const pixels = layer.ctx.getImageData(0, 0, layer.canvas.width, layer.canvas.height).data;
|
||||
let hash = 2166136261;
|
||||
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
|
||||
return { width: layer.canvas.width, height: layer.canvas.height, hash: hash >>> 0 };
|
||||
});
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
|
||||
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.8)));
|
||||
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
|
||||
const afterSequentialPreviews = await layerSnapshot();
|
||||
await page.locator('#ge-transform-cancel-btn').click();
|
||||
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
|
||||
const afterDirectPreview = await layerSnapshot();
|
||||
expect(afterSequentialPreviews).toEqual(afterDirectPreview);
|
||||
|
||||
const safeWidth = await page.locator('#ge-transform-w').inputValue();
|
||||
const beforeRejected = await layerSnapshot();
|
||||
await page.locator('#ge-transform-w').fill('40000');
|
||||
await expect(page.locator('#toast')).toContainText('dimension limit');
|
||||
await expect(page.locator('#ge-transform-w')).toHaveValue(safeWidth);
|
||||
expect(await layerSnapshot()).toEqual(beforeRejected);
|
||||
await page.locator('#ge-transform-cancel-btn').click();
|
||||
});
|
||||
|
||||
test('mobile touch input can grab every transform handle', async ({ browser, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile transform handles');
|
||||
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
|
||||
await expect(page.locator('.ge-transform-popup')).toBeVisible();
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const ids = ['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l', 'rot'];
|
||||
|
||||
const handlePoint = id => page.evaluate(async handleId => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
|
||||
const geometry = transformFrameGeometry({
|
||||
centerX: state.transformCenter.x,
|
||||
centerY: state.transformCenter.y,
|
||||
width: state.transformPendingW,
|
||||
height: state.transformPendingH,
|
||||
rotation: state.transformPendingRot,
|
||||
}, { zoom: state.zoom, rotationInside: handleId === 'rot' });
|
||||
const handle = geometry.handles.find(item => item.id === handleId);
|
||||
const rect = state.mainCanvas.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + handle.x * rect.width / state.mainCanvas.width,
|
||||
y: rect.top + handle.y * rect.height / state.mainCanvas.height,
|
||||
};
|
||||
}, id);
|
||||
|
||||
for (let index = 0; index < ids.length; index += 1) {
|
||||
const expectedId = ids[index];
|
||||
const point = await handlePoint(expectedId);
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: point.x, y: point.y, id: index + 1, radiusX: 4, radiusY: 4 }],
|
||||
});
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformHandle;
|
||||
})).toBe(expectedId);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformHandle;
|
||||
})).toBe(null);
|
||||
}
|
||||
|
||||
const right = await handlePoint('r');
|
||||
const widthBefore = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformPendingW;
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: right.x, y: right.y, id: 20, radiusX: 4, radiusY: 4 }],
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ x: right.x + 80, y: right.y, id: 20, radiusX: 4, radiusY: 4 }],
|
||||
});
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformPendingW;
|
||||
})).toBeGreaterThan(widthBefore);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
|
||||
const center = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
const rect = state.mainCanvas.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + state.transformCenter.x * rect.width / state.mainCanvas.width,
|
||||
y: rect.top + state.transformCenter.y * rect.height / state.mainCanvas.height,
|
||||
zoom: state.zoom,
|
||||
};
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: center.x, y: center.y, id: 30, radiusX: 4, radiusY: 4 }],
|
||||
});
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformHandle;
|
||||
})).toBe('move');
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [
|
||||
{ x: center.x - 20, y: center.y, id: 30, radiusX: 4, radiusY: 4 },
|
||||
{ x: center.x + 20, y: center.y, id: 31, radiusX: 4, radiusY: 4 },
|
||||
],
|
||||
});
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformHandle;
|
||||
})).toBe(null);
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [
|
||||
{ x: center.x - 45, y: center.y, id: 30, radiusX: 4, radiusY: 4 },
|
||||
{ x: center.x + 45, y: center.y, id: 31, radiusX: 4, radiusY: 4 },
|
||||
],
|
||||
});
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.zoom;
|
||||
})).toBeGreaterThan(center.zoom);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
const repositoryRoot = path.join(__dirname, '..', '..');
|
||||
const defaultPython = process.platform === 'win32'
|
||||
? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe')
|
||||
: path.join(repositoryRoot, '.venv', 'bin', 'python');
|
||||
const python = process.env.ODYSSEUS_TEST_PYTHON || (fs.existsSync(defaultPython) ? defaultPython : 'python');
|
||||
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR
|
||||
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-photo-editor-e2e-'));
|
||||
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH
|
||||
|| path.join(dataDirectory, 'app.db');
|
||||
process.env.PHOTO_EDITOR_E2E_DB_PATH = databasePath;
|
||||
process.env.PHOTO_EDITOR_E2E_DATA_DIR = dataDirectory;
|
||||
|
||||
const browserName = process.env.PHOTO_EDITOR_E2E_BROWSER || 'chromium';
|
||||
if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
|
||||
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
|
||||
}
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: path.join(__dirname, 'photo-editor'),
|
||||
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
|
||||
globalSetup: require.resolve('./setup.js'),
|
||||
globalTeardown: require.resolve('./teardown.js'),
|
||||
use: {
|
||||
baseURL,
|
||||
browserName,
|
||||
headless: true,
|
||||
viewport: { width: 1440, height: 960 },
|
||||
extraHTTPHeaders: { 'Accept-Encoding': 'identity' },
|
||||
serviceWorkers: 'block',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
|
||||
cwd: repositoryRoot,
|
||||
url: baseURL,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
env: {
|
||||
...process.env,
|
||||
AUTH_ENABLED: 'false',
|
||||
DATABASE_URL: `sqlite:///${databasePath}`,
|
||||
ODYSSEUS_DATA_DIR: dataDirectory,
|
||||
ODYSSEUS_STARTUP_WARMUPS: '0',
|
||||
RESPONSE_COMPRESSION_ENABLED: 'false',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
const fs = require('node:fs');
|
||||
|
||||
module.exports = async () => {
|
||||
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
|
||||
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH;
|
||||
if (!dataDirectory || !databasePath) {
|
||||
throw new Error('Photo editor E2E data paths were not configured');
|
||||
}
|
||||
fs.mkdirSync(dataDirectory, { recursive: true });
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const fs = require('node:fs');
|
||||
|
||||
module.exports = async () => {
|
||||
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
|
||||
if (!dataDirectory) return;
|
||||
fs.rmSync(dataDirectory, { recursive: true, force: true });
|
||||
};
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
[
|
||||
{
|
||||
"id": "clean_v3_open_email_reply",
|
||||
"kind": "draft",
|
||||
"user": "Write reply this email saying 8am works for me",
|
||||
"active_document": {
|
||||
"title": "New Email",
|
||||
"language": "email",
|
||||
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture@example.com>\nReferences: <fixture@example.com>\nX-Source-UID: 999999\n---\n\n---------- Previous message ----------\nCan you confirm the meeting time?\n"
|
||||
},
|
||||
"expect_first_tool": "update_document",
|
||||
"forbidden_tools": [
|
||||
"create_document",
|
||||
"edit_document",
|
||||
"suggest_document",
|
||||
"manage_documents",
|
||||
"web_search"
|
||||
],
|
||||
"must_mutate": "document_contains_8am"
|
||||
},
|
||||
{
|
||||
"id": "clean_v3_open_email_short_reply",
|
||||
"kind": "draft",
|
||||
"user": "Write reply saying 8am works for me",
|
||||
"active_document": {
|
||||
"title": "Meeting",
|
||||
"language": "email",
|
||||
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture-short@example.com>\nReferences: <fixture-short@example.com>\nX-Source-UID: 999998\n---\n\n---------- Previous message ----------\nCan you confirm the meeting time?\n"
|
||||
},
|
||||
"expect_first_tool": "update_document",
|
||||
"forbidden_tools": [
|
||||
"create_document",
|
||||
"edit_document",
|
||||
"suggest_document",
|
||||
"manage_documents",
|
||||
"web_search"
|
||||
],
|
||||
"must_mutate": "document_contains_8am"
|
||||
},
|
||||
{
|
||||
"id": "clean_v3_open_email_unspecified_reply",
|
||||
"kind": "draft",
|
||||
"user": "Write reply to this email",
|
||||
"active_document": {
|
||||
"title": "Meeting",
|
||||
"language": "email",
|
||||
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture-unspecified@example.com>\nReferences: <fixture-unspecified@example.com>\nX-Source-UID: 999997\n---\n\n---------- Previous message ----------\nCan you confirm whether tomorrow morning works?\n"
|
||||
},
|
||||
"expect_first_tool": "update_document",
|
||||
"forbidden_tools": [
|
||||
"create_document",
|
||||
"edit_document",
|
||||
"suggest_document",
|
||||
"manage_documents",
|
||||
"ui_control",
|
||||
"web_search"
|
||||
],
|
||||
"forbidden_repeat_tools": ["update_document"],
|
||||
"expect_document_changed": true,
|
||||
"must_preserve_active_document_all": [
|
||||
"To:",
|
||||
"Subject:",
|
||||
"In-Reply-To:",
|
||||
"References:",
|
||||
"X-Source-UID:",
|
||||
"---"
|
||||
]
|
||||
}
|
||||
]
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"fixture": "basic-shapes.png",
|
||||
"required_facts": {
|
||||
"text": "ODYSSEUS 42",
|
||||
"left_object": "red circle",
|
||||
"right_object": "blue square"
|
||||
},
|
||||
"followup": {
|
||||
"prompt": "What color was the shape on the right?",
|
||||
"required_answer": "blue"
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480" viewBox="0 0 640 480">
|
||||
<rect width="640" height="480" fill="#ffffff"/>
|
||||
<text x="320" y="90" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="52" font-weight="bold" fill="#111111">ODYSSEUS 42</text>
|
||||
<circle cx="190" cy="285" r="90" fill="#e53935"/>
|
||||
<rect x="370" y="195" width="180" height="180" fill="#1e5bd7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 413 B |
+38
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Quarterly Sales Dashboard</title>
|
||||
<style>
|
||||
body { margin: 0; background: #eef2f7; color: #172033; font: 24px Arial, sans-serif; }
|
||||
main { width: 960px; height: 640px; box-sizing: border-box; padding: 42px 56px; }
|
||||
h1 { margin: 0 0 28px; font-size: 38px; }
|
||||
.cards { display: flex; gap: 20px; margin-bottom: 34px; }
|
||||
.card { background: white; border-radius: 14px; padding: 18px 24px; box-shadow: 0 3px 12px #17203320; }
|
||||
.label { color: #64748b; font-size: 18px; }
|
||||
.value { font-size: 30px; font-weight: 700; margin-top: 5px; }
|
||||
.healthy { color: #14804a; }
|
||||
.chart { height: 350px; display: flex; align-items: end; gap: 42px; padding: 0 45px; background: white; border-radius: 14px; box-shadow: 0 3px 12px #17203320; }
|
||||
.column { width: 135px; text-align: center; font-weight: 700; }
|
||||
.bar { background: #3976e8; border-radius: 10px 10px 0 0; color: white; padding-top: 10px; box-sizing: border-box; }
|
||||
.q1 { height: 100px; } .q2 { height: 175px; } .q3 { height: 275px; background: #7c3aed; } .q4 { height: 200px; }
|
||||
.quarter { padding: 12px 0 18px; color: #334155; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Quarterly Sales Dashboard</h1>
|
||||
<div class="cards">
|
||||
<div class="card"><div class="label">Build status</div><div class="value healthy">Healthy</div></div>
|
||||
<div class="card"><div class="label">API latency</div><div class="value">142 ms</div></div>
|
||||
<div class="card"><div class="label">Active users</div><div class="value">1,284</div></div>
|
||||
</div>
|
||||
<div class="chart" aria-label="Quarterly sales: Q1 20, Q2 35, Q3 55, Q4 40">
|
||||
<div class="column"><div class="bar q1">20</div><div class="quarter">Q1</div></div>
|
||||
<div class="column"><div class="bar q2">35</div><div class="quarter">Q2</div></div>
|
||||
<div class="column"><div class="bar q3">55</div><div class="quarter">Q3</div></div>
|
||||
<div class="column"><div class="bar q4">40</div><div class="quarter">Q4</div></div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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(); }
|
||||
});
|
||||
@@ -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('<think>literal</think>'),
|
||||
'<think>literal</think>',
|
||||
);
|
||||
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\\"/g, """) };'
|
||||
);
|
||||
src = src.replace(
|
||||
/import \{ splitTableRow \} from '\.\/markdown\/tableRow\.js';/,
|
||||
'const splitTableRow = (row) => row.split("|").filter((cell) => cell.trim() !== "");'
|
||||
);
|
||||
src = src.replace(
|
||||
/import \{ replaceEmojiShortcodes, hasEmojiShortcode \} from '\.\/emojiShortcodes\.js';/,
|
||||
'const hasEmojiShortcode = (t) => !!t && t.indexOf(":") !== -1 && /:[a-z0-9_+-]{1,40}:/i.test(t); const replaceEmojiShortcodes = (t) => t;'
|
||||
);
|
||||
src = src.replace(/export function /g, 'function ');
|
||||
src = src.replace(/export const /g, 'const ');
|
||||
src = src.replace(/export default markdownModule;?/g, '');
|
||||
src += '\nthis.__mdToHtml = mdToHtml;';
|
||||
|
||||
class MutationObserver {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
URL,
|
||||
MutationObserver,
|
||||
localStorage: { getItem() { return '[]'; }, setItem() {} },
|
||||
document: {
|
||||
body: { classList: { contains() { return true; } } },
|
||||
addEventListener() {},
|
||||
querySelectorAll() { return []; },
|
||||
getElementById() { return null; },
|
||||
contains() { return true; },
|
||||
},
|
||||
window: {
|
||||
location: { origin: 'http://localhost' },
|
||||
katex: null,
|
||||
mermaid: null,
|
||||
},
|
||||
};
|
||||
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox, { filename: markdownPath });
|
||||
|
||||
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');
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {expectedNoteTitles,compareNoteState} from '../scripts/note_test_oracle.mjs';
|
||||
import {scoreCalls} from '../scripts/compare_schema_thinking.mjs';
|
||||
const rows=[{id:'a123',title:'Japan',content:'body',pinned:false},{id:'b123',title:'Today',content:'keep'}];
|
||||
const call=args=>({function:{name:'manage_notes',arguments:JSON.stringify(args)}});
|
||||
test('shared expectations cover negation, exceptions and invalid cases',()=>{
|
||||
assert.deepEqual(expectedNoteTitles('keep_all',['Japan','Today']),[]);
|
||||
assert.deepEqual(expectedNoteTitles('contrast',['Groceries','Japan','Today']),['Groceries']);
|
||||
assert.deepEqual(expectedNoteTitles('except_one',['Groceries','Japan','Today']),['Groceries','Today']);
|
||||
assert.throws(()=>expectedNoteTitles('unregistered',[]));
|
||||
});
|
||||
test('state oracle detects edits and additions, not just disappearing IDs',()=>{
|
||||
assert.equal(compareNoteState(rows,rows).unchanged,true);
|
||||
assert.equal(compareNoteState(rows,[rows[1]],['a123']).exact,true);
|
||||
assert.equal(compareNoteState(rows,[{...rows[1],content:'changed'}],['a123']).exact,false);
|
||||
assert.deepEqual(compareNoteState(rows,[{...rows[0],pinned:true},rows[1]]).changed_fields,['pinned']);
|
||||
assert.equal(compareNoteState(rows,[...rows,{id:'new',title:'extra'}]).added_count,1);
|
||||
assert.equal(compareNoteState(rows,[{...rows[0],archived:true},rows[1]],['a123']).exact,false);
|
||||
});
|
||||
test('duplicate deletion cannot pass exact proposal check',()=>{
|
||||
const result=scoreCalls([call({action:'delete',id:'a123'}),call({action:'delete',title:'Japan'})],rows,['Japan']);
|
||||
assert.equal(result.exact_target_proposal,false);
|
||||
assert.equal(result.duplicate_targets,1);
|
||||
});
|
||||
test('stale ID with valid unique title follows actual backend fallback',()=>{
|
||||
assert.equal(scoreCalls([call({action:'delete',id:'stale',title:'Japan'})],rows,['Japan']).exact_target_proposal,true);
|
||||
assert.equal(scoreCalls([{function:{name:'manage_notes',arguments:'null'}}],rows,[]).exact_target_proposal,false);
|
||||
});
|
||||
@@ -0,0 +1,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(); }
|
||||
});
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {recordsIn,scoreCalls,auditHistory,reformatNoteResult} from '../scripts/compare_schema_thinking.mjs';
|
||||
const records=[{id:'aaaaaaaa-0000-0000-0000-000000000000',title:'Japan'},
|
||||
{id:'bbbbbbbb-0000-0000-0000-000000000000',title:'Today'}];
|
||||
const call=(args)=>({function:{name:'manage_notes',arguments:JSON.stringify(args)}});
|
||||
|
||||
test('proposal grading resolves supported title and ID references, not concatenations',()=>{
|
||||
assert.equal(scoreCalls([call({action:'delete',title:'Japan Today'})],records,['Japan','Today']).exact_target_proposal,false);
|
||||
assert.equal(scoreCalls([call({action:'delete',id:'aaaaaaaa'}),call({action:'delete',title:'Today'})],records,['Japan','Today']).exact_target_proposal,true);
|
||||
});
|
||||
test('negative controls fail if any record is deleted',()=>{
|
||||
assert.equal(scoreCalls([],records,[]).exact_target_proposal,true);
|
||||
assert.equal(scoreCalls([call({action:'delete',title:'Japan'})],records,[]).exact_target_proposal,false);
|
||||
});
|
||||
test('missing targets are not hidden by repeated calls or exploratory reads',()=>{
|
||||
const result=scoreCalls([call({action:'delete',title:'Japan'}),call({action:'delete',title:'Japan'}),call({action:'list'})],records,['Japan','Today']);
|
||||
assert.equal(result.exact_target_proposal,false);
|
||||
assert.equal(result.duplicate_targets,1);
|
||||
assert.deepEqual(result.missing_targets,['Today']);
|
||||
});
|
||||
test('audit checks actual tool result bytes and native call pairing',()=>{
|
||||
const result={role:'tool',tool_call_id:'c1',content:records.map(r=>`- [${r.id}] **${r.title}**`).join('\n')};
|
||||
const messages=[{role:'user',content:'notes'},
|
||||
{role:'assistant',tool_calls:[{id:'c1'}]},result,{role:'user',content:'delete both'}];
|
||||
const request={messages,tools:[],chat_template_kwargs:{enable_thinking:false}};
|
||||
assert.deepEqual(recordsIn(messages),records);
|
||||
assert.equal(auditHistory(request,[{messages}],records.map(r=>r.id)).exact_prior_note_result_preserved,true);
|
||||
const broken={...request,messages:[{...result,content:result.content+' changed'}]};
|
||||
const audit=auditHistory(broken,[{messages}],records.map(r=>r.id));
|
||||
assert.equal(audit.exact_prior_note_result_preserved,false);
|
||||
assert.equal(audit.orphan_tool_results,1);
|
||||
});
|
||||
|
||||
test('format-only variants retain IDs, titles, ordering, suffixes and wrapper fields',()=>{
|
||||
const rows=[{...records[0],suffix:' [PINNED] #travel'},
|
||||
{...records[1],title:'Today "special"',suffix:' [checklist] #todo'}];
|
||||
const text=rows.map(r=>`- [${r.id}] **${r.title}**${r.suffix}`).join('\n');
|
||||
const wrapped=JSON.stringify({results:text,exit_code:0,extra:'unchanged'});
|
||||
const quoted=JSON.parse(reformatNoteResult(wrapped,'quoted'));
|
||||
assert.equal(quoted.extra,'unchanged');
|
||||
assert.equal(quoted.exit_code,0);
|
||||
assert.equal(quoted.results,rows.map(r=>`- [${r.id}] ${JSON.stringify(r.title)}${r.suffix}`).join('\n'));
|
||||
const jsonl=JSON.parse(reformatNoteResult(wrapped,'jsonl'));
|
||||
assert.deepEqual(jsonl.results.split('\n').map(l=>JSON.parse(l)),rows);
|
||||
assert.throws(()=>reformatNoteResult(text+'\nAdditional body text','jsonl'));
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { skillsSummaryMetrics, filterSkillsByQuickFilter, skillNeedsReview } from '../static/js/skillsMetrics.js';
|
||||
|
||||
test('approval uses the configured threshold for every item, not array index', () => {
|
||||
const rows = ['one', 'two', 'three'].map(name => ({ name, status: 'published', audit_verdict: 'pass', confidence: 0.9, baseline_verdict: 'same' }));
|
||||
assert.equal(skillsSummaryMetrics(rows, 0.85).approved, 3);
|
||||
assert.equal(skillsSummaryMetrics(rows, 0.85).review, 0);
|
||||
assert.equal(filterSkillsByQuickFilter(rows, 'approved', 0.95).length, 0);
|
||||
assert.equal(filterSkillsByQuickFilter(rows, 'draft', 0.95).length, 3);
|
||||
});
|
||||
|
||||
test('review badges agree with approval and keep unaudited skills queued', () => {
|
||||
assert.equal(skillNeedsReview({ status: 'draft' }), false);
|
||||
assert.equal(skillNeedsReview({ status: 'draft', audit_verdict: 'pass', confidence: 0.95 }), true);
|
||||
assert.equal(skillNeedsReview({ status: 'published', audit_verdict: 'pass', confidence: 0.95, necessity: { necessary: false } }), true);
|
||||
assert.equal(skillNeedsReview({ status: 'published', source: 'builtin' }), false);
|
||||
});
|
||||
|
||||
test('built-ins and drafts have distinct filters and archived skills remain recoverable', () => {
|
||||
const rows = [{ name: 'shipped', source: 'builtin', status: 'published' }, { name: 'bad', status: 'binned' }];
|
||||
assert.deepEqual(filterSkillsByQuickFilter(rows, 'builtin').map(s => s.name), ['shipped']);
|
||||
assert.deepEqual(filterSkillsByQuickFilter(rows, 'draft').map(s => s.name), ['bad']);
|
||||
});
|
||||
@@ -0,0 +1,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.',
|
||||
],
|
||||
];
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');`,
|
||||
);
|
||||
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');
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user