mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-01 18:52:21 +01:00
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:
co-authored by
pewdiepie-archdaemon
Alexandre Teixeira
parent
5353241743
commit
39303f19cd
@@ -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'),
|
||||
|
||||
@@ -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"
|
||||
@@ -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 ></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']
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user