fix(editor): stabilize image generation, layer editing and controls (#6992)

* fix(editor): stabilize image workflows and editing controls

* docs(editor): add desktop and mobile visual validation evidence

* fix(editor): synchronize quick edit model events and register imagePreferences in test coordinator

* fix(settings): enforce authoritative global image-gen policy and non-admin preference persistence

- Enforce authoritative administrator global image_gen_enabled disable in get_user_setting, do_generate_image, and image_editing_provider_available, preventing non-admin user preference overrides while preserving user-disabled opt-outs and can_generate_images privilege checks across direct, gallery, and editor paths.
- Filter non-admin preference persistence in saveImagePreferences to user preference keys only (image_model, image_edit_model, image_quality), preventing global policy toggles from leaking into user preferences.
- Return accurate 200 OK Response from saveImagePreferences after fallback persistence succeeds so settings UI displays 'Saved' instead of false 403 error, while retaining visibility of real errors.
- Load per-user preferences via readImagePreferences in initImageSettings so non-admin selections persist and display correctly after page reload.
- Clear per-user model preferences on successful admin save (POST /api/auth/settings) via PUT /api/prefs/{key} with value: null to prevent stale per-user preferences from shadowing updated global settings.
- Support clearing keys in PUT /api/prefs/{key} when value is null.
- Add comprehensive Python regression tests for global-disabled/user-enabled, global-enabled/user-disabled, and authorized generation, plus Node and browser regression tests for non-admin persistence, toggle isolation, reload, and admin clearing.

* fix(settings): invalidate shared appConfig cache near auth settings write

* fix(test): isolate test_tool_policy unit suite from external model endpoint state

* fix(test): isolate web_fetch failure regression from contextual follow-up heuristics

* fix(test): isolate test_tool_policy from cross-test approval gate state

Ensure _patch_loop_basics disables TOOL_APPROVAL_GATE_ENABLED so routing tests in test_tool_policy run in the intended opt-out posture even when prior tests populate DATA_DIR with skills or untrusted context. Remove unnecessary forced_tools and contextual web mock workarounds.

* fix(settings): isolate non-admin image toggle and surface preference cleanup errors

- Restrict image generation availability toggle to admin users in imageModels.js, disabling the switch for non-admins and reverting unauthorized changes with an explicit message.
- Separate non-admin image model preference saves from global toggle updates.
- Track preference cleanup results on admin save in imagePreferences.js, propagating cleanup failures instead of ignoring them.
- Require valid preference keys on 403 fallback so toggle-only attempts reject cleanly.
- Add /api/prefs route mock to test_image_model_settings_sync_both_directions_and_survive_reopening.
- Add regression coverage for admin cleanup failures and non-admin toggle rejection.

* fix(settings): reconcile admin toggle on partial cleanup failure and report errors

- In imagePreferences.js, distinguish pre-persistence failures from post-save cleanup failures using err.globalSaved and err.phase.

- In imagePreferences.js, notify listeners of global policy updates on partial success before propagating cleanup failure.

- In imageModels.js, retain the persisted target state on admin toggle when global persistence succeeds but cleanup fails.

- In imageModels.js, reconcile admin toggle with authoritative server state via readImagePreferences() after ambiguous or partial failures.

- In imageModels.js, report partial success accurately without suppressing preference cleanup error messages.

- Add regression coverage in test_settings_save_response_ok_js.py for cleanup HTTP 500, cleanup network rejection, successful cleanup, and failed global POST.

---------

Co-authored-by: pewdiepie-archdaemon <pewdiepie-archdaemon@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
This commit is contained in:
PewDiePie
2026-11-01 02:56:46 +00:00
committed by GitHub
co-authored by pewdiepie-archdaemon Alexandre Teixeira
parent 5353241743
commit 39303f19cd
56 changed files with 3384 additions and 278 deletions
@@ -24,6 +24,7 @@ const REAL_MODULES = new Set([
path.join(JS, 'settings/speech.js'),
path.join(JS, 'settings/writingStyle.js'),
path.join(JS, 'settings/imageModels.js'),
path.join(JS, 'settings/imagePreferences.js'),
path.join(JS, 'settings/agent.js'),
path.join(JS, 'settings/shell.js'),
path.join(JS, 'settings/peek.js'),
+4 -2
View File
@@ -29,8 +29,10 @@ def test_secondary_brush_controls_live_after_the_menu_open():
block = CONTROLS[i:j]
for marker in ('id="ge-brush-spacing"', 'id="ge-brush-smoothing"',
'id="ge-brush-blend"', 'ge-brush-pressure-row',
'id="ge-brush-flow-row"', 'id="ge-brush-softness-row"'):
'id="ge-brush-flow-row"'):
assert marker in block, marker
assert 'id="ge-brush-softness-row"' not in block
assert CONTROLS.index('id="ge-brush-softness-row"') < i
def test_brush_size_label_drops_the_redundant_word():
@@ -98,7 +100,7 @@ def test_brush_strip_fits_and_smudge_row_respects_inline_hide():
# The Opacity/Flow/Softness sliders are wound tighter so the strip fits.
assert ".ge-controls--in-options #ge-brush-section { gap: 6px; }" in CSS
# The brush controls hug their content so the sliders sit next to the caret.
assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }" in CSS
assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; flex-direction: row;" in CSS
# The smudge Strength row is hidden with an inline display:none on other
# tools; the `.ge-eraser-row { display:flex !important }` rule must not
# override that.
@@ -44,9 +44,11 @@ def test_quick_edit_shows_the_generation_message_verbatim():
assert "_isGenOfflineMsg" in EDITOR
assert "_setAiCommandStatus(_isGenOfflineMsg(_genErr) ? _genErr : `Could not generate that edit: ${_genErr}`, 'error');" in EDITOR
assert "_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');" in EDITOR
# the generateImage op maps its own fetch/HTTP failures through the classifier
assert "_generationErrorMessage({ network: true" in EDITOR
assert "_generationErrorMessage({ status: res.status, detail: _gd })" in EDITOR
# The extracted generation action keeps the shared failure classifier.
generation = (ROOT / 'static/js/editor/image-generation.js').read_text()
assert "formatError: _generationErrorMessage" in EDITOR
assert "formatError({ network: true" in generation
assert "formatError({ status: response.status" in generation
def test_sw_cache_is_bumped():
@@ -0,0 +1,500 @@
import json
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes import editor_agent_routes as routes
from src.agent_tools import editor_tools
@pytest.fixture(autouse=True)
def allow_image_generation_privilege(monkeypatch):
monkeypatch.setattr('src.auth_helpers.require_privilege', lambda *args: 'test-user')
@pytest.mark.parametrize("chosen", ["openai/gpt-5-image", ""])
def test_quick_edit_generation_uses_generation_selection_not_planner_model(monkeypatch, chosen):
seen = []
async def execute(self, content, ctx):
seen.append(json.loads(content))
return "provider stub", {"exit_code": 1, "error": "test stops before canvas verification"}
monkeypatch.setattr(editor_tools.GenerateImageLayerTool, "execute", execute)
monkeypatch.setattr(routes, "_user", lambda request: None)
monkeypatch.setattr(routes.editor_agent_model, "startup_probe", lambda: None)
monkeypatch.setattr(routes.style_guide, "strengthen", lambda prompt, steps: steps)
monkeypatch.setattr(routes.style_guide, "enforce_guards", lambda steps: steps)
monkeypatch.setattr(routes, "_image_provider_available", lambda owner: True)
monkeypatch.setattr(routes, "_plan_special", lambda *args: None)
monkeypatch.setattr(routes, "_ensure_generative", lambda prompt, steps: steps)
monkeypatch.setattr(routes, "_unsure_plan", lambda prompt, plan: plan)
routes.set_editor_planner(lambda messages, owner: json.dumps({
"reply": "Generate", "steps": [{"op": "generate_image", "params": {
"prompt": "a cow", "model": "gpt-image-1",
}}],
}))
app = FastAPI()
app.include_router(routes.setup_editor_agent_routes())
try:
with TestClient(app) as client:
response = client.post("/api/editor/agent", json={
"prompt": "create a new image of a cow",
"model": "edit-only-model", "generation_model": chosen,
})
assert response.status_code == 200, response.text
assert len(seen) == 1, response.text
assert seen[0].get("model", "") == chosen
finally:
routes.set_editor_planner(None)
@pytest.mark.parametrize('op,expected', [('generate_image','saved-generation'),('ai_edit_image','saved-editor')])
def test_quick_edit_agent_uses_saved_models_over_browser_and_planner(monkeypatch, op, expected):
seen = []
async def execute(self, content, ctx):
seen.append(json.loads(content))
return 'stub', {'exit_code':1, 'error':'stop before verification'}
monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {
'image_model':'saved-generation', 'image_edit_model':'saved-editor'})
monkeypatch.setattr(editor_tools.GenerateImageLayerTool, 'execute', execute)
monkeypatch.setattr(editor_tools.AiEditImageTool, 'execute', execute)
monkeypatch.setattr(routes, '_user', lambda request: None)
monkeypatch.setattr(routes.editor_agent_model, 'startup_probe', lambda: None)
monkeypatch.setattr(routes.style_guide, 'strengthen', lambda prompt, steps: steps)
monkeypatch.setattr(routes.style_guide, 'enforce_guards', lambda steps: steps)
monkeypatch.setattr(routes, '_image_provider_available', lambda owner: True)
monkeypatch.setattr(routes, '_plan_special', lambda *args: None)
monkeypatch.setattr(routes, '_ensure_generative', lambda prompt, steps: steps)
monkeypatch.setattr(routes, '_unsure_plan', lambda prompt, plan: plan)
routes.set_editor_planner(lambda messages, owner: json.dumps({
'reply':'Edit', 'steps':[{'op':op,'params':{'prompt':'a cow','model':'planner-model','size':'1024x1024'}}]}))
app = FastAPI()
app.include_router(routes.setup_editor_agent_routes())
try:
with TestClient(app) as client:
response = client.post('/api/editor/agent', json={
'prompt':'create an image of a cow','_use_image_settings':True,
'model':'stale-editor','generation_model':'stale-generator','state':{'width':1280,'height':720}})
assert response.status_code == 200, response.text
assert len(seen) == 1, response.text
assert seen[0]['model'] == expected
assert seen[0]['_use_image_settings'] is True
if op == 'generate_image':
assert seen[0]['size'] == '1280x720'
finally:
routes.set_editor_planner(None)
def test_quick_generation_route_uses_saved_setting(monkeypatch):
import asyncio
from routes.gallery import gallery_routes as gallery
from src import ai_interaction
seen = []
async def generate(content, **kwargs):
seen.append(json.loads(content))
return {'image_url':'data:image/png;base64,dGVzdA=='}
monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice')
monkeypatch.setattr(ai_interaction, 'do_generate_image', generate)
monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {'image_model':'saved-generation'})
class Request:
async def json(self):
return {'prompt':'a cow','model':'stale-generator','_use_image_settings':True,'width':1280,'height':720}
endpoint = next(route.endpoint for route in gallery.setup_gallery_routes().routes if route.path == '/api/image/generate')
result = asyncio.run(endpoint(Request()))
assert result['image'] == 'dGVzdA=='
assert seen[0]['model'] == 'saved-generation'
assert seen[0]['size'] == '1280x720'
def test_edit_default_uses_generation_only_when_edit_setting_is_empty(monkeypatch):
from src import editor_image_settings
monkeypatch.setattr(editor_image_settings, 'load_settings', lambda: {'image_model':'generator','image_edit_model':''})
assert editor_image_settings.editor_image_models() == {'generation':'generator','edit':'generator'}
def test_editor_image_models_per_user_isolation_and_fallback(tmp_path, monkeypatch):
from routes import prefs_routes
from src import editor_image_settings, settings
prefs_file = str(tmp_path / "user_prefs.json")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
global_config = {"image_model": "global-gen", "image_edit_model": "global-edit"}
monkeypatch.setattr(settings, "load_settings", lambda: global_config)
monkeypatch.setattr(editor_image_settings, "load_settings", lambda: global_config)
# Save per-user preferences
prefs_routes._save_for_user("alice", {"image_model": "alice-gen", "image_edit_model": "alice-edit"})
prefs_routes._save_for_user("bob", {"image_model": "bob-gen"}) # no edit model
# Alice gets Alice's distinct models
alice_models = editor_image_settings.editor_image_models(owner="alice")
assert alice_models == {"generation": "alice-gen", "edit": "alice-edit"}
# Bob gets Bob's generation model, and edit model falls back to global edit model
bob_models = editor_image_settings.editor_image_models(owner="bob")
assert bob_models == {"generation": "bob-gen", "edit": "global-edit"}
# When global edit model is empty, edit model falls back to Bob's generation model
empty_edit = {"image_model": "global-gen", "image_edit_model": ""}
monkeypatch.setattr(settings, "load_settings", lambda: empty_edit)
monkeypatch.setattr(editor_image_settings, "load_settings", lambda: empty_edit)
bob_fallback_models = editor_image_settings.editor_image_models(owner="bob")
assert bob_fallback_models == {"generation": "bob-gen", "edit": "bob-gen"}
# Restore global settings with global-edit
monkeypatch.setattr(settings, "load_settings", lambda: global_config)
monkeypatch.setattr(editor_image_settings, "load_settings", lambda: global_config)
# Charlie (no preferences) falls back to global admin settings
charlie_models = editor_image_settings.editor_image_models(owner="charlie")
assert charlie_models == {"generation": "global-gen", "edit": "global-edit"}
# Anonymous/unauthenticated owner falls back to global admin settings
anon_models = editor_image_settings.editor_image_models(owner="")
assert anon_models == {"generation": "global-gen", "edit": "global-edit"}
def test_non_admin_user_prefs_api_routing_and_isolation(tmp_path, monkeypatch):
import time
from routes import editor_agent_routes, prefs_routes
from src import editor_image_settings, settings
prefs_file = str(tmp_path / "user_prefs.json")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
global_config = {"image_model": "admin-global-gen", "image_edit_model": "admin-global-edit"}
monkeypatch.setattr(settings, "load_settings", lambda: global_config)
monkeypatch.setattr(editor_image_settings, "load_settings", lambda: global_config)
current_user = "alice"
app = FastAPI()
app.include_router(prefs_routes.setup_prefs_routes())
app.include_router(editor_agent_routes.setup_editor_agent_routes())
monkeypatch.setattr("src.auth_helpers.get_current_user", lambda request: current_user)
monkeypatch.setattr(prefs_routes, "get_current_user", lambda request: current_user)
monkeypatch.setattr(editor_agent_routes, "_user", lambda request: current_user)
monkeypatch.setattr(editor_agent_routes.editor_agent_model, "startup_probe", lambda: None)
monkeypatch.setattr(editor_agent_routes.style_guide, "strengthen", lambda prompt, steps: steps)
monkeypatch.setattr(editor_agent_routes.style_guide, "enforce_guards", lambda steps: steps)
monkeypatch.setattr(editor_agent_routes, "_image_provider_available", lambda owner: True)
monkeypatch.setattr(editor_agent_routes, "_plan_special", lambda *args: None)
monkeypatch.setattr(editor_agent_routes, "_ensure_generative", lambda prompt, steps: steps)
monkeypatch.setattr(editor_agent_routes, "_unsure_plan", lambda prompt, plan: plan)
seen = []
async def execute_gen(self, content, ctx):
seen.append(("gen", json.loads(content)))
return "stub", {"exit_code": 1, "error": "stop"}
async def execute_edit(self, content, ctx):
seen.append(("edit", json.loads(content)))
return "stub", {"exit_code": 1, "error": "stop"}
monkeypatch.setattr(editor_tools.GenerateImageLayerTool, "execute", execute_gen)
monkeypatch.setattr(editor_tools.AiEditImageTool, "execute", execute_edit)
editor_agent_routes.set_editor_planner(lambda messages, owner: json.dumps({
"reply": "Gen", "steps": [
{"op": "generate_image", "params": {"prompt": "cat", "model": "stale-planner", "size": "512x512"}}
]
}))
try:
with TestClient(app) as client:
# Alice configures her preferences via PUT /api/prefs/{key}
put_gen = client.put("/api/prefs/image_model", json={"value": "alice-fav-gen"})
assert put_gen.status_code == 200
put_edit = client.put("/api/prefs/image_edit_model", json={"value": "alice-fav-edit"})
assert put_edit.status_code == 200
# Verify GET /api/prefs reflects Alice's choices
get_prefs = client.get("/api/prefs")
assert get_prefs.status_code == 200
assert get_prefs.json()["image_model"] == "alice-fav-gen"
assert get_prefs.json()["image_edit_model"] == "alice-fav-edit"
# Alice invokes quick edit with _use_image_settings: True
res = client.post("/api/editor/agent", json={
"prompt": "generate a cat",
"_use_image_settings": True,
"model": "stale-browser-edit",
"generation_model": "stale-browser-gen",
})
assert res.status_code == 200
assert len(seen) == 1
# Authoritative Alice preference overrides stale planner and browser models
assert seen[0][1]["model"] == "alice-fav-gen"
# Now test Bob (who has no preferences) - should receive global admin settings
current_user = "bob"
seen.clear()
res_bob = client.post("/api/editor/agent", json={
"prompt": "generate a dog",
"_use_image_settings": True,
"model": "stale-browser-edit",
"generation_model": "stale-browser-gen",
})
assert res_bob.status_code == 200
assert len(seen) == 1
# Bob falls back to global default, not Alice's preference
assert seen[0][1]["model"] == "admin-global-gen"
finally:
editor_agent_routes.set_editor_planner(None)
def test_is_background_removal_intent_grammar_and_dos_safety():
from routes.editor_agent_routes import _is_background_removal_intent
import time
# Valid commands across all verbs and targets
assert _is_background_removal_intent("remove background")
assert _is_background_removal_intent("please remove the background")
assert _is_background_removal_intent("erase bg")
assert _is_background_removal_intent("delete backdrop")
assert _is_background_removal_intent("clear the bg from the active layer")
assert _is_background_removal_intent("strip background of current layer!")
assert _is_background_removal_intent("erase backdrop from selected layer...")
assert _is_background_removal_intent(" PLEASE REMOVE THE BG FROM THE SELECTED LAYER . ! ")
# Invalid commands rejected
assert not _is_background_removal_intent("")
assert not _is_background_removal_intent("remove foreground")
assert not _is_background_removal_intent("please blur the background")
assert not _is_background_removal_intent("remove background and make it dark")
assert not _is_background_removal_intent("remove")
assert not _is_background_removal_intent("background")
assert not _is_background_removal_intent("please remove")
assert not _is_background_removal_intent("remove the")
assert not _is_background_removal_intent("remove background from layer") # missing active/current/selected
assert not _is_background_removal_intent("remove background?") # unallowed punctuation
# ReDoS safety test: large repeated whitespace must run linearly in < 50ms
evil_padding = " \t " * 10000
evil_prompt = evil_padding + "please" + evil_padding + "remove" + evil_padding + "the" + evil_padding + "bg" + evil_padding + "!"
start = time.perf_counter()
assert _is_background_removal_intent(evil_prompt)
elapsed = time.perf_counter() - start
assert elapsed < 0.1, f"Intent parsing took too long: {elapsed:.4f}s (polynomial backtracking risk)"
evil_invalid = evil_padding + "remove" + evil_padding + "everything"
start = time.perf_counter()
assert not _is_background_removal_intent(evil_invalid)
elapsed_invalid = time.perf_counter() - start
assert elapsed_invalid < 0.1, f"Invalid intent parsing took too long: {elapsed_invalid:.4f}s"
def test_image_generation_global_disabled_user_enabled_rejected(tmp_path, monkeypatch):
import asyncio
from fastapi import HTTPException
from routes.gallery import gallery_routes as gallery
from routes import editor_agent_routes, prefs_routes
from src import ai_interaction, settings
prefs_file = str(tmp_path / "user_prefs.json")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
# Admin globally disables image generation
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": False, "image_model": "gpt-image-1"})
# User 'alice' has image_gen_enabled = True in her preferences
prefs_routes._save_for_user("alice", {"image_gen_enabled": True, "image_model": "alice-model"})
# 1. get_user_setting must be False despite user preference
assert settings.get_user_setting("image_gen_enabled", owner="alice", default=True) is False
# 2. image_editing_provider_available must be False
assert ai_interaction.image_editing_provider_available(owner="alice") is False
# 3. Direct generation must be rejected with admin disabled message
direct_res = asyncio.run(ai_interaction.do_generate_image(
json.dumps({"prompt": "a dog"}), owner="alice"
))
assert "error" in direct_res
assert "disabled by the administrator" in direct_res["error"]
# 4. Gallery route must reject with 503
app = FastAPI()
app.include_router(gallery.setup_gallery_routes())
monkeypatch.setattr(gallery, "require_privilege", lambda request, priv: "alice")
with TestClient(app) as client:
gal_res = client.post("/api/image/generate", json={"prompt": "a dog"})
assert gal_res.status_code == 503
assert "switched off" in gal_res.json()["detail"].lower()
# 5. Editor agent route must be blocked
app_editor = FastAPI()
app_editor.include_router(editor_agent_routes.setup_editor_agent_routes())
monkeypatch.setattr(editor_agent_routes, "_user", lambda request: "alice")
routes.set_editor_planner(lambda messages, owner: json.dumps({
"reply": "Gen", "steps": [{"op": "generate_image", "params": {"prompt": "a dog"}}]
}))
try:
with TestClient(app_editor) as client:
ed_res = client.post("/api/editor/agent", json={"prompt": "create a dog"})
assert ed_res.status_code == 200
data = ed_res.json()
assert data.get("blocked") is True
assert data.get("needs_provider") is True
finally:
routes.set_editor_planner(None)
def test_image_generation_global_enabled_user_disabled_rejected(tmp_path, monkeypatch):
import asyncio
from routes.gallery import gallery_routes as gallery
from routes import editor_agent_routes, prefs_routes
from src import ai_interaction, settings
prefs_file = str(tmp_path / "user_prefs.json")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
# Admin globally enables image generation
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": True, "image_model": "gpt-image-1"})
# User 'bob' has image_gen_enabled = False in his preferences
prefs_routes._save_for_user("bob", {"image_gen_enabled": False, "image_model": "bob-model"})
# 1. get_user_setting must be False per user preference
assert settings.get_user_setting("image_gen_enabled", owner="bob", default=True) is False
# 2. image_editing_provider_available must be False for bob
assert ai_interaction.image_editing_provider_available(owner="bob") is False
# 3. Direct generation must be rejected with user preference message
direct_res = asyncio.run(ai_interaction.do_generate_image(
json.dumps({"prompt": "a cat"}), owner="bob"
))
assert "error" in direct_res
assert "disabled by user preference" in direct_res["error"]
# 4. Gallery route must reject with 503
app = FastAPI()
app.include_router(gallery.setup_gallery_routes())
monkeypatch.setattr(gallery, "require_privilege", lambda request, priv: "bob")
with TestClient(app) as client:
gal_res = client.post("/api/image/generate", json={"prompt": "a cat"})
assert gal_res.status_code == 503
# 5. Editor agent route must be blocked for bob
app_editor = FastAPI()
app_editor.include_router(editor_agent_routes.setup_editor_agent_routes())
monkeypatch.setattr(editor_agent_routes, "_user", lambda request: "bob")
routes.set_editor_planner(lambda messages, owner: json.dumps({
"reply": "Gen", "steps": [{"op": "generate_image", "params": {"prompt": "a cat"}}]
}))
try:
with TestClient(app_editor) as client:
ed_res = client.post("/api/editor/agent", json={"prompt": "create a cat"})
assert ed_res.status_code == 200
data = ed_res.json()
assert data.get("blocked") is True
assert data.get("needs_provider") is True
finally:
routes.set_editor_planner(None)
def test_image_generation_authorized_generation_and_privilege_checks(tmp_path, monkeypatch):
import asyncio
from fastapi import HTTPException
from routes.gallery import gallery_routes as gallery
from routes import editor_agent_routes, prefs_routes
from src import ai_interaction, settings
prefs_file = str(tmp_path / "user_prefs.json")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
# Admin globally enables image generation
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": True, "image_model": "gpt-image-1"})
# User 'charlie' has image generation enabled (no disabling pref)
prefs_routes._save_for_user("charlie", {"image_model": "charlie-gen"})
assert settings.get_user_setting("image_gen_enabled", owner="charlie", default=True) is True
# Check privilege enforcement: user lacking can_generate_images is forbidden
def require_privilege_mock(request, privilege):
if privilege == "can_generate_images":
raise HTTPException(403, "Permission denied")
return "charlie"
monkeypatch.setattr("src.auth_helpers.require_privilege", require_privilege_mock)
monkeypatch.setattr(gallery, "require_privilege", require_privilege_mock)
# Gallery checks privilege and returns 403
app = FastAPI()
app.include_router(gallery.setup_gallery_routes())
with TestClient(app) as client:
gal_res = client.post("/api/image/generate", json={"prompt": "a bird"})
assert gal_res.status_code == 403
assert "Permission denied" in gal_res.text
# Editor checks privilege on AI content and raises 403
app_editor = FastAPI()
app_editor.include_router(editor_agent_routes.setup_editor_agent_routes())
monkeypatch.setattr(editor_agent_routes, "_user", lambda request: "charlie")
monkeypatch.setattr(editor_agent_routes, "_image_provider_available", lambda owner: True)
routes.set_editor_planner(lambda messages, owner: json.dumps({
"reply": "Gen", "steps": [{"op": "generate_image", "params": {"prompt": "a bird"}}]
}))
try:
with TestClient(app_editor) as client:
ed_res = client.post("/api/editor/agent", json={"prompt": "create a bird"})
assert ed_res.status_code == 403
finally:
routes.set_editor_planner(None)
# Now grant privilege: generation proceeds
monkeypatch.setattr("src.auth_helpers.require_privilege", lambda request, priv: "charlie")
monkeypatch.setattr(gallery, "require_privilege", lambda request, priv: "charlie")
seen = []
async def fake_do_generate(content, **kwargs):
seen.append(kwargs)
return {"image_url": "data:image/png;base64,dGVzdA=="}
monkeypatch.setattr(ai_interaction, "do_generate_image", fake_do_generate)
with TestClient(app) as client:
gal_ok = client.post("/api/image/generate", json={"prompt": "a bird"})
assert gal_ok.status_code == 200
assert seen[0]["owner"] == "charlie"
def test_put_pref_clears_key_when_value_is_null(tmp_path, monkeypatch):
from routes import prefs_routes
from src import settings
prefs_file = str(tmp_path / "user_prefs.json")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
# Set initial preference
prefs_routes._save_for_user("admin_user", {"image_model": "old-admin-model"})
assert prefs_routes._load_for_user("admin_user").get("image_model") == "old-admin-model"
app = FastAPI()
app.include_router(prefs_routes.setup_prefs_routes())
monkeypatch.setattr("src.auth_helpers.get_current_user", lambda request: "admin_user")
monkeypatch.setattr(prefs_routes, "get_current_user", lambda request: "admin_user")
with TestClient(app) as client:
res = client.put("/api/prefs/image_model", json={"value": None})
assert res.status_code == 200
assert res.json() == {"key": "image_model", "value": None}
# Verify key is deleted from user's record
user_prefs = prefs_routes._load_for_user("admin_user")
assert "image_model" not in user_prefs
# Verify get_user_setting now falls back to global setting
monkeypatch.setattr(settings, "load_settings", lambda: {"image_model": "new-global-model"})
assert settings.get_user_setting("image_model", owner="admin_user") == "new-global-model"
+2 -3
View File
@@ -49,10 +49,9 @@ def test_ctrl_zoom_shortcuts_drive_the_toolbar_and_override_the_browser():
def test_ctrl_alt_l_opens_the_levels_adjustment():
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
# Photoshop-style chord: reuse the Image menu row so the dialog and the
# adjustment-layer plumbing stay in one place.
# Open layer-local controls without creating/committing a new adjustment layer.
assert "e.altKey && e.code === 'KeyL'" in keyboard
assert "document.querySelector('[data-image-action=\"adj-levels\"]')" in keyboard
assert "openLayerAdjustment?.('levels');" in keyboard
def test_ctrl_alt_shift_e_stamps_the_visible_layers():
@@ -0,0 +1,992 @@
import os
from pathlib import Path
import pytest
try:
from playwright.sync_api import sync_playwright
except ImportError:
sync_playwright = None
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def page():
if sync_playwright is None:
pytest.fail("playwright is required for editor browser tests. Install requirements-dev.txt")
with sync_playwright() as playwright:
launch_kwargs = {"headless": True, "args": ["--disable-dev-shm-usage", "--no-sandbox"]}
if os.environ.get("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"):
launch_kwargs["executable_path"] = os.environ["PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"]
browser = playwright.chromium.launch(**launch_kwargs)
page = browser.new_page(viewport={"width": 1000, "height": 700})
def serve(route):
url = route.request.url
if '/api/' in url:
route.fulfill(status=404, body='{}', content_type='application/json')
return
relative = url.split('http://editor.test/', 1)[1].split('?', 1)[0]
if not relative:
route.fulfill(body='<html><body></body></html>', content_type='text/html')
elif (ROOT / relative).is_file():
route.fulfill(path=str(ROOT / relative))
else:
route.fulfill(status=404, body='Not found')
page.route('http://editor.test/**', serve)
page.goto('http://editor.test/')
yield page
browser.close()
def test_remove_background_sends_only_active_layer_and_preserves_others(page):
result = page.evaluate("""async () => {
const {state} = await import('/static/js/editor/state.js');
const {createApplyImageTool} = await import('/static/js/editor/ai-tool-runner.js');
function layer(id, color) {
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 4;
const ctx = canvas.getContext('2d'); ctx.fillStyle = color; ctx.fillRect(0,0,4,4);
return {id, canvas, ctx, visible:true, locks:{}};
}
const red = layer('red','red'), blue = layer('blue','blue');
red.kind = 'placed'; red.placed = {sourceName:'original'};
state.layers = [red, blue]; state.activeLayerId = red.id; state.editorOpen = true;
const beforeBlue = blue.canvas.toDataURL(); const expected = red.canvas.toDataURL().split(',')[1];
let sent, snapshots = 0;
const clear = document.createElement('canvas'); clear.width = clear.height = 4;
window.fetch = async (url, opts) => {
sent = JSON.parse(opts.body);
state.activeLayerId = blue.id; // Changing selection during the request must not change its target.
return {ok:true, json:async () => ({image:clear.toDataURL()})};
};
const run = createApplyImageTool({
flatten: () => {throw Error('Must not flatten');}, saveState: () => snapshots++,
createLayer: () => {throw Error('Must not create a merged layer');},
composite: () => {}, renderLayerPanel: () => {}, deriveBusyLabel: () => 'Removing',
getSelectedAIEndpoint: () => ({}), spinnerModule: {}, uiModule: {showToast(){}},
});
await run('/api/image/remove-bg', {}, 'BG Removed', document.createElement('button'));
return {correctInput:sent.image === expected, blueUnchanged:blue.canvas.toDataURL() === beforeBlue,
count:state.layers.length, visible:state.layers.every(l => l.visible), snapshots,
alpha:red.ctx.getImageData(0,0,1,1).data[3], kind:red.kind, placed:red.placed};
}""")
assert result == dict(correctInput=True, blueUnchanged=True, count=2, visible=True, snapshots=1, alpha=0, kind='raster', placed=None)
def test_levels_shortcut_opens_controls_without_menu_commit(page):
result = page.evaluate("""async () => {
const {state} = await import('/static/js/editor/state.js');
const {wireKeyboardShortcuts} = await import('/static/js/editor/keyboard-shortcuts.js');
state.editorOpen = true;
let selected;
wireKeyboardShortcuts({toolbar:document.createElement('div'), toolKeyMap:{},
openLayerAdjustment:type => selected = type});
const event = new KeyboardEvent('keydown', {key:'l', code:'KeyL', ctrlKey:true, altKey:true,
bubbles:true, cancelable:true});
document.dispatchEvent(event);
return {selected, prevented:event.defaultPrevented};
}""")
assert result == dict(selected='levels', prevented=True)
def test_hue_saturation_shortcut_opens_adjustment(page):
result = page.evaluate("""async () => {
const {state} = await import('/static/js/editor/state.js');
const {wireKeyboardShortcuts} = await import('/static/js/editor/keyboard-shortcuts.js');
state.editorOpen = true;
let selected;
wireKeyboardShortcuts({toolbar:document.createElement('div'), toolKeyMap:{},
openLayerAdjustment:type => selected = type});
const event = new KeyboardEvent('keydown', {key:'U', code:'KeyU', ctrlKey:true, shiftKey:true,
bubbles:true, cancelable:true});
document.dispatchEvent(event);
return {selected, prevented:event.defaultPrevented};
}""")
assert result == dict(selected='hue-saturation', prevented=True)
def test_inner_shadow_clips_inside_and_reverses_direction(page):
result = page.evaluate("""async () => {
const {renderEffects} = await import('/static/js/editor/effects.js');
const source = document.createElement('canvas'); source.width=source.height=60;
const ctx=source.getContext('2d');ctx.fillStyle='white';ctx.fillRect(15,15,30,30);
function sample(inside,x) {
const out=renderEffects(source,[{type:'drop-shadow',params:{inside,x,y:0,blur:0,opacity:1,color:'#000000'}}]);
const c=out.getContext('2d');
return {left:[...c.getImageData(16,30,1,1).data],right:[...c.getImageData(43,30,1,1).data],
outside:[...c.getImageData(47,30,1,1).data]};
}
return {right:sample(true,6),left:sample(true,-6),outer:sample(false,6)};
}""")
assert result['right']['left'] == [0,0,0,255]
assert result['right']['right'] == [255,255,255,255]
assert result['left']['right'] == [0,0,0,255]
assert result['right']['outside'][3] == 0
assert result['outer']['outside'] == [0,0,0,255]
def test_cut_placed_image_rasterizes_once_without_confirmation(page):
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
page.evaluate("""async () => {
document.body.innerHTML='<div id="toast"></div><div id="gallery-editor-container"></div>';
const editor=await import('/static/js/galleryEditor.js');
const {state}=await import('/static/js/editor/state.js');
editor.openEditor(null,null,{w:32,h:32},'Cut test');
const layer=state.layers.at(-1);
layer.ctx.fillStyle='red';layer.ctx.fillRect(0,0,32,32);
layer.kind='placed';layer.placed={sourceName:'Original image'};
document.activeElement?.blur();
document.dispatchEvent(new KeyboardEvent('keydown',{key:'x',code:'KeyX',ctrlKey:true,bubbles:true,cancelable:true}));
}""")
page.wait_for_function("""async () => {
const {state}=await import('/static/js/editor/state.js');
return !!state.internalClipboard && state.layers.at(-1).ctx.getImageData(0,0,1,1).data[3]===0;
}""")
result=page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
return {kind:state.layers.at(-1).kind,placed:state.layers.at(-1).placed,
copied:[...state.internalClipboard.getContext('2d').getImageData(0,0,1,1).data],
prompted:document.querySelector('#styled-confirm-modal')?.textContent?.includes('Rasterize layer')||false};
}""")
assert result==dict(kind='raster',placed=None,copied=[255,0,0,255],prompted=False)
def test_drop_shadow_dialog_inside_and_direction_apply(page):
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
page.add_style_tag(content=(ROOT/'static/css/13-image-editor.css').read_text())
page.add_style_tag(content=':root{--panel:#242426;--bg:#171719;--fg:#eee;--border:#454549;--red:#d47965;}body{background:var(--bg);color:var(--fg);font-family:Arial,sans-serif;}')
page.evaluate("""async () => {
document.body.innerHTML='<div id="toast"></div><div id="gallery-editor-container" style="height:680px"></div>';
const editor=await import('/static/js/galleryEditor.js');
editor.openEditor(null,null,{w:64,h:64},'Shadow test');
document.querySelector('[data-filter-action="effect-drop-shadow"]').click();
}""")
page.locator('.ge-filter-overlay select[data-key="inside"]').select_option('1')
page.locator('.ge-filter-overlay input[data-key="angle"]').evaluate("el=>{el.value='90';el.dispatchEvent(new Event('input',{bubbles:true}));}")
page.screenshot(path='/tmp/editor-shadow-inside-direction.png')
page.locator('.ge-filter-overlay [data-action="apply"]').click()
page.wait_for_function("""async () => {
const {state}=await import('/static/js/editor/state.js');
return state.layers.at(-1).effects.length===1;
}""")
result=page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {inside,x,y}=state.layers.at(-1).effects[0].params;
return {inside,x,y};
}""")
assert result==dict(inside=True,x=0,y=-7)
def test_reorder_rebuilds_effect_rows_under_their_parent(page):
result=page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {createLayerPanelRenderer}=await import('/static/js/editor/layer-panel.js');
document.body.innerHTML='<div id="ge-layers-list"></div>';
state.container=document.body;state.editorOpen=true;
function layer(id,effects=[]) {
const canvas=document.createElement('canvas');canvas.width=canvas.height=16;
return {id,name:id,kind:'raster',canvas,ctx:canvas.getContext('2d'),visible:true,
opacity:1,locks:{},effects,masks:[],adjLayers:[]};
}
const a=layer('a',[{id:'shadow-a',type:'drop-shadow',params:{}}]), b=layer('b');
state.layers=[a,b];state.activeLayerId=a.id;state.layerOffsets=new Map();state.layerGroups=[];
let options;
const renderer=createLayerPanelRenderer({composite(){},saveState(){},
dragSortModule:{enable:(id,selector,config)=>{options=config;}},uiModule:{showToast(){}}});
renderer.render();
const list=document.getElementById('ge-layers-list');
const row=id=>list.querySelector(`.ge-layer-item[data-layer-id="${id}"]`);
list.insertBefore(row('a'),row('b'));
options.onReorder([...list.querySelectorAll('.ge-layer-item[data-layer-id]')]);
return {order:state.layers.map(layer=>layer.id),excluded:options.excludeSelector,
rows:[...list.children].map(el=>el.dataset.layerId||el.dataset.effectId)};
}""")
assert result==dict(order=['b','a'],excluded='.ge-adj-sub-item',rows=['a','shadow-a','b'])
@pytest.mark.parametrize('tool', ['marquee','lasso'])
@pytest.mark.parametrize('pointer_type', ['mouse','pen','touch'])
@pytest.mark.parametrize('pan', [False,True])
def test_selections_start_outside_canvas_unless_explicitly_panning(page,tool,pointer_type,pan):
result=page.evaluate("""async ({tool,pointerType,pan}) => {
const {state}=await import('/static/js/editor/state.js');
const {wireCanvasEvents}=await import('/static/js/editor/canvas-events.js');
document.body.innerHTML='<div id="area"><canvas width="100" height="100"></canvas><button>Chrome</button></div>';
const area=document.getElementById('area');
state.mainCanvas=area.querySelector('canvas');state.tool=tool;
state.editorOpen=true;state.spacePanActive=pan;state.panX=state.panY=0;
let starts=0,moves=0,ends=0;
wireCanvasEvents({canvasArea:area,beginDraw:()=>starts++,continueDraw:()=>moves++,
endDraw:()=>ends++,updateBrushCursor(){},syncZoomControls(){}});
function pointer(type,target=area) {
target.dispatchEvent(new PointerEvent(type,{pointerId:5,pointerType,button:0,
clientX:type==='pointerdown'?10:30,clientY:type==='pointerdown'?10:30,bubbles:true,cancelable:true}));
}
pointer('pointerdown');
if(pointerType==='mouse')area.dispatchEvent(new MouseEvent('mousedown',{button:0,bubbles:true}));
pointer('pointermove');
if(pointerType==='mouse')window.dispatchEvent(new MouseEvent('mousemove'));
pointer('pointerup');
if(pointerType==='mouse')window.dispatchEvent(new MouseEvent('mouseup'));
const after={starts,moves,ends,panX:state.panX};
pointer('pointerdown',area.querySelector('button'));
return {...after,chromeStarted:starts!==after.starts};
}""",dict(tool=tool,pointerType=pointer_type,pan=pan))
assert result['starts']==(0 if pan else 1)
assert result['panX']==(20 if pan else 0)
assert not result['chromeStarted']
if not pan:
assert result['moves']==1
assert result['ends']==1
def test_marquee_drag_from_workspace_creates_clipped_selection(page):
result=page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {wireCanvasEvents}=await import('/static/js/editor/canvas-events.js');
const {createMarqueeTool}=await import('/static/js/editor/tools/marquee.js');
document.body.innerHTML='<div id="area"><canvas width="100" height="100" style="position:absolute;left:100px;top:100px;width:100px;height:100px"></canvas></div>';
const area=document.getElementById('area');state.mainCanvas=area.querySelector('canvas');
state.tool='marquee';state.imgWidth=state.imgHeight=100;state.editorOpen=true;
state.spacePanActive=false;state.panX=state.panY=0;state.wandMask=null;
const layer={id:'a'};state.layers=[layer];state.activeLayerId='a';state.layerOffsets=new Map();
const tool=createMarqueeTool({activeLayer:()=>layer,saveState(){},composite(){},drawOverlay(){},syncSelectionUi(){}});
wireCanvasEvents({canvasArea:area,beginDraw:e=>tool.begin(e),continueDraw:e=>tool.drag(e),
endDraw:e=>tool.end(e),updateBrushCursor(){},syncZoomControls(){}});
area.dispatchEvent(new PointerEvent('pointerdown',{pointerType:'mouse',pointerId:1,button:0,clientX:80,clientY:80,bubbles:true}));
area.dispatchEvent(new MouseEvent('mousedown',{button:0,clientX:80,clientY:80,bubbles:true}));
window.dispatchEvent(new MouseEvent('mousemove',{clientX:150,clientY:150}));
window.dispatchEvent(new MouseEvent('mouseup',{clientX:150,clientY:150}));
const ctx=state.wandMask.getContext('2d');
return {inside:ctx.getImageData(0,0,1,1).data[3],outside:ctx.getImageData(70,70,1,1).data[3],pan:state.panX};
}""")
assert result==dict(inside=255,outside=0,pan=0)
def test_save_menu_remains_clickable_after_png_export_and_window_raise(page):
page.add_style_tag(content='''
.modal {position:fixed;inset:0;z-index:20000;background:#222;}
#ge-save-menu-btn {position:absolute;right:20px;top:20px;}
.ge-save-menu {position:fixed;z-index:10005;width:200px;background:#333;}
.ge-save-menu[hidden] {display:none;}
.ge-save-menu button {display:block;width:100%;height:30px;}
''')
page.evaluate("""async () => {
const {wireTopbar}=await import('/static/js/editor/wire-topbar.js');
document.body.innerHTML='<div id="gallery-modal" class="modal"><div id="gallery-editor-container"><div class="ge-topbar"><div class="ge-save-wrap"><button id="ge-save-menu-btn">Save</button><div id="ge-save-menu" class="ge-save-menu" hidden><button id="ge-save">Save image</button><button id="ge-export-png">Save as PNG</button></div></div></div></div></div>';
window.pngExports=0;window.saves=0;
document.getElementById('ge-save').addEventListener('click',()=>window.saves++);
wireTopbar({registerDocClickAway(){},exportPNGFile:()=>window.pngExports++});
}""")
page.locator('#ge-save-menu-btn').click()
page.locator('#ge-export-png').click(timeout=2000)
page.evaluate("document.getElementById('gallery-modal').style.zIndex='50000'")
page.locator('#ge-save-menu-btn').click()
page.locator('#ge-save').click(timeout=2000)
assert page.evaluate('({png:window.pngExports,saves:window.saves})') == dict(png=1,saves=1)
def test_submenu_hover_aligns_to_row_and_flips_at_right_edge(page):
page.add_style_tag(content='''
#menu {position:absolute; left:780px; top:100px; width:180px;}
button {display:block; height:35px; width:180px;}
.ge-submenu {display:none; position:absolute; width:160px; height:120px; top:0; left:100%;}
.ge-menu-parent:hover + .ge-submenu {display:block;}
''')
page.evaluate("""async () => {
document.body.innerHTML = '<div id="menu"><button>First</button><button>Second</button>'
+ '<button class="ge-menu-parent">Blur &gt;</button><div class="ge-submenu">Gaussian</div></div>';
const {wireSubmenuToggles} = await import('/static/js/editor/submenu-toggles.js');
wireSubmenuToggles(document.getElementById('menu'));
}""")
page.locator('.ge-menu-parent').hover()
row = page.locator('.ge-menu-parent').bounding_box()
panel = page.locator('.ge-submenu').bounding_box()
assert abs(panel['y'] - row['y']) <= 1
assert panel['x'] + panel['width'] <= row['x'] + 1
page.screenshot(path='/tmp/editor-submenu-alignment.png')
@pytest.mark.parametrize('prompt', ['remove bg', 'remove background', 'remove the background from the selected layer'])
def test_quick_edit_planner_uses_background_tool(prompt):
from routes.editor_agent_routes import _plan_special
steps, _, _ = _plan_special(prompt, {}, True)
assert steps == [{'op': 'ai.removeBackground', 'params': {}}]
def test_quick_edit_background_removal_precedes_creative_routing():
source = (ROOT / 'static/js/galleryEditor.js').read_text()
local = source.split('const _executeQuickEditLocal = async (prompt) => {', 1)[1]
assert local.index('if (_looksBackgroundRemoval(p))') < local.index('if (_looksRecolor(p))')
@pytest.mark.parametrize('run', [False, True])
def test_background_tool_suggestion_prepares_command_and_enter_submits(page, run):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
picker = 'const pickSuggestion = ' + source.split('const pickSuggestion = ', 1)[1].split(" wrap.addEventListener('pointerdown'", 1)[0]
assert "runPrompt: id === 'rembg' ? 'remove background' : null" in source
result = page.evaluate("""({picker, run}) => {
const suggestionItems = [{toolId:'rembg', label:'Bg Remove', runPrompt:'remove background'}];
const input = document.createElement('input'); input.value = 'bg';
let submitted = 0, selected = null, clearSynced = false;
const form = {requestSubmit: () => submitted++};
const _clickToolButton = id => selected = id;
const _setAiCommandStatus = () => {};
const hideSuggestions = () => {};
const syncClearButton = () => clearSynced = true;
eval(picker + '; pickSuggestion(0, run);');
return {prompt:input.value, submitted, selected, clearSynced};
}""", {'picker':picker, 'run':run})
assert result == dict(prompt='remove background', submitted=int(run), selected='rembg', clearSynced=True)
@pytest.mark.parametrize('tool', ['dodge', 'burn'])
def test_retouch_invalidates_adjustment_preview_and_stops_before_undo(page, tool):
result = page.evaluate("""async tool => {
const {state} = await import('/static/js/editor/state.js');
const {createStrokePipeline} = await import('/static/js/editor/stroke-pipeline.js');
const {renderLayerWithAdjLayers} = await import('/static/js/editor/fx/pixel-pass.js');
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 30;
const ctx = canvas.getContext('2d'); ctx.fillStyle = 'rgb(100,100,100)'; ctx.fillRect(0,0,30,30);
const layer = {id:'target', canvas, ctx, adjLayers:[{id:'adj', type:'brightness-contrast',
params:{brightness:1.1,contrast:1}, visible:true, opacity:1}]};
state.layers = [layer]; state.layerOffsets = new Map(); state.tool = tool;
Object.assign(state,{brushSize:12, brushOpacity:100, brushFlow:100, brushSoftness:0,
brushSpacing:20, brushSmoothing:0, dodgeBurnIntensity:50});
const original = ctx.getImageData(0,0,30,30);
const pixel = () => renderLayerWithAdjLayers(layer).getContext('2d').getImageData(15,15,1,1).data[0];
const before = pixel();
const pipeline = createStrokePipeline({activeLayer:()=>layer, getActiveMaskLayer:()=>null, composite:()=>{}});
pipeline.beginStroke({x:15,y:15,pressure:1},tool);
const during = pixel();
pipeline.endStroke(null); // Finish before Undo restores the saved pixels.
ctx.putImageData(original,0,0); layer._adjFinalKey = null; layer._adjCacheKey = null;
pipeline.endStroke({x:28,y:28,pressure:1}); // A late pointer-up cannot replay this stroke.
return {before,during,after:pixel(),corner:ctx.getImageData(28,28,1,1).data[0]};
}""", tool)
assert result['during'] > result['before'] if tool == 'dodge' else result['during'] < result['before']
assert result['after'] == result['before']
assert result['corner'] == 100
def test_undo_finishes_active_stroke_before_restoring_history():
source = (ROOT / 'static/js/galleryEditor.js').read_text()
undo = source.split('function undo() {', 1)[1].split('function redo()', 1)[0]
assert undo.index('if (state.drawing) _strokeTool.tryEnd();') < undo.index('state.undoStack.pop()')
@pytest.mark.parametrize('width,height', [(1000,700), (390,844)])
def test_filter_submenu_rows_have_equal_heights_with_and_without_icons(page, width, height):
page.set_viewport_size({'width':width,'height':height})
page.add_style_tag(content=(ROOT / 'static/css/13-image-editor.css').read_text())
page.evaluate("""() => {
document.body.innerHTML = '<div class="ge-submenu" style="display:block;left:8px;top:8px;">'
+ '<button class="dropdown-item-compact"><span>Plain</span></button>'
+ '<button class="dropdown-item-compact"><svg width="24" height="24"><circle cx="12" cy="12" r="8"/></svg><span>Icon</span></button>'
+ '<button class="dropdown-item-compact"><span>Longer label</span><svg width="10" height="10"></svg></button></div>';
}""")
heights = page.locator('.dropdown-item-compact').evaluate_all('(rows)=>rows.map(row=>row.getBoundingClientRect().height)')
assert heights == ([28]*3 if width > 820 else [32]*3)
page.screenshot(path=f'/tmp/editor-filter-row-heights-{width}.png')
def test_ai_settings_escape_closes_only_the_dialog(page):
page.route('**/api/auth/settings', lambda route: route.fulfill(json={'image_model':'', 'image_edit_model':''}))
result = page.evaluate("""async () => {
const {createAiSettings} = await import('/static/js/editor/ai-settings.js');
const container = document.createElement('div'); document.body.append(container);
const settings = createAiSettings({container}); settings.open();
let reachedEditor = false;
document.addEventListener('keydown', () => reachedEditor = true, {capture:true, once:true});
document.dispatchEvent(new KeyboardEvent('keydown', {key:'Escape', bubbles:true, cancelable:true}));
const result = {closed:settings.element.hidden, reachedEditor}; settings.destroy();
return result;
}""")
assert result == dict(closed=True, reachedEditor=False)
def test_image_model_settings_sync_both_directions_and_survive_reopening(page):
saved = {'image_model':'openai/gpt-5-image', 'image_edit_model':'', 'image_quality':'medium', 'image_gen_enabled':True}
writes = []
user_prefs = {}
def settings_api(route):
if route.request.method == 'POST':
patch = route.request.post_data_json
writes.append(patch)
saved.update(patch)
route.fulfill(json=dict(saved))
def prefs_api(route):
url = route.request.url
if route.request.method == 'PUT':
key = url.split('/api/prefs/')[-1]
data = route.request.post_data_json or {}
val = data.get('value')
if val is None:
user_prefs.pop(key, None)
else:
user_prefs[key] = val
route.fulfill(status=200, json={'key': key, 'value': val})
return
route.fulfill(status=200, json=dict(user_prefs))
page.route('**/api/auth/settings', settings_api)
page.route('**/api/prefs**', prefs_api)
page.route('**/api/model-endpoints', lambda route: route.fulfill(json=[{
'is_enabled':True, 'online':True, 'model_type':'llm',
'models':['openai/gpt-5-image','openai/gpt-image-2'],
}]))
page.evaluate("""async () => {
document.body.innerHTML = '<select id="set-imgModelSelect"><option value="">Auto</option></select>'
+ '<select id="set-imgEditModelSelect"><option value="">Same</option></select>'
+ '<select id="set-imgQualitySelect"><option value="medium">Medium</option></select>'
+ '<input type="checkbox" id="set-imgEnabledToggle"><span id="set-imgSettingsMsg"></span>';
const {initImageSettings} = await import('/static/js/settings/imageModels.js');
await initImageSettings();
const {createAiSettings} = await import('/static/js/editor/ai-settings.js');
window.dialog = createAiSettings({container:document.body});
for (const id of ['ge-ai-model','ge-ai-command-model']) {
const select = document.getElementById(id);
for (const model of ['', 'openai/gpt-5-image','openai/gpt-image-2']) {
const option = document.createElement('option');
option.value = model ? 'https://openrouter.ai/api/v1::'+model : '';
option.textContent = model || 'Auto'; select.append(option);
}
}
dialog.open();
}""")
page.wait_for_function("document.getElementById('ge-ai-model').value.endsWith('::openai/gpt-5-image')")
page.select_option('#ge-ai-model', 'https://openrouter.ai/api/v1::openai/gpt-image-2')
page.wait_for_function("document.getElementById('set-imgModelSelect').value === 'openai/gpt-image-2'")
assert saved['image_model'] == 'openai/gpt-image-2'
page.select_option('#set-imgEditModelSelect', 'openai/gpt-image-2')
page.wait_for_function("document.getElementById('ge-ai-command-model').value.endsWith('::openai/gpt-image-2')")
page.evaluate('dialog.close(); dialog.open();')
page.wait_for_function("document.getElementById('ge-ai-command-model').value.endsWith('::openai/gpt-image-2')")
assert saved['image_edit_model'] == 'openai/gpt-image-2'
assert len(writes) == 2
def test_non_admin_image_model_preferences_persist_and_reload(page):
global_settings = {
'image_model': 'global-default-gen',
'image_edit_model': '',
'image_quality': 'medium',
'image_gen_enabled': True,
}
user_prefs = {}
auth_posts = []
pref_puts = []
is_admin = False
def handle_auth_settings(route):
if route.request.method == 'POST':
auth_posts.append(route.request.post_data_json)
if not is_admin:
route.fulfill(status=403, json={'error': 'Admin access is required to change these settings.'})
return
global_settings.update(route.request.post_data_json)
route.fulfill(status=200, json=dict(global_settings))
return
route.fulfill(status=200, json=dict(global_settings))
def handle_prefs(route):
url = route.request.url
if route.request.method == 'PUT':
key = url.split('/api/prefs/')[-1]
data = route.request.post_data_json or {}
val = data.get('value')
pref_puts.append((key, val))
if val is None:
user_prefs.pop(key, None)
else:
user_prefs[key] = val
route.fulfill(status=200, json={'key': key, 'value': val})
return
route.fulfill(status=200, json=dict(user_prefs))
page.route('**/api/auth/settings', handle_auth_settings)
page.route('**/api/prefs**', handle_prefs)
page.route('**/api/model-endpoints', lambda route: route.fulfill(json=[{
'is_enabled': True, 'online': True, 'model_type': 'llm',
'models': ['global-default-gen', 'openai/gpt-5-image', 'openai/gpt-image-2'],
}]))
# Step 1: Non-admin loads image settings, selects a preference
page.evaluate("""async () => {
document.body.innerHTML = '<select id="set-imgModelSelect"><option value="">Auto</option></select>'
+ '<select id="set-imgEditModelSelect"><option value="">Same</option></select>'
+ '<select id="set-imgQualitySelect"><option value="medium">Medium</option></select>'
+ '<input type="checkbox" id="set-imgEnabledToggle" checked><span id="set-imgSettingsMsg"></span>';
const {initImageSettings} = await import('/static/js/settings/imageModels.js');
await initImageSettings();
}""")
# Non-admin changes generation model
page.select_option('#set-imgModelSelect', 'openai/gpt-image-2')
# Wait for status message to show 'Saved'
page.wait_for_function("document.getElementById('set-imgSettingsMsg').textContent === 'Saved'")
msg_color = page.evaluate("document.getElementById('set-imgSettingsMsg').style.color")
assert msg_color == 'var(--fg)', f"Expected var(--fg), got {msg_color}"
# Verify per-user preference was saved and global toggle was NOT propagated
assert user_prefs.get('image_model') == 'openai/gpt-image-2'
assert 'image_gen_enabled' not in user_prefs, "Global policy toggle must not be saved to user prefs"
# Step 2: Simulate page reload / re-initialization for non-admin
page.evaluate("""async () => {
document.getElementById('set-imgModelSelect').value = '';
const {initImageSettings} = await import('/static/js/settings/imageModels.js');
await initImageSettings();
}""")
page.wait_for_function("document.getElementById('set-imgModelSelect').value === 'openai/gpt-image-2'")
assert page.evaluate("document.getElementById('set-imgModelSelect').value") == 'openai/gpt-image-2'
# Step 3: Admin saves settings - pre-existing per-user preferences must be cleared
is_admin = True
page.evaluate("""async () => {
const {saveImagePreferences} = await import('/static/js/settings/imagePreferences.js');
await saveImagePreferences({ image_model: 'admin-chosen-model', image_edit_model: '', image_quality: 'high', image_gen_enabled: true });
}""")
# Verify per-user preference for image_model was cleared (PUT with value: null)
assert 'image_model' not in user_prefs, "Admin save must clear pre-existing per-user preference"
assert global_settings['image_model'] == 'admin-chosen-model'
# Admin reloads settings - receives newly saved global setting, not stale pref
page.evaluate("""async () => {
const {initImageSettings} = await import('/static/js/settings/imageModels.js');
await initImageSettings();
}""")
assert global_settings['image_model'] == 'admin-chosen-model'
def test_generation_places_result_without_stretching_or_refresh(page):
result = page.evaluate("""async () => {
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
const image = document.createElement('canvas'); image.width = 80; image.height = 20;
const ctx = image.getContext('2d'); ctx.fillStyle = 'red'; ctx.fillRect(0,0,80,20);
const selection = document.createElement('canvas'); selection.width = selection.height = 40;
selection.getContext('2d').fillRect(0,0,40,40);
let placed = null, body = null;
const run = createImageGenerationAction({
getDocument:()=>({key:'one',width:40,height:40,mask:selection}),
fetchImpl:async (url,opts) => {
body = JSON.parse(opts.body);
selection.getContext('2d').clearRect(0,0,40,40);
return {ok:true,json:async()=>({image:image.toDataURL()})};
},
placeResult:(canvas,name,mask)=>{
const data = canvas.getContext('2d');
placed = {width:canvas.width,height:canvas.height,left:data.getImageData(0,10,1,1).data[0],
right:data.getImageData(79,10,1,1).data[0], mask:mask.getContext('2d').getImageData(20,20,1,1).data[3]};
return 'new-layer';
},
});
const id = await run({prompt:'a cow',masked:true});
return {id,placed,body};
}""")
assert result['id'] == 'new-layer'
assert result['placed'] == dict(width=80,height=20,left=255,right=255,mask=255)
assert result['body'] == dict(_use_image_settings=True,prompt='a cow',width=40,height=40)
@pytest.mark.parametrize('action', ['cancel','timeout','invalid-image','document-switch'])
def test_generation_finishes_cleanly_on_failure_without_late_placement(page, action):
result = page.evaluate("""async action => {
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
let key = 'original', placements = 0;
const controller = new AbortController();
const run = createImageGenerationAction({
getDocument:()=>({key,width:40,height:40}), getSignal:()=>controller.signal,
timeoutMs:action === 'timeout' ? 20 : 2000,
fetchImpl:async (url,opts)=>{
if (action === 'timeout' || action === 'cancel') {
if (action === 'cancel') setTimeout(()=>controller.abort(),10);
return new Promise((resolve,reject)=>opts.signal.addEventListener('abort',
()=>reject(new DOMException('Aborted','AbortError')), {once:true}));
}
if (action === 'invalid-image') return {ok:true,json:async()=>({image:'bad-image-data'})};
const image = document.createElement('canvas'); image.width = image.height = 4;
key = 'different';
return {ok:true,json:async()=>({image:image.toDataURL()})};
},
placeResult:()=>placements++,
});
let error = '';
try {await run({prompt:'a cow'});} catch (e) {error = e.message;}
return {error,placements};
}""", action)
assert result['placements'] == 0
assert result['error']
if action == 'timeout':
assert 'timed out' in result['error']
if action == 'document-switch':
assert 'document changed' in result['error']
def test_generation_provider_error_is_not_retried_and_next_request_can_succeed(page):
result = page.evaluate("""async () => {
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
let calls = 0, placements = 0;
const image = document.createElement('canvas'); image.width = image.height = 4;
const run = createImageGenerationAction({
getDocument:()=>({key:'one',width:40,height:40}),
formatError:({status})=>status === 503 ? 'Generation is disabled' : 'error',
fetchImpl:async ()=> ++calls === 1
? {ok:false,status:503,json:async()=>({detail:'Disabled'})}
: {ok:true,json:async()=>({image:image.toDataURL()})},
placeResult:()=>++placements,
});
let error;
try {await run({prompt:'one'});} catch (e) {error = e.message;}
const callsAfterFailure = calls;
await run({prompt:'two'});
return {error,callsAfterFailure,calls,placements};
}""")
assert result == dict(error='Generation is disabled',callsAfterFailure=1,calls=2,placements=1)
def test_late_progress_response_cannot_restore_working_after_completion(page):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
functions = source.split('function _startAiProgress(requestId) {',1)[1].split('function _setAiCommandStatus(',1)[0]
functions = 'function _startAiProgress(requestId) {' + functions
result = page.evaluate("""async functions => {
let _aiProgressTimer = null, _aiProgressEpoch = 0, status = 'Working';
let resolveRequest;
const fetch = () => new Promise(resolve => resolveRequest = resolve);
const _setAiProgress = () => {};
const _setAiCommandStatus = text => status = text;
const api = eval(functions + '; ({start:_startAiProgress,stop:_stopAiProgress});');
api.start('old-request');
api.stop(); status = 'Done';
resolveRequest({ok:true,json:async()=>({stage:'editing',progress:0.5})});
await new Promise(resolve=>setTimeout(resolve,20));
return status;
}""", functions)
assert result == 'Done'
@pytest.mark.parametrize('success', [True,False])
def test_quick_edit_queue_clears_working_on_completion(page, success):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
pump = 'const pumpQuickEdit = ' + source.split('const pumpQuickEdit = ',1)[1].split(' const enqueueQuickEditJob',1)[0]
result = page.evaluate("""async ({pump,success}) => {
const {finishQuickEditJob} = await import('/static/js/editor/quick-edit-job.js');
const jobs = [{id:'one',prompt:'fill',status:'queued'}];
let pumping = false, _quickEditActiveJob = null, stopped = 0, terminal;
const renderJobStack = () => {};
const dismissJob = () => {};
const _stopAiProgress = () => stopped++;
const _setAiCommandStatus = (text,kind) => terminal = {text,kind};
const executeQuickEdit = async () => success ? {ok:true} : {ok:false,error:'Provider failed'};
await eval(pump + '; pumpQuickEdit();');
return {status:jobs[0].status,detail:jobs[0].detail,terminal,stopped,pumping};
}""", {'pump':pump,'success':success})
expected = 'done' if success else 'error'
message = 'Done' if success else 'Provider failed'
assert result == dict(status=expected,detail=message,terminal=dict(text=message,kind=expected),stopped=1,pumping=False)
@pytest.mark.parametrize('bridge', [False,True])
@pytest.mark.parametrize('instruction', [False,True])
@pytest.mark.parametrize('matching_ratio', [False,True])
def test_generated_layer_can_keep_pixels_outside_document_bounds(page, bridge, instruction, matching_ratio):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
placement = 'const _placeResultLayer = ' + source.split('const _placeResultLayer = ',1)[1].split(' const _generateImage',1)[0]
bridge_line = next(line for line in source.splitlines() if "'layers.placeImage':" in line)
result = page.evaluate("""async ({placement,bridgeLine,bridge,instruction,matchingRatio}) => {
const {imageResultGeometry} = await import('/static/js/editor/image-result-geometry.js');
const state = {imgWidth:matchingRatio ? 1280 : 40,imgHeight:matchingRatio ? 720 : 40,layers:[],layerOffsets:new Map()};
let saved = 0;
const _saveState = ()=>saved++;
const composite = ()=>{};
const _renderLayerPanel = ()=>{};
const createLayer = (name,w,h)=>{
const canvas = document.createElement('canvas'); canvas.width=w;canvas.height=h;
return {id:'new',name,canvas,ctx:canvas.getContext('2d')};
};
const image = document.createElement('canvas'); image.width=matchingRatio ? 1536 : 80;image.height=matchingRatio ? 864 : 20;
image.getContext('2d').fillRect(0,0,image.width,image.height);
if (bridge) {
const run = eval('({' + bridgeLine + '})')["layers.placeImage"].run;
await run({dataUrl:image.toDataURL(),nativeSize:true,masked:false,
mode:instruction ? 'instruction' : undefined,differenceMask:instruction});
} else {
eval(placement + '; _placeResultLayer(image,"Generated",null);');
}
const layer = state.layers[0];
return {width:layer.canvas.width,height:layer.canvas.height,offset:state.layerOffsets.get(layer.id),
outsideAlpha:layer.ctx.getImageData(79,10,1,1).data[3],saved};
}""", {'placement':placement,'bridgeLine':bridge_line,'bridge':bridge,'instruction':instruction,'matchingRatio':matching_ratio})
expected = dict(width=1280,height=720,offset=dict(x=0,y=0),outsideAlpha=255,saved=1) if matching_ratio else dict(width=80,height=20,offset=dict(x=-20,y=10),outsideAlpha=255,saved=1)
assert result == expected
def test_whole_image_edit_does_not_clip_native_result_to_select_all(page):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
operation = 'const _aiInpaintSelection = ' + source.split('const _aiInpaintSelection = ',1)[1].split(' // Replace the background/scene',1)[0]
result = page.evaluate("""async operation => {
const mask = document.createElement('canvas'); mask.width=40;mask.height=40;
const canvas = document.createElement('canvas'); canvas.width=80;canvas.height=20;
const state = {wandMask:mask};
const activeLayer = ()=>({id:'one'});
const _aiEditCanvas = async ()=>({canvas,fullFrame:true});
const _placeResultLayer = (result,name,resultMask)=>({width:result.width,height:result.height,masked:!!resultMask});
return await eval(operation + '; _aiInpaintSelection("change the sky");');
}""", operation)
assert result == dict(width=80,height=20,masked=False)
@pytest.mark.parametrize('matching_ratio', [False,True])
def test_inpaint_panel_preserves_native_whole_edit_dimensions(page, matching_ratio):
source = (ROOT / 'static/js/editor/ai-inpaint.js').read_text()
body = 'const fullFrame = ' + source.split('const fullFrame = ',1)[1].split(' // Snapshot the AI result',1)[0]
result = page.evaluate("""async ({body,matchingRatio}) => {
const {imageResultGeometry} = await import('/static/js/editor/image-result-geometry.js');
const state = {imgWidth:matchingRatio ? 1280 : 40,imgHeight:matchingRatio ? 720 : 40,layerOffsets:new Map()};
const data = {full_frame:true}, layerName = 'Edit';
const createLayer = (name,w,h)=>{
const canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
return {id:'result',canvas,ctx:canvas.getContext('2d')};
};
const source = document.createElement('canvas'); source.width=matchingRatio ? 1536 : 80;source.height=matchingRatio ? 864 : 20;
source.getContext('2d').fillRect(0,0,source.width,source.height);
const resultImg = new Image(); resultImg.src=source.toDataURL(); await resultImg.decode();
return eval(body + '; ({width:resultLayer.canvas.width,height:resultLayer.canvas.height,offset:state.layerOffsets.get(resultLayer.id)});');
}""", {'body':body,'matchingRatio':matching_ratio})
assert result == (dict(width=1280,height=720,offset=dict(x=0,y=0)) if matching_ratio else dict(width=80,height=20,offset=dict(x=-20,y=10)))
def test_brush_softness_is_in_main_bar_and_saves_extended_value(page):
page.add_style_tag(content=(ROOT / 'static/css/13-image-editor.css').read_text())
result = page.evaluate("""async () => {
const {controlsHTML} = await import('/static/js/editor/build/controls.js');
const {wireStrokeToolSliders} = await import('/static/js/editor/stroke-tool-sliders.js');
const {state} = await import('/static/js/editor/state.js');
const bar = document.createElement('div');bar.className='ge-options-bar';
const controls = document.createElement('div');controls.className='ge-controls ge-controls--in-options';
controls.innerHTML=controlsHTML({color:'#000000',brushSize:40,wandTolerance:20});
for(const child of controls.children) if(child.id !== 'ge-brush-controls') child.style.display='none';
bar.append(controls);document.body.append(bar);
wireStrokeToolSliders();
const slider=document.getElementById('ge-brush-softness');
slider.value='1200';slider.dispatchEvent(new Event('input',{bubbles:true}));
return {inMore:!!slider.closest('#ge-brush-more-menu'),max:slider.max,value:state.brushSoftness,
visible:slider.getBoundingClientRect().height>0,label:document.getElementById('ge-brush-softness-label').textContent};
}""")
assert result == dict(inMore=False,max='1200',value=1200,visible=True,label='1200%')
page.screenshot(path='/tmp/editor-brush-softness-main-bar.png')
def test_extended_softness_produces_softer_pixels_not_just_larger_slider_values(page):
result = page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {createStrokePipeline}=await import('/static/js/editor/stroke-pipeline.js');
const results=[];
for(const softness of [300,1200]) {
const canvas=document.createElement('canvas');canvas.width=canvas.height=100;
const layer={id:'brush',canvas,ctx:canvas.getContext('2d')};
Object.assign(state,{tool:'brush',brushSize:60,brushSoftness:softness,brushOpacity:100,brushFlow:100,
brushBlendMode:'source-over',color:'#000000',brushSpacing:15,brushSmoothing:0,
brushPixel:false,pressureSize:false,pressureOpacity:false,pressureFlow:false,layerOffsets:new Map()});
const pipeline=createStrokePipeline({activeLayer:()=>layer,getActiveMaskLayer:()=>null,composite:()=>{}});
pipeline.beginStroke({x:50,y:50,pressure:1},'brush');pipeline.endStroke(null);
const alpha=x=>layer.ctx.getImageData(x,50,1,1).data[3];
results.push({middle:alpha(65),center:alpha(50),outside:alpha(85)});
document.body.append(canvas);
}
return results;
}""")
assert result[1]['middle'] < result[0]['middle'] / 3
assert result[1]['center'] > 150
assert result[0]['outside'] == result[1]['outside'] == 0
page.screenshot(path='/tmp/editor-brush-softness-falloff.png')
@pytest.mark.parametrize('width', [1000,840])
def test_brush_numbers_do_not_overlap_after_real_panel_normalization(page,width):
page.set_viewport_size({'width':width,'height':700})
for stylesheet in ['static/css/13-image-editor.css','static/css/documents-gallery-editor.css']:
page.add_style_tag(content=(ROOT / stylesheet).read_text())
page.add_style_tag(content=':root{--bg:#171719;--panel:#242426;--fg:#eee;--red:#d47965;--border:#454549;}body{background:var(--bg);}')
result = page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {controlsHTML}=await import('/static/js/editor/build/controls.js');
const {buildRightPanel}=await import('/static/js/editor/build/right-panel.js');
const {wireStrokeToolSliders}=await import('/static/js/editor/stroke-tool-sliders.js');
state.container=document.createElement('div');document.body.append(state.container);
state.brushSize=800;
const {controls}=buildRightPanel({controlsHTML,layerPanelHTML:()=>'<div></div>'});
controls.classList.add('ge-controls--in-options');
const bar=document.createElement('div');bar.className='ge-options-bar';bar.append(controls);state.container.append(bar);
for(const child of controls.children) if(child.id !== 'ge-brush-controls') child.style.display='none';
wireStrokeToolSliders();
const slider=document.getElementById('ge-brush-softness');
const elements=[document.querySelector('#ge-brush-softness-row label'),slider,
document.getElementById('ge-brush-softness-label'),document.querySelector('.ge-brush-size-row label'),
document.querySelector('.ge-brush-size-row input')];
const bounds=()=>elements.map(element=>{
const r=element.getBoundingClientRect();return {x:r.x,right:r.right,width:r.width};
});
slider.value='1';slider.dispatchEvent(new Event('input'));const small=bounds();
slider.value='1200';slider.dispatchEvent(new Event('input'));const large=bounds();
return {small,large,value:document.getElementById('ge-brush-softness-label').textContent};
}""")
assert result['value'] == '1200%'
assert all(bounds['width'] > 0 for bounds in result['large']), result
for left,right in zip(result['large'],result['large'][1:]):
assert left['right'] <= right['x'] + 1, result
for small,large in zip(result['small'],result['large']):
assert abs(small['x']-large['x']) <= 1, result
page.screenshot(path=f'/tmp/editor-brush-number-layout-{width}.png')
@pytest.mark.parametrize('fail_once', [False, True])
def test_new_project_create_initializes_canvas(page, fail_once):
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
page.add_style_tag(content='''
.modal {position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:250;pointer-events:none;}
.modal-content {pointer-events:auto;background:#333;padding:20px;}
#gallery-modal {z-index:700;}
#gallery-modal > .modal-content {width:90vw;height:90vh;}
''')
page.evaluate("""async () => {
document.body.innerHTML = '<div id="toast"></div><div class="modal" id="gallery-modal"><div class="modal-content"><div id="gallery-editor-container"></div></div></div>';
const editor = await import('/static/js/galleryEditor.js');
window.newProject = editor.openEditor(null, null, null, 'New canvas');
window.newProject.catch(error => { window.createError = error.stack; });
}""")
if fail_once:
page.evaluate("""() => {
const fill = CanvasRenderingContext2D.prototype.fillRect;
CanvasRenderingContext2D.prototype.fillRect = function(...args) {
if (this.canvas.width === 1024 && this.canvas.height === 1024) {
CanvasRenderingContext2D.prototype.fillRect = fill;
throw Error('Canvas allocation test failure');
}
return fill.apply(this, args);
};
}""")
page.locator('#ge-canvas-prompt-ok').click(timeout=3000)
if fail_once:
page.wait_for_function("window.createError")
assert 'Canvas allocation test failure' in page.evaluate('window.createError')
assert 'Could not create project' in page.locator('body').inner_text()
page.evaluate("""async () => {
window.createError = null;
const editor = await import('/static/js/galleryEditor.js');
window.newProject = editor.openEditor(null, null, null, 'New canvas');
window.newProject.catch(error => { window.createError = error.stack; });
}""")
page.locator('#ge-canvas-prompt-ok').click(timeout=3000)
page.wait_for_timeout(500)
result = page.evaluate("""async () => {
const {state} = await import('/static/js/editor/state.js');
return {error:window.createError, width:state.mainCanvas?.width,
height:state.mainCanvas?.height, layers:state.layers.map(layer=>layer.name)};
}""")
assert not errors, errors
assert not result.get('error'), result
assert result == {'error':None,'width':1024,'height':1024,'layers':['Background','Edit']}
def test_undo_clears_rembg_live_layer_and_edge_tuner_cannot_corrupt(page):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
restore_fn = source.split('function _restoreState(snap) {', 1)[1].split('function undo() {', 1)[0]
assert 'state.rembgLiveLayer = null;' in restore_fn
assert 'state.rembgLiveSnap = null;' in restore_fn
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
result = page.evaluate("""async () => {
document.body.innerHTML = '<div id="toast"></div><div id="gallery-editor-container"></div>';
const editor = await import('/static/js/galleryEditor.js');
const {state} = await import('/static/js/editor/state.js');
editor.openEditor(null, null, {w: 16, h: 16}, 'Rembg edge tuner undo test');
const layer = state.layers.at(-1);
layer.ctx.fillStyle = 'rgba(0, 255, 0, 1)';
layer.ctx.fillRect(0, 0, 16, 16);
const originalPixel = [...layer.ctx.getImageData(0, 0, 1, 1).data];
// Save a valid pre-edit snapshot to undoStack
const origCanvas = document.createElement('canvas');
origCanvas.width = origCanvas.height = 16;
origCanvas.getContext('2d').drawImage(layer.canvas, 0, 0);
const preEditSnap = {
imgWidth: 16, imgHeight: 16,
activeLayerId: layer.id,
selectedLayerIds: [layer.id],
layers: [{
id: layer.id,
name: 'Layer 1',
canvas: origCanvas,
imageData: origCanvas.getContext('2d').getImageData(0, 0, 16, 16),
visible: true,
opacity: 1,
blendMode: 'source-over',
locks: {},
}],
};
state.undoStack.push(preEditSnap);
// Create tuner snapshot for background removal
const snapCanvas = document.createElement('canvas');
snapCanvas.width = snapCanvas.height = 16;
const snapCtx = snapCanvas.getContext('2d');
snapCtx.fillStyle = 'rgba(255, 0, 0, 1)';
snapCtx.fillRect(4, 4, 8, 8); // cutout shape
// Attach live tuner
state.rembgLiveLayer = layer;
state.rembgLiveSnap = snapCanvas;
const tunerAttached = state.rembgLiveLayer === layer && state.rembgLiveSnap === snapCanvas;
// Mutate layer pixels to simulate background removal result
layer.ctx.clearRect(0, 0, 16, 16);
layer.ctx.drawImage(snapCanvas, 0, 0);
// Trigger Undo via Ctrl+Z
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true, cancelable: true,
}));
const clearedAfterUndo = state.rembgLiveLayer === null && state.rembgLiveSnap === null;
const restoredLayer = state.layers.at(-1);
const restoredPixel = [...restoredLayer.ctx.getImageData(0, 0, 1, 1).data];
// Now interact with edge tuner sliders after undo
const featherInput = document.getElementById('ge-rembg-feather');
const growInput = document.getElementById('ge-rembg-grow');
if (featherInput) {
featherInput.value = '5';
featherInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (growInput) {
growInput.value = '5';
growInput.dispatchEvent(new Event('input', { bubbles: true }));
}
// Check pixel again after tuner events: restored pixels must remain untouched
const pixelAfterTuner = [...restoredLayer.ctx.getImageData(0, 0, 1, 1).data];
return {
tunerAttached,
clearedAfterUndo,
restoredPixel,
originalPixel,
pixelAfterTuner,
rembgLiveLayerNull: state.rembgLiveLayer === null,
rembgLiveSnapNull: state.rembgLiveSnap === null,
};
}""")
assert result['tunerAttached'] is True
assert result['clearedAfterUndo'] is True
assert result['rembgLiveLayerNull'] is True
assert result['rembgLiveSnapNull'] is True
assert result['restoredPixel'] == result['originalPixel']
assert result['pixelAfterTuner'] == result['originalPixel']
+2 -2
View File
@@ -143,8 +143,8 @@ def test_crop_can_start_outside_the_canvas_without_panning():
# image (so it can be pulled out past the edge) instead of the canvas-area
# pan grabbing the pointer and switching to hand mode.
CE = (ROOT / "static/js/editor/canvas-events.js").read_text(encoding="utf-8")
assert "if (state.tool !== 'lasso' && state.tool !== 'crop') return;" in CE
assert "(state.tool === 'lasso' || state.tool === 'crop') && !directPan" in CE
assert "['marquee', 'lasso', 'crop'].includes(state.tool)" in CE
assert "if (canStartOutside() && !directPan)" in CE
# Pressing editor chrome inside the area (e.g. the crop Apply panel) must
# not start a fresh crop.
assert ".ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup" in CE
+5 -6
View File
@@ -32,13 +32,12 @@ def test_preflight_module_has_the_four_setup_steps():
assert "required ready" in src
def test_preflight_is_wired_into_the_editor():
def test_editor_uses_ai_settings_without_redundant_setup():
editor = _read("static/js/galleryEditor.js")
assert "import { createPreflight } from './editor/preflight.js';" in editor
assert "createPreflight({ canvasArea })" in editor
assert "_preflight?.maybeOpen();" in editor
assert "setupBtn.id = 'ge-setup-btn';" in editor
assert "_preflight?.destroy();" in editor
assert "createPreflight" not in editor
assert "setupBtn.id = 'ge-setup-btn';" not in editor
assert "createAiSettings({ container })" in editor
assert "aiBtn.addEventListener('click', () => _aiSettings?.open());" in editor
def test_ai_models_exposes_status_for_the_checklist():
+36 -1
View File
@@ -49,7 +49,42 @@ def test_generate_route_forwards_the_picked_model():
assert 'model_choice = str((body or {}).get("model") or "").strip()' in route
assert 'payload["model"] = model_choice' in route
assert "content = _json.dumps(payload)" in route
assert "localStorage.getItem('ge-ai-command-model')" in JS
operation = JS.split("'ai.generateImage':", 1)[1].split("\n", 1)[0]
generation = (ROOT / 'static/js/editor/image-generation.js').read_text()
assert '_generateImage(p || {})' in operation
assert "_use_image_settings: true" in generation
assert "document.getElementById('ge-ai-model')" not in operation
assert "_getSelectedQuickEditModel()" not in operation
assert "localStorage.getItem" not in operation
def test_ai_settings_separates_generation_from_editing_without_setup():
settings = (ROOT / "static/js/editor/ai-settings.js").read_text()
assert 'id="ge-ai-model"' in settings
assert 'id="ge-ai-command-model"' in settings
assert "Image generation model" in settings
assert "createPreflight" not in JS
assert "ge-setup-btn';" not in JS.split("const aiBtn", 1)[0]
def test_generate_uses_live_picker_and_auto_edit_fallback():
import subprocess
module = (ROOT / "static/js/editor/ai-models.js").as_uri()
script = """
import assert from 'node:assert/strict';
const {resolveQuickEditModel} = await import(MODULE);
const selected = 'https://openrouter.ai/api/v1::openai/gpt-5-image';
const option = {value: selected, textContent: 'GPT-5 Image'};
const picker = {value: selected, selectedIndex: 0, options: [option]};
const fallback = {value: '', options: [option]};
globalThis.document = {getElementById: id => id === 'ge-ai-command-model' ? picker : fallback};
globalThis.localStorage = {getItem: () => {throw new Error('must not read stale storage');}};
assert.equal(resolveQuickEditModel().model, 'openai/gpt-5-image');
picker.value = '';
assert.equal(resolveQuickEditModel().model, '');
""".replace("MODULE", repr(module))
subprocess.run(["node", "--input-type=module", "-e", script], check=True)
def test_cloud_image_model_is_resolved_before_the_local_fallback():
+7 -6
View File
@@ -52,7 +52,7 @@ def test_quick_edit_flip_and_rotate_accept_numeric_degrees():
def test_quick_edit_transforms_do_not_add_a_second_undo_snapshot():
start = EDITOR.index("if (/\\brotate\\b.*\\b180\\b")
end = EDITOR.index("if (_looksBackgroundRemoval(p)) {", start)
end = EDITOR.index("if (!/\\b(text|title|caption|font|heading|subtitle)", start)
transform_block = EDITOR[start:end]
assert "_saveState('Rotate" not in transform_block
assert "_saveState('Flip" not in transform_block
@@ -372,9 +372,10 @@ def test_quick_edit_has_a_model_picker():
assert "function _getSelectedQuickEditModel()" in EDITOR
assert "getModel: () => _getSelectedQuickEditModel()," in EDITOR
# Populated from the provider list and persisted per user.
assert "aiCommandSelect.innerHTML = '<option value=\"\">Auto (default)</option>';" in models
assert "localStorage.setItem('ge-ai-command-model', aiCommandSelect.value)" in models
assert "localStorage.getItem('ge-ai-command-model')" in models
assert "aiCommandSelect.innerHTML = '<option value=\"\">Same as generation</option>';" in models
assert "localStorage.setItem('ge-ai-command-model'" not in models
assert "localStorage.getItem('ge-ai-command-model')" not in models
assert 'saveImagePreferences({ [key]: model })' in AI_SETTINGS
# Styled.
assert ".ge-ai-settings-card" in STYLE
assert ".ge-ai-command-model" in STYLE
@@ -698,7 +699,7 @@ def test_quick_edit_shows_a_real_progress_bar():
assert "fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}`" in EDITOR
# The edit request carries the request_id the server keys progress on.
assert "const requestId = 'qe-' + Date.now().toString(36)" in EDITOR
assert "const body = JSON.stringify({ image, request_id: requestId, ...payload });" in EDITOR
assert "const body = JSON.stringify({ image, width: flat.width, height: flat.height, request_id: requestId, ...payload });" in EDITOR
assert "_startAiProgress(requestId);" in EDITOR
assert "_stopAiProgress();" in EDITOR
# Styling exists.
@@ -718,7 +719,7 @@ def test_generation_503_is_handled_gracefully():
# (returns 503). Surface a clear message and do not retry immediately.
assert "if (status === 503) return _GEN_DISABLED_MSG;" in EDITOR
assert "_GEN_DISABLED_MSG = 'Image generation is switched off on the server at the moment.'" in EDITOR
assert "window.__geGenerationDisabled" in EDITOR
assert "formatError: _generationErrorMessage" in EDITOR
def test_sending_a_prompt_returns_focus_to_the_document():
+73 -8
View File
@@ -5,6 +5,7 @@ import json
from types import SimpleNamespace
import httpx
import pytest
from PIL import Image
import routes.gallery_routes as gallery
@@ -16,7 +17,10 @@ def png(image):
return base64.b64encode(output.getvalue()).decode()
def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog):
@pytest.mark.parametrize('model', ['openai/gpt-5-image', 'openai/gpt-image-2'])
@pytest.mark.parametrize('saved_settings', [False, True])
@pytest.mark.parametrize('full_frame', [False, True])
def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog, model, saved_settings, full_frame):
import logging
caplog.set_level(logging.DEBUG)
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', api_key='test')
@@ -29,36 +33,97 @@ def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog):
monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice')
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *args: endpoint)
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, ''))
monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {'image_model':'wrong-generation-model', 'image_edit_model':model})
def resolve_model(selected, **kwargs):
assert selected == model
assert kwargs['owner'] == 'alice'
return endpoint.base_url + '/chat/completions', model, {}
monkeypatch.setattr('src.ai_interaction._resolve_model', resolve_model)
requests = []
def respond(request):
requests.append(request)
assert str(request.url) == endpoint.base_url + '/images'
assert str(request.url) == endpoint.base_url + ('/chat/completions' if model == 'openai/gpt-5-image' else '/images')
payload = json.loads(request.content)
assert payload['model'] == 'openai/gpt-5-image'
assert len(payload['input_references']) == 2
assert payload['model'] == model
if model == 'openai/gpt-5-image':
assert len(payload['messages'][0]['content']) == 3
else:
assert len(payload['input_references']) == 2
assert request.headers['authorization'] == 'Bearer test'
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (2, 1), 'blue'))}]})
image = png(Image.new('RGB', (4, 4) if full_frame else (2, 1), 'blue'))
if model == 'openai/gpt-5-image':
return httpx.Response(200, json={'choices':[{'message':{'images':[{'image_url':{'url':'data:image/png;base64,'+image}}]}}]})
return httpx.Response(200, json={'data': [{'b64_json': image}]})
client = httpx.AsyncClient
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: client(transport=httpx.MockTransport(respond), **kwargs))
mask = Image.new('L', (2, 1))
if full_frame:
mask.paste(255, (0,0,2,1))
mask.putpixel((1, 0), 255)
class Request:
async def json(self):
return {'_endpoint': endpoint.base_url, '_model': 'openai/gpt-5-image',
return {'_endpoint': endpoint.base_url, '_model': 'stale-model' if saved_settings else model,
'_use_image_settings':saved_settings,
'image': png(Image.new('RGB', (2, 1), 'red')), 'mask': png(mask), 'prompt': 'private-cloud-prompt'}
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
result = asyncio.run(route.endpoint(Request()))
output = Image.open(io.BytesIO(base64.b64decode(result['image'])))
assert output.getpixel((0, 0)) == (255, 0, 0, 255)
assert output.getpixel((1, 0)) == (0, 0, 255, 255)
if full_frame:
assert output.size == (4,4)
assert result['full_frame'] is True
else:
assert output.getpixel((0, 0)) == (255, 0, 0, 255)
assert output.getpixel((1, 0)) == (0, 0, 255, 255)
assert len(requests) == 1
assert 'private-cloud-prompt' not in caplog.text
def test_partial_image_edit_preserves_ratio_and_unmasked_pixels():
source = Image.new('RGBA',(4,4),'red')
generated = Image.new('RGBA',(4,2),'blue')
mask = Image.new('L',(4,4),255)
mask.putpixel((0,0),0)
result, full = gallery._compose_cloud_image_edit(source,generated,mask)
assert full is False
assert result.size == (4,4)
assert result.getpixel((2,0)) == source.getpixel((2,0))
assert result.getpixel((2,3)) == source.getpixel((2,3))
assert result.getpixel((2,1)) == (0,0,255,255)
def test_quick_edit_inpaint_requests_source_ratio_without_width_height_fields(monkeypatch):
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1',api_key='test')
monkeypatch.setattr(gallery,'SessionLocal',lambda:SimpleNamespace(close=lambda:None))
monkeypatch.setattr(gallery,'require_privilege',lambda *args:'alice')
monkeypatch.setattr(gallery,'_visible_image_endpoint_for_base',lambda *args:endpoint)
monkeypatch.setattr('src.url_safety.check_outbound_url',lambda *args,**kwargs:(True,''))
sent = []
original = httpx.AsyncClient
def respond(request):
payload = json.loads(request.content)
sent.append(payload)
return httpx.Response(200,json={'data':[{'b64_json':png(Image.new('RGB',(1536,864),'blue'))}]})
monkeypatch.setattr(httpx,'AsyncClient',lambda **kwargs:original(transport=httpx.MockTransport(respond),**kwargs))
class Request:
async def json(self):
return {'_endpoint':endpoint.base_url,'_model':'openai/gpt-image-2','prompt':'change the sky',
'image':png(Image.new('RGB',(1280,720),'red')),'mask':png(Image.new('L',(1280,720),255))}
route = next(route for route in gallery.setup_gallery_routes().routes if route.path == '/api/image/inpaint')
result = asyncio.run(route.endpoint(Request()))
assert sent[0]['aspect_ratio'] == '16:9'
assert 'size' not in sent[0]
image = Image.open(io.BytesIO(base64.b64decode(result['image'])))
assert image.size == (1536,864)
def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', owner='alice',
model_type='llm', cached_models='["openai/gpt-5-image", "text-model"]', pinned_models=None)
+67 -10
View File
@@ -23,7 +23,7 @@ def test_native_generation_uses_configured_model_and_multiline_prompt(image_tran
json.dumps({'prompt': prompt}), session_id='fixture', owner='alice'))
assert 'error' not in result
assert seen == [('openai/gpt-5-image', 'alice')]
assert json.loads(requests[0].content)['prompt'] == prompt
assert json.loads(requests[0].content)['messages'][0]['content'] == prompt
assert result['image_id']
@@ -87,13 +87,17 @@ def image_transport(monkeypatch, tmp_path):
original_client = httpx.AsyncClient
requests = []
def configure(base, status=200, body=None):
def configure(base, status=200, body=None, model='openai/gpt-5-image'):
monkeypatch.setattr(ai_interaction, '_resolve_model', lambda *args, **kwargs: (
base + '/chat/completions', 'openai/gpt-5-image', {'Authorization': 'Bearer test'},
base + '/chat/completions', model, {'Authorization': 'Bearer test'},
))
def handle(request):
requests.append(request)
if body is None and 'openrouter.ai' in base and request.url.path.endswith('/chat/completions'):
return httpx.Response(status, json={'choices': [{'message': {'images': [{
'image_url': {'url': 'data:image/png;base64,' + base64.b64encode(source.read_bytes()).decode()},
}]}}]})
return httpx.Response(status, json=body if body is not None else {
'data': [{'b64_json': base64.b64encode(source.read_bytes()).decode()}],
})
@@ -130,12 +134,13 @@ def test_image_provider_protocol(image_transport, editing, base):
assert request.headers['authorization'] == 'Bearer test'
if 'openrouter.ai' in base:
assert len(requests) == 1 # No unsupported local progress/fallback probes.
assert str(request.url) == base + '/images'
assert str(request.url) == base + '/chat/completions'
payload = json.loads(request.content)
assert payload['n'] == 1
assert payload['model'] == 'openai/gpt-5-image'
assert payload['modalities'] == ['image', 'text']
assert 'prompt' not in payload
if editing:
assert payload['size'] == '1536x1024'
reference = payload['input_references'][0]['image_url']['url']
reference = payload['messages'][0]['content'][1]['image_url']['url']
assert reference.startswith('data:image/png;base64,')
assert base64.b64decode(reference.split(',', 1)[1]) == source.read_bytes()
assert 'request_id' not in payload
@@ -181,13 +186,65 @@ def test_edit_dimensions_respect_exif_and_explicit_size(image_transport, explici
exif = Image.Exif()
exif[274] = 6
Image.new('RGB', (120, 80)).save(source, exif=exif)
configure('https://openrouter.ai/api/v1')
configure('https://openrouter.ai/api/v1', model='openai/gpt-image-1')
result = asyncio.run(ai_interaction.do_edit_image(
'Edit', str(source), model_spec='openai/gpt-5-image',
'Edit', str(source), model_spec='openai/gpt-image-1',
size='1024x1024' if explicit else 'auto',
))
assert 'error' not in result
assert json.loads(requests[0].content)['size'] == ('1024x1024' if explicit else '1024x1536')
assert json.loads(requests[0].content)['aspect_ratio'] == ('1:1' if explicit else '2:3')
def test_native_openrouter_image_model_keeps_dedicated_image_api(image_transport):
configure, source, requests, rows = image_transport
configure('https://openrouter.ai/api/v1', model='openai/gpt-image-1')
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-image-1', incognito=True))
assert 'error' not in result
assert str(requests[0].url).endswith('/api/v1/images')
assert json.loads(requests[0].content)['model'] == 'openai/gpt-image-1'
@pytest.mark.parametrize('editing', [False, True])
def test_http_200_provider_deprecation_is_not_reported_as_empty_image(image_transport, editing):
configure, source, requests, rows = image_transport
configure('https://openrouter.ai/api/v1', body={'choices': [{
'finish_reason': 'error', 'message': {'content': None},
'error': {'code': 502, 'message': 'The model gpt-image-1 has been deprecated'},
}]})
if editing:
result = asyncio.run(ai_interaction.do_edit_image('Edit', str(source), model_spec='openai/gpt-5-image'))
else:
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-5-image'))
assert 'deprecated' in result['error']
assert 'openai/gpt-5-image' in result['error']
assert 'AI settings' in result['error']
assert len(requests) == 1
assert not rows
def test_empty_image_response_identifies_the_selected_model(image_transport):
configure, source, requests, rows = image_transport
configure('https://openrouter.ai/api/v1', body={'choices': [{'message': {'content': 'No image.'}}]})
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-5-image'))
assert "'openai/gpt-5-image' returned no image" in result['error']
assert 'AI settings' in result['error']
assert len(requests) == 1
@pytest.mark.parametrize('editing', [False,True])
def test_openrouter_1280_by_720_requests_16_by_9_not_square(image_transport, editing):
configure, source, requests, rows = image_transport
Image.new('RGB',(1280,720),'red').save(source)
configure('https://openrouter.ai/api/v1',model='openai/gpt-image-2')
if editing:
result = asyncio.run(ai_interaction.do_edit_image('Change the sky',str(source),model_spec='openai/gpt-image-2',incognito=True))
else:
result = asyncio.run(ai_interaction.do_generate_image(json.dumps({
'prompt':'a landscape','model':'openai/gpt-image-2','size':'1280x720'}),incognito=True))
assert 'error' not in result
body = json.loads(requests[0].content)
assert body['aspect_ratio'] == '16:9'
assert 'size' not in body
def test_unreadable_image_does_not_call_provider(image_transport):
+551
View File
@@ -118,3 +118,554 @@ def test_failed_save_shows_failure_in_simulated_dom():
assert data["serverErrResult"] == {"text": "Failed to save", "color": "var(--red)"}
assert data["okResult"] == {"text": "Saved", "color": "var(--fg)"}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_image_preferences_js_policy_and_non_admin_fallback():
"""Verify imagePreferences.js non-admin fallback, policy toggle isolation, and admin clearing."""
js = """
globalThis.window = { dispatchEvent: () => {} };
globalThis.CustomEvent = class CustomEvent {};
const { saveImagePreferences, readImagePreferences } = await import('./static/js/settings/imagePreferences.js');
const results = {};
// Scenario 1: Non-admin save (POST /api/auth/settings returns 403)
const prefCalls = [];
globalThis.fetch = async (url, opts = {}) => {
if (url === '/api/auth/settings') {
return { status: 403, ok: false, text: async () => 'Forbidden' };
}
if (url.startsWith('/api/prefs/')) {
prefCalls.push({ url, body: JSON.parse(opts.body) });
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
throw new Error('Unexpected URL: ' + url);
};
const patch = {
image_gen_enabled: true,
image_model: 'user-gen-model',
image_edit_model: 'user-edit-model',
image_quality: 'high',
};
const res = await saveImagePreferences(patch);
results.fallbackResOk = res.ok;
results.fallbackResStatus = res.status;
results.prefCalls = prefCalls;
// Scenario 2: Non-admin save fails on prefs PUT (keeps actual failure visible)
globalThis.fetch = async (url) => {
if (url === '/api/auth/settings') return { status: 403, ok: false };
if (url.startsWith('/api/prefs/')) return { status: 500, ok: false, text: async () => 'Disk error' };
throw new Error('Unexpected URL: ' + url);
};
try {
await saveImagePreferences({ image_model: 'fail-model' });
results.prefFailureThrew = false;
} catch (err) {
results.prefFailureThrew = true;
results.prefFailureStatus = err.status;
}
// Scenario 3: Admin save clears pre-existing per-user preferences
const adminPrefClears = [];
globalThis.fetch = async (url, opts = {}) => {
if (url === '/api/auth/settings') {
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
if (url.startsWith('/api/prefs/')) {
adminPrefClears.push({ url, body: JSON.parse(opts.body) });
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
throw new Error('Unexpected URL: ' + url);
};
const adminRes = await saveImagePreferences({ image_model: 'admin-model', image_gen_enabled: true });
results.adminResOk = adminRes.ok;
results.adminPrefClears = adminPrefClears;
// Scenario 4: readImagePreferences loads image_quality along with models
globalThis.fetch = async (url) => {
if (url === '/api/prefs') {
return {
ok: true,
json: async () => ({ image_model: 'pref-gen', image_quality: 'low' }),
};
}
if (url === '/api/auth/settings') {
return {
ok: true,
json: async () => ({ image_model: 'global-gen', image_edit_model: 'global-edit', image_quality: 'medium' }),
};
}
throw new Error('Unexpected URL: ' + url);
};
const loaded = await readImagePreferences();
results.loaded = loaded;
console.log(JSON.stringify(results));
"""
proc = subprocess.run(["node", "--input-type=module", "-e", js], capture_output=True, text=True, check=True)
import json
data = json.loads(proc.stdout)
# 1. Non-admin fallback returns ok=True, status=200
assert data["fallbackResOk"] is True
assert data["fallbackResStatus"] == 200
# 2. image_gen_enabled was filtered out; only image_model, image_edit_model, image_quality written
written_keys = [c["url"].split("/api/prefs/")[1] for c in data["prefCalls"]]
assert "image_gen_enabled" not in written_keys
assert "image_model" in written_keys
assert "image_edit_model" in written_keys
assert "image_quality" in written_keys
# 3. Preference failure threw error and preserved status
assert data["prefFailureThrew"] is True
assert data["prefFailureStatus"] == 500
# 4. Admin save cleared per-user preferences with value: null
assert data["adminResOk"] is True
cleared_keys = [c["url"].split("/api/prefs/")[1] for c in data["adminPrefClears"]]
assert "image_model" in cleared_keys
for call in data["adminPrefClears"]:
assert call["body"] == {"value": None}
# 5. readImagePreferences includes image_quality from prefs
assert data["loaded"]["image_model"] == "pref-gen"
assert data["loaded"]["image_edit_model"] == "global-edit"
assert data["loaded"]["image_quality"] == "low"
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_admin_cleanup_failure_and_rejection_surfaced():
"""Verify saveImagePreferences surfaces HTTP failures and rejected network requests during admin cleanup."""
js = """
globalThis.window = { dispatchEvent: () => {} };
globalThis.CustomEvent = class CustomEvent {};
const { saveImagePreferences } = await import('./static/js/settings/imagePreferences.js');
const results = {};
// 1. HTTP 500 failure during admin preference cleanup
globalThis.fetch = async (url) => {
if (url === '/api/auth/settings') {
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
if (url.startsWith('/api/prefs/')) {
return { status: 500, ok: false, text: async () => 'Database lock error' };
}
throw new Error('Unexpected URL: ' + url);
};
try {
await saveImagePreferences({ image_model: 'admin-model', image_gen_enabled: true });
results.http500Threw = false;
} catch (err) {
results.http500Threw = true;
results.http500Status = err.status;
results.http500GlobalSaved = err.globalSaved;
results.http500Phase = err.phase;
}
// 2. Network rejection during admin preference cleanup
globalThis.fetch = async (url) => {
if (url === '/api/auth/settings') {
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
if (url.startsWith('/api/prefs/')) {
throw new Error('Network offline');
}
throw new Error('Unexpected URL: ' + url);
};
try {
await saveImagePreferences({ image_model: 'admin-model', image_gen_enabled: true });
results.networkRejectThrew = false;
} catch (err) {
results.networkRejectThrew = true;
results.networkRejectStatus = err.status;
results.networkRejectGlobalSaved = err.globalSaved;
results.networkRejectPhase = err.phase;
}
// 3. Successful cleanup
const clearedKeys = [];
globalThis.fetch = async (url, opts = {}) => {
if (url === '/api/auth/settings') {
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
if (url.startsWith('/api/prefs/')) {
clearedKeys.push({ url, body: JSON.parse(opts.body) });
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
throw new Error('Unexpected URL: ' + url);
};
const okRes = await saveImagePreferences({ image_model: 'admin-model', image_gen_enabled: true });
results.okResStatus = okRes.status;
results.clearedKeysCount = clearedKeys.length;
console.log(JSON.stringify(results));
"""
proc = subprocess.run(["node", "--input-type=module", "-e", js], capture_output=True, text=True, check=True)
import json
data = json.loads(proc.stdout)
assert data["http500Threw"] is True
assert data["http500Status"] == 500
assert data["http500GlobalSaved"] is True
assert data["http500Phase"] == "cleanup"
assert data["networkRejectThrew"] is True
assert data["networkRejectStatus"] == 500
assert data["networkRejectGlobalSaved"] is True
assert data["networkRejectPhase"] == "cleanup"
assert data["okResStatus"] == 200
assert data["clearedKeysCount"] == 3
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_admin_toggle_reconciliation_and_partial_failure_reporting():
"""Verify admin toggle reconciles with authoritative server state and reports partial success accurately."""
js = """
const { initImageSettings } = await import('./static/js/settings/imageModels.js');
function createEnv() {
const elements = {};
function makeElement(id, tag = 'div') {
const listeners = {};
const el = {
id, tagName: tag.toUpperCase(),
value: '', checked: false, disabled: false, title: '',
style: {},
parentElement: { title: '', style: {} },
closest: () => ({ style: {} }),
addEventListener: (evt, cb) => { listeners[evt] = listeners[evt] || []; listeners[evt].push(cb); },
dispatchEvent: (evt) => Promise.all((listeners[evt] || []).map(cb => cb())),
length: 0, appendChild: () => {}, remove: () => {}
};
elements[id] = el;
return el;
}
const modelSel = makeElement('set-imgModelSelect', 'select');
const editModelSel = makeElement('set-imgEditModelSelect', 'select');
const qualSel = makeElement('set-imgQualitySelect', 'select');
const msg = makeElement('set-imgSettingsMsg', 'span');
const enabledToggle = makeElement('set-imgEnabledToggle', 'input');
globalThis.document = {
getElementById: (id) => elements[id] || null,
createElement: (tag) => makeElement('anon-' + Math.random(), tag),
};
globalThis.window = {
_isAdmin: true,
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => {}
};
globalThis.CustomEvent = class CustomEvent {};
return { modelSel, editModelSel, qualSel, msg, enabledToggle };
}
const testResults = {};
// 1. Global POST 200 + cleanup HTTP 500 failure
{
const { enabledToggle, msg } = createEnv();
let serverGlobal = true;
globalThis.fetch = async (url, opts = {}) => {
if (url === '/api/model-endpoints') return { ok: true, json: async () => [] };
if (url === '/api/prefs') return { ok: true, json: async () => ({}) };
if (url === '/api/auth/settings') {
if (opts.method === 'POST') {
const body = JSON.parse(opts.body);
serverGlobal = body.image_gen_enabled;
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
return { ok: true, json: async () => ({ image_gen_enabled: serverGlobal }) };
}
if (url.startsWith('/api/prefs/')) {
return { status: 500, ok: false, text: async () => 'DB lock' };
}
throw new Error('Unexpected URL: ' + url);
};
await initImageSettings();
enabledToggle.checked = false;
await enabledToggle.dispatchEvent('change');
testResults.s1_checked = enabledToggle.checked;
testResults.s1_server = serverGlobal;
testResults.s1_msg = msg.textContent;
testResults.s1_color = msg.style.color;
}
// 2. Global POST 200 + cleanup network rejection
{
const { enabledToggle, msg } = createEnv();
let serverGlobal = true;
globalThis.fetch = async (url, opts = {}) => {
if (url === '/api/model-endpoints') return { ok: true, json: async () => [] };
if (url === '/api/prefs') return { ok: true, json: async () => ({}) };
if (url === '/api/auth/settings') {
if (opts.method === 'POST') {
const body = JSON.parse(opts.body);
serverGlobal = body.image_gen_enabled;
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
return { ok: true, json: async () => ({ image_gen_enabled: serverGlobal }) };
}
if (url.startsWith('/api/prefs/')) {
throw new Error('Network offline');
}
throw new Error('Unexpected URL: ' + url);
};
await initImageSettings();
enabledToggle.checked = false;
await enabledToggle.dispatchEvent('change');
testResults.s2_checked = enabledToggle.checked;
testResults.s2_server = serverGlobal;
testResults.s2_msg = msg.textContent;
testResults.s2_color = msg.style.color;
}
// 3. Successful cleanup
{
const { enabledToggle, msg } = createEnv();
let serverGlobal = true;
globalThis.fetch = async (url, opts = {}) => {
if (url === '/api/model-endpoints') return { ok: true, json: async () => [] };
if (url === '/api/prefs') return { ok: true, json: async () => ({}) };
if (url === '/api/auth/settings') {
if (opts.method === 'POST') {
const body = JSON.parse(opts.body);
serverGlobal = body.image_gen_enabled;
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
return { ok: true, json: async () => ({ image_gen_enabled: serverGlobal }) };
}
if (url.startsWith('/api/prefs/')) {
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
throw new Error('Unexpected URL: ' + url);
};
await initImageSettings();
enabledToggle.checked = false;
await enabledToggle.dispatchEvent('change');
testResults.s3_checked = enabledToggle.checked;
testResults.s3_server = serverGlobal;
testResults.s3_msg = msg.textContent;
testResults.s3_color = msg.style.color;
}
// 4. Failed global POST (HTTP 500)
{
const { enabledToggle, msg } = createEnv();
let serverGlobal = true;
globalThis.fetch = async (url, opts = {}) => {
if (url === '/api/model-endpoints') return { ok: true, json: async () => [] };
if (url === '/api/prefs') return { ok: true, json: async () => ({}) };
if (url === '/api/auth/settings') {
if (opts.method === 'POST') {
return { status: 500, ok: false, text: async () => 'Internal Server Error' };
}
return { ok: true, json: async () => ({ image_gen_enabled: serverGlobal }) };
}
if (url.startsWith('/api/prefs/')) {
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
throw new Error('Unexpected URL: ' + url);
};
await initImageSettings();
enabledToggle.checked = false;
await enabledToggle.dispatchEvent('change');
testResults.s4_checked = enabledToggle.checked;
testResults.s4_server = serverGlobal;
testResults.s4_msg = msg.textContent;
testResults.s4_color = msg.style.color;
}
console.log(JSON.stringify(testResults));
"""
proc = subprocess.run(["node", "--input-type=module", "-e", js], capture_output=True, text=True, check=True)
import json
data = json.loads(proc.stdout)
# 1. Successful global POST followed by HTTP 500 cleanup failure:
# Toggle does not revert, server policy was persisted, and message reports partial success + cleanup error
assert data["s1_checked"] is False
assert data["s1_server"] is False
assert "Policy saved, but failed to clear preferences" in data["s1_msg"]
assert "DB lock" in data["s1_msg"]
assert data["s1_color"] == "var(--red)"
# 2. Successful global POST followed by network rejection during cleanup:
# Toggle does not revert, server policy was persisted, and message reports network failure
assert data["s2_checked"] is False
assert data["s2_server"] is False
assert "Policy saved, but failed to clear preferences" in data["s2_msg"]
assert "Network offline" in data["s2_msg"]
assert data["s2_color"] == "var(--red)"
# 3. Successful cleanup:
# Toggle saved, server updated, success message displayed
assert data["s3_checked"] is False
assert data["s3_server"] is False
assert data["s3_msg"] == "Saved"
assert data["s3_color"] == "var(--fg)"
# 4. Failed global POST:
# Toggle reverts to authoritative server state, error displayed
assert data["s4_checked"] is True
assert data["s4_server"] is True
assert data["s4_msg"] == "Failed to save"
assert data["s4_color"] == "var(--red)"
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_non_admin_toggle_rejection_and_ui_behavior():
"""Verify non-admin cannot change image_gen_enabled via API or UI, and receives unambiguous feedback."""
js = """
const { saveImagePreferences } = await import('./static/js/settings/imagePreferences.js');
const results = {};
// 1. Direct call to saveImagePreferences with only non-admin excluded toggle
globalThis.window = { dispatchEvent: () => {} };
globalThis.CustomEvent = class CustomEvent {};
globalThis.fetch = async (url) => {
if (url === '/api/auth/settings') return { status: 403, ok: false, text: async () => 'Forbidden' };
throw new Error('Unexpected URL: ' + url);
};
try {
await saveImagePreferences({ image_gen_enabled: true });
results.toggleOnlyThrew = false;
} catch (err) {
results.toggleOnlyThrew = true;
results.toggleOnlyStatus = err.status;
}
// 2. Simulated UI interaction in imageModels.js for non-admin
const elements = {};
function makeElement(id, tag = 'div') {
const listeners = {};
const el = {
id, tagName: tag.toUpperCase(),
value: '', checked: false, disabled: false, title: '',
style: {},
parentElement: { title: '', style: {} },
closest: () => ({ style: {} }),
addEventListener: (evt, cb) => { listeners[evt] = listeners[evt] || []; listeners[evt].push(cb); },
dispatchEvent: (evt) => Promise.all((listeners[evt] || []).map(cb => cb())),
length: 0, appendChild: () => {}, remove: () => {}
};
elements[id] = el;
return el;
}
const modelSel = makeElement('set-imgModelSelect', 'select');
const editModelSel = makeElement('set-imgEditModelSelect', 'select');
const qualSel = makeElement('set-imgQualitySelect', 'select');
const msg = makeElement('set-imgSettingsMsg', 'span');
const enabledToggle = makeElement('set-imgEnabledToggle', 'input');
globalThis.document = {
getElementById: (id) => elements[id] || null,
createElement: (tag) => makeElement('anon-' + Math.random(), tag),
};
globalThis.window._isAdmin = false;
globalThis.window.addEventListener = () => {};
globalThis.window.removeEventListener = () => {};
const apiCalls = [];
globalThis.fetch = async (url, opts = {}) => {
apiCalls.push({ url, method: opts.method || 'GET' });
if (url === '/api/model-endpoints') return { ok: true, json: async () => [] };
if (url === '/api/prefs') return { ok: true, json: async () => ({}) };
if (url === '/api/auth/settings') {
if (opts.method === 'POST') return { status: 403, ok: false, text: async () => 'Forbidden' };
return { ok: true, json: async () => ({ image_gen_enabled: true, image_model: 'global-model' }) };
}
if (url.startsWith('/api/prefs/')) {
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
throw new Error('Unexpected URL: ' + url);
};
const { initImageSettings } = await import('./static/js/settings/imageModels.js');
await initImageSettings();
// Check non-admin initial UI state
results.nonAdminToggleDisabled = enabledToggle.disabled;
results.nonAdminToggleTitle = enabledToggle.title;
results.nonAdminToggleChecked = enabledToggle.checked;
// Simulate non-admin attempting to toggle image generation
enabledToggle.checked = false; // user clicked switch to turn off
enabledToggle.dispatchEvent('change');
results.revertedChecked = enabledToggle.checked;
results.revertedMsgText = msg.textContent;
results.revertedMsgColor = msg.style.color;
// Simulate non-admin changing model preference
modelSel.value = 'user-selected-model';
await modelSel.dispatchEvent('change');
results.modelChangeMsg = msg.textContent;
// 3. Admin UI interaction in imageModels.js
globalThis.window._isAdmin = true;
const adminToggle = makeElement('set-imgEnabledToggle', 'input');
const adminMsg = makeElement('set-imgSettingsMsg', 'span');
elements['set-imgEnabledToggle'] = adminToggle;
elements['set-imgSettingsMsg'] = adminMsg;
let adminSavedToggle = null;
globalThis.fetch = async (url, opts = {}) => {
if (url === '/api/model-endpoints') return { ok: true, json: async () => [] };
if (url === '/api/prefs') return { ok: true, json: async () => ({}) };
if (url === '/api/auth/settings') {
if (opts.method === 'POST') {
const body = JSON.parse(opts.body);
adminSavedToggle = body.image_gen_enabled;
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
return { ok: true, json: async () => ({ image_gen_enabled: true, image_model: 'global-model' }) };
}
if (url.startsWith('/api/prefs/')) {
return { status: 200, ok: true, json: async () => ({ ok: true }) };
}
throw new Error('Unexpected URL: ' + url);
};
await initImageSettings();
results.adminToggleDisabled = adminToggle.disabled;
adminToggle.checked = false;
await adminToggle.dispatchEvent('change');
results.adminSavedToggle = adminSavedToggle;
results.adminMsg = adminMsg.textContent;
console.log(JSON.stringify(results));
"""
proc = subprocess.run(["node", "--input-type=module", "-e", js], capture_output=True, text=True, check=True)
import json
data = json.loads(proc.stdout)
# 1. Non-admin toggle-only save threw 403
assert data["toggleOnlyThrew"] is True
assert data["toggleOnlyStatus"] == 403
# 2. Non-admin UI has disabled toggle and rejects changes with warning
assert data["nonAdminToggleDisabled"] is True
assert "Admin access is required" in data["nonAdminToggleTitle"]
assert data["nonAdminToggleChecked"] is True
assert data["revertedChecked"] is True
assert "Admin access is required" in data["revertedMsgText"]
assert data["revertedMsgColor"] == "var(--red)"
# 3. Non-admin model changes succeed
assert data["modelChangeMsg"] == "Saved"
# 4. Admin UI allows toggle changes and persists to auth settings
assert data["adminToggleDisabled"] is False
assert data["adminSavedToggle"] is False
assert data["adminMsg"] == "Saved"
+1
View File
@@ -48,6 +48,7 @@ def _patch_loop_basics(monkeypatch):
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
monkeypatch.setattr("src.tool_capabilities.TOOL_APPROVAL_GATE_ENABLED", False)
def test_detects_strong_guide_only_turns():