fix(editor): stabilize image workflows and editing controls

This commit is contained in:
pewdiepie-archdaemon
2026-10-30 08:46:10 +00:00
parent 8b8fc62449
commit e79c81fbb8
45 changed files with 1928 additions and 267 deletions
+4 -2
View File
@@ -29,8 +29,10 @@ def test_secondary_brush_controls_live_after_the_menu_open():
block = CONTROLS[i:j]
for marker in ('id="ge-brush-spacing"', 'id="ge-brush-smoothing"',
'id="ge-brush-blend"', 'ge-brush-pressure-row',
'id="ge-brush-flow-row"', 'id="ge-brush-softness-row"'):
'id="ge-brush-flow-row"'):
assert marker in block, marker
assert 'id="ge-brush-softness-row"' not in block
assert CONTROLS.index('id="ge-brush-softness-row"') < i
def test_brush_size_label_drops_the_redundant_word():
@@ -98,7 +100,7 @@ def test_brush_strip_fits_and_smudge_row_respects_inline_hide():
# The Opacity/Flow/Softness sliders are wound tighter so the strip fits.
assert ".ge-controls--in-options #ge-brush-section { gap: 6px; }" in CSS
# The brush controls hug their content so the sliders sit next to the caret.
assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }" in CSS
assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; flex-direction: row;" in CSS
# The smudge Strength row is hidden with an inline display:none on other
# tools; the `.ge-eraser-row { display:flex !important }` rule must not
# override that.
@@ -44,9 +44,11 @@ def test_quick_edit_shows_the_generation_message_verbatim():
assert "_isGenOfflineMsg" in EDITOR
assert "_setAiCommandStatus(_isGenOfflineMsg(_genErr) ? _genErr : `Could not generate that edit: ${_genErr}`, 'error');" in EDITOR
assert "_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');" in EDITOR
# the generateImage op maps its own fetch/HTTP failures through the classifier
assert "_generationErrorMessage({ network: true" in EDITOR
assert "_generationErrorMessage({ status: res.status, detail: _gd })" in EDITOR
# The extracted generation action keeps the shared failure classifier.
generation = (ROOT / 'static/js/editor/image-generation.js').read_text()
assert "formatError: _generationErrorMessage" in EDITOR
assert "formatError({ network: true" in generation
assert "formatError({ status: response.status" in generation
def test_sw_cache_is_bumped():
@@ -0,0 +1,121 @@
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'}
+2 -3
View File
@@ -49,10 +49,9 @@ def test_ctrl_zoom_shortcuts_drive_the_toolbar_and_override_the_browser():
def test_ctrl_alt_l_opens_the_levels_adjustment():
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
# Photoshop-style chord: reuse the Image menu row so the dialog and the
# adjustment-layer plumbing stay in one place.
# Open layer-local controls without creating/committing a new adjustment layer.
assert "e.altKey && e.code === 'KeyL'" in keyboard
assert "document.querySelector('[data-image-action=\"adj-levels\"]')" in keyboard
assert "openLayerAdjustment?.('levels');" in keyboard
def test_ctrl_alt_shift_e_stamps_the_visible_layers():
@@ -0,0 +1,770 @@
from pathlib import Path
import pytest
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def page():
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1000, "height": 700})
def serve(route):
relative = route.request.url.split('http://editor.test/', 1)[1].split('?', 1)[0]
if not relative:
route.fulfill(body='<html><body></body></html>', content_type='text/html')
else:
route.fulfill(path=str(ROOT / relative))
page.route('http://editor.test/**', serve)
page.goto('http://editor.test/')
yield page
browser.close()
def test_remove_background_sends_only_active_layer_and_preserves_others(page):
result = page.evaluate("""async () => {
const {state} = await import('/static/js/editor/state.js');
const {createApplyImageTool} = await import('/static/js/editor/ai-tool-runner.js');
function layer(id, color) {
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 4;
const ctx = canvas.getContext('2d'); ctx.fillStyle = color; ctx.fillRect(0,0,4,4);
return {id, canvas, ctx, visible:true, locks:{}};
}
const red = layer('red','red'), blue = layer('blue','blue');
red.kind = 'placed'; red.placed = {sourceName:'original'};
state.layers = [red, blue]; state.activeLayerId = red.id; state.editorOpen = true;
const beforeBlue = blue.canvas.toDataURL(); const expected = red.canvas.toDataURL().split(',')[1];
let sent, snapshots = 0;
const clear = document.createElement('canvas'); clear.width = clear.height = 4;
window.fetch = async (url, opts) => {
sent = JSON.parse(opts.body);
state.activeLayerId = blue.id; // Changing selection during the request must not change its target.
return {ok:true, json:async () => ({image:clear.toDataURL()})};
};
const run = createApplyImageTool({
flatten: () => {throw Error('Must not flatten');}, saveState: () => snapshots++,
createLayer: () => {throw Error('Must not create a merged layer');},
composite: () => {}, renderLayerPanel: () => {}, deriveBusyLabel: () => 'Removing',
getSelectedAIEndpoint: () => ({}), spinnerModule: {}, uiModule: {showToast(){}},
});
await run('/api/image/remove-bg', {}, 'BG Removed', document.createElement('button'));
return {correctInput:sent.image === expected, blueUnchanged:blue.canvas.toDataURL() === beforeBlue,
count:state.layers.length, visible:state.layers.every(l => l.visible), snapshots,
alpha:red.ctx.getImageData(0,0,1,1).data[3], kind:red.kind, placed:red.placed};
}""")
assert result == dict(correctInput=True, blueUnchanged=True, count=2, visible=True, snapshots=1, alpha=0, kind='raster', placed=None)
def test_levels_shortcut_opens_controls_without_menu_commit(page):
result = page.evaluate("""async () => {
const {state} = await import('/static/js/editor/state.js');
const {wireKeyboardShortcuts} = await import('/static/js/editor/keyboard-shortcuts.js');
state.editorOpen = true;
let selected;
wireKeyboardShortcuts({toolbar:document.createElement('div'), toolKeyMap:{},
openLayerAdjustment:type => selected = type});
const event = new KeyboardEvent('keydown', {key:'l', code:'KeyL', ctrlKey:true, altKey:true,
bubbles:true, cancelable:true});
document.dispatchEvent(event);
return {selected, prevented:event.defaultPrevented};
}""")
assert result == dict(selected='levels', prevented=True)
def test_hue_saturation_shortcut_opens_adjustment(page):
result = page.evaluate("""async () => {
const {state} = await import('/static/js/editor/state.js');
const {wireKeyboardShortcuts} = await import('/static/js/editor/keyboard-shortcuts.js');
state.editorOpen = true;
let selected;
wireKeyboardShortcuts({toolbar:document.createElement('div'), toolKeyMap:{},
openLayerAdjustment:type => selected = type});
const event = new KeyboardEvent('keydown', {key:'U', code:'KeyU', ctrlKey:true, shiftKey:true,
bubbles:true, cancelable:true});
document.dispatchEvent(event);
return {selected, prevented:event.defaultPrevented};
}""")
assert result == dict(selected='hue-saturation', prevented=True)
def test_inner_shadow_clips_inside_and_reverses_direction(page):
result = page.evaluate("""async () => {
const {renderEffects} = await import('/static/js/editor/effects.js');
const source = document.createElement('canvas'); source.width=source.height=60;
const ctx=source.getContext('2d');ctx.fillStyle='white';ctx.fillRect(15,15,30,30);
function sample(inside,x) {
const out=renderEffects(source,[{type:'drop-shadow',params:{inside,x,y:0,blur:0,opacity:1,color:'#000000'}}]);
const c=out.getContext('2d');
return {left:[...c.getImageData(16,30,1,1).data],right:[...c.getImageData(43,30,1,1).data],
outside:[...c.getImageData(47,30,1,1).data]};
}
return {right:sample(true,6),left:sample(true,-6),outer:sample(false,6)};
}""")
assert result['right']['left'] == [0,0,0,255]
assert result['right']['right'] == [255,255,255,255]
assert result['left']['right'] == [0,0,0,255]
assert result['right']['outside'][3] == 0
assert result['outer']['outside'] == [0,0,0,255]
def test_cut_placed_image_rasterizes_once_without_confirmation(page):
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
page.evaluate("""async () => {
document.body.innerHTML='<div id="toast"></div><div id="gallery-editor-container"></div>';
const editor=await import('/static/js/galleryEditor.js');
const {state}=await import('/static/js/editor/state.js');
editor.openEditor(null,null,{w:32,h:32},'Cut test');
const layer=state.layers.at(-1);
layer.ctx.fillStyle='red';layer.ctx.fillRect(0,0,32,32);
layer.kind='placed';layer.placed={sourceName:'Original image'};
document.activeElement?.blur();
document.dispatchEvent(new KeyboardEvent('keydown',{key:'x',code:'KeyX',ctrlKey:true,bubbles:true,cancelable:true}));
}""")
page.wait_for_function("""async () => {
const {state}=await import('/static/js/editor/state.js');
return !!state.internalClipboard && state.layers.at(-1).ctx.getImageData(0,0,1,1).data[3]===0;
}""")
result=page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
return {kind:state.layers.at(-1).kind,placed:state.layers.at(-1).placed,
copied:[...state.internalClipboard.getContext('2d').getImageData(0,0,1,1).data],
prompted:document.querySelector('#styled-confirm-modal')?.textContent?.includes('Rasterize layer')||false};
}""")
assert result==dict(kind='raster',placed=None,copied=[255,0,0,255],prompted=False)
def test_drop_shadow_dialog_inside_and_direction_apply(page):
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
page.add_style_tag(content=(ROOT/'static/css/13-image-editor.css').read_text())
page.add_style_tag(content=':root{--panel:#242426;--bg:#171719;--fg:#eee;--border:#454549;--red:#d47965;}body{background:var(--bg);color:var(--fg);font-family:Arial,sans-serif;}')
page.evaluate("""async () => {
document.body.innerHTML='<div id="toast"></div><div id="gallery-editor-container" style="height:680px"></div>';
const editor=await import('/static/js/galleryEditor.js');
editor.openEditor(null,null,{w:64,h:64},'Shadow test');
document.querySelector('[data-filter-action="effect-drop-shadow"]').click();
}""")
page.locator('.ge-filter-overlay select[data-key="inside"]').select_option('1')
page.locator('.ge-filter-overlay input[data-key="angle"]').evaluate("el=>{el.value='90';el.dispatchEvent(new Event('input',{bubbles:true}));}")
page.screenshot(path='/tmp/editor-shadow-inside-direction.png')
page.locator('.ge-filter-overlay [data-action="apply"]').click()
page.wait_for_function("""async () => {
const {state}=await import('/static/js/editor/state.js');
return state.layers.at(-1).effects.length===1;
}""")
result=page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {inside,x,y}=state.layers.at(-1).effects[0].params;
return {inside,x,y};
}""")
assert result==dict(inside=True,x=0,y=-7)
def test_reorder_rebuilds_effect_rows_under_their_parent(page):
result=page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {createLayerPanelRenderer}=await import('/static/js/editor/layer-panel.js');
document.body.innerHTML='<div id="ge-layers-list"></div>';
state.container=document.body;state.editorOpen=true;
function layer(id,effects=[]) {
const canvas=document.createElement('canvas');canvas.width=canvas.height=16;
return {id,name:id,kind:'raster',canvas,ctx:canvas.getContext('2d'),visible:true,
opacity:1,locks:{},effects,masks:[],adjLayers:[]};
}
const a=layer('a',[{id:'shadow-a',type:'drop-shadow',params:{}}]), b=layer('b');
state.layers=[a,b];state.activeLayerId=a.id;state.layerOffsets=new Map();state.layerGroups=[];
let options;
const renderer=createLayerPanelRenderer({composite(){},saveState(){},
dragSortModule:{enable:(id,selector,config)=>{options=config;}},uiModule:{showToast(){}}});
renderer.render();
const list=document.getElementById('ge-layers-list');
const row=id=>list.querySelector(`.ge-layer-item[data-layer-id="${id}"]`);
list.insertBefore(row('a'),row('b'));
options.onReorder([...list.querySelectorAll('.ge-layer-item[data-layer-id]')]);
return {order:state.layers.map(layer=>layer.id),excluded:options.excludeSelector,
rows:[...list.children].map(el=>el.dataset.layerId||el.dataset.effectId)};
}""")
assert result==dict(order=['b','a'],excluded='.ge-adj-sub-item',rows=['a','shadow-a','b'])
@pytest.mark.parametrize('tool', ['marquee','lasso'])
@pytest.mark.parametrize('pointer_type', ['mouse','pen','touch'])
@pytest.mark.parametrize('pan', [False,True])
def test_selections_start_outside_canvas_unless_explicitly_panning(page,tool,pointer_type,pan):
result=page.evaluate("""async ({tool,pointerType,pan}) => {
const {state}=await import('/static/js/editor/state.js');
const {wireCanvasEvents}=await import('/static/js/editor/canvas-events.js');
document.body.innerHTML='<div id="area"><canvas width="100" height="100"></canvas><button>Chrome</button></div>';
const area=document.getElementById('area');
state.mainCanvas=area.querySelector('canvas');state.tool=tool;
state.editorOpen=true;state.spacePanActive=pan;state.panX=state.panY=0;
let starts=0,moves=0,ends=0;
wireCanvasEvents({canvasArea:area,beginDraw:()=>starts++,continueDraw:()=>moves++,
endDraw:()=>ends++,updateBrushCursor(){},syncZoomControls(){}});
function pointer(type,target=area) {
target.dispatchEvent(new PointerEvent(type,{pointerId:5,pointerType,button:0,
clientX:type==='pointerdown'?10:30,clientY:type==='pointerdown'?10:30,bubbles:true,cancelable:true}));
}
pointer('pointerdown');
if(pointerType==='mouse')area.dispatchEvent(new MouseEvent('mousedown',{button:0,bubbles:true}));
pointer('pointermove');
if(pointerType==='mouse')window.dispatchEvent(new MouseEvent('mousemove'));
pointer('pointerup');
if(pointerType==='mouse')window.dispatchEvent(new MouseEvent('mouseup'));
const after={starts,moves,ends,panX:state.panX};
pointer('pointerdown',area.querySelector('button'));
return {...after,chromeStarted:starts!==after.starts};
}""",dict(tool=tool,pointerType=pointer_type,pan=pan))
assert result['starts']==(0 if pan else 1)
assert result['panX']==(20 if pan else 0)
assert not result['chromeStarted']
if not pan:
assert result['moves']==1
assert result['ends']==1
def test_marquee_drag_from_workspace_creates_clipped_selection(page):
result=page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {wireCanvasEvents}=await import('/static/js/editor/canvas-events.js');
const {createMarqueeTool}=await import('/static/js/editor/tools/marquee.js');
document.body.innerHTML='<div id="area"><canvas width="100" height="100" style="position:absolute;left:100px;top:100px;width:100px;height:100px"></canvas></div>';
const area=document.getElementById('area');state.mainCanvas=area.querySelector('canvas');
state.tool='marquee';state.imgWidth=state.imgHeight=100;state.editorOpen=true;
state.spacePanActive=false;state.panX=state.panY=0;state.wandMask=null;
const layer={id:'a'};state.layers=[layer];state.activeLayerId='a';state.layerOffsets=new Map();
const tool=createMarqueeTool({activeLayer:()=>layer,saveState(){},composite(){},drawOverlay(){},syncSelectionUi(){}});
wireCanvasEvents({canvasArea:area,beginDraw:e=>tool.begin(e),continueDraw:e=>tool.drag(e),
endDraw:e=>tool.end(e),updateBrushCursor(){},syncZoomControls(){}});
area.dispatchEvent(new PointerEvent('pointerdown',{pointerType:'mouse',pointerId:1,button:0,clientX:80,clientY:80,bubbles:true}));
area.dispatchEvent(new MouseEvent('mousedown',{button:0,clientX:80,clientY:80,bubbles:true}));
window.dispatchEvent(new MouseEvent('mousemove',{clientX:150,clientY:150}));
window.dispatchEvent(new MouseEvent('mouseup',{clientX:150,clientY:150}));
const ctx=state.wandMask.getContext('2d');
return {inside:ctx.getImageData(0,0,1,1).data[3],outside:ctx.getImageData(70,70,1,1).data[3],pan:state.panX};
}""")
assert result==dict(inside=255,outside=0,pan=0)
def test_save_menu_remains_clickable_after_png_export_and_window_raise(page):
page.add_style_tag(content='''
.modal {position:fixed;inset:0;z-index:20000;background:#222;}
#ge-save-menu-btn {position:absolute;right:20px;top:20px;}
.ge-save-menu {position:fixed;z-index:10005;width:200px;background:#333;}
.ge-save-menu[hidden] {display:none;}
.ge-save-menu button {display:block;width:100%;height:30px;}
''')
page.evaluate("""async () => {
const {wireTopbar}=await import('/static/js/editor/wire-topbar.js');
document.body.innerHTML='<div id="gallery-modal" class="modal"><div id="gallery-editor-container"><div class="ge-topbar"><div class="ge-save-wrap"><button id="ge-save-menu-btn">Save</button><div id="ge-save-menu" class="ge-save-menu" hidden><button id="ge-save">Save image</button><button id="ge-export-png">Save as PNG</button></div></div></div></div></div>';
window.pngExports=0;window.saves=0;
document.getElementById('ge-save').addEventListener('click',()=>window.saves++);
wireTopbar({registerDocClickAway(){},exportPNGFile:()=>window.pngExports++});
}""")
page.locator('#ge-save-menu-btn').click()
page.locator('#ge-export-png').click(timeout=2000)
page.evaluate("document.getElementById('gallery-modal').style.zIndex='50000'")
page.locator('#ge-save-menu-btn').click()
page.locator('#ge-save').click(timeout=2000)
assert page.evaluate('({png:window.pngExports,saves:window.saves})') == dict(png=1,saves=1)
def test_submenu_hover_aligns_to_row_and_flips_at_right_edge(page):
page.add_style_tag(content='''
#menu {position:absolute; left:780px; top:100px; width:180px;}
button {display:block; height:35px; width:180px;}
.ge-submenu {display:none; position:absolute; width:160px; height:120px; top:0; left:100%;}
.ge-menu-parent:hover + .ge-submenu {display:block;}
''')
page.evaluate("""async () => {
document.body.innerHTML = '<div id="menu"><button>First</button><button>Second</button>'
+ '<button class="ge-menu-parent">Blur &gt;</button><div class="ge-submenu">Gaussian</div></div>';
const {wireSubmenuToggles} = await import('/static/js/editor/submenu-toggles.js');
wireSubmenuToggles(document.getElementById('menu'));
}""")
page.locator('.ge-menu-parent').hover()
row = page.locator('.ge-menu-parent').bounding_box()
panel = page.locator('.ge-submenu').bounding_box()
assert abs(panel['y'] - row['y']) <= 1
assert panel['x'] + panel['width'] <= row['x'] + 1
page.screenshot(path='/tmp/editor-submenu-alignment.png')
@pytest.mark.parametrize('prompt', ['remove bg', 'remove background', 'remove the background from the selected layer'])
def test_quick_edit_planner_uses_background_tool(prompt):
from routes.editor_agent_routes import _plan_special
steps, _, _ = _plan_special(prompt, {}, True)
assert steps == [{'op': 'ai.removeBackground', 'params': {}}]
def test_quick_edit_background_removal_precedes_creative_routing():
source = (ROOT / 'static/js/galleryEditor.js').read_text()
local = source.split('const _executeQuickEditLocal = async (prompt) => {', 1)[1]
assert local.index('if (_looksBackgroundRemoval(p))') < local.index('if (_looksRecolor(p))')
@pytest.mark.parametrize('run', [False, True])
def test_background_tool_suggestion_prepares_command_and_enter_submits(page, run):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
picker = 'const pickSuggestion = ' + source.split('const pickSuggestion = ', 1)[1].split(" wrap.addEventListener('pointerdown'", 1)[0]
assert "runPrompt: id === 'rembg' ? 'remove background' : null" in source
result = page.evaluate("""({picker, run}) => {
const suggestionItems = [{toolId:'rembg', label:'Bg Remove', runPrompt:'remove background'}];
const input = document.createElement('input'); input.value = 'bg';
let submitted = 0, selected = null, clearSynced = false;
const form = {requestSubmit: () => submitted++};
const _clickToolButton = id => selected = id;
const _setAiCommandStatus = () => {};
const hideSuggestions = () => {};
const syncClearButton = () => clearSynced = true;
eval(picker + '; pickSuggestion(0, run);');
return {prompt:input.value, submitted, selected, clearSynced};
}""", {'picker':picker, 'run':run})
assert result == dict(prompt='remove background', submitted=int(run), selected='rembg', clearSynced=True)
@pytest.mark.parametrize('tool', ['dodge', 'burn'])
def test_retouch_invalidates_adjustment_preview_and_stops_before_undo(page, tool):
result = page.evaluate("""async tool => {
const {state} = await import('/static/js/editor/state.js');
const {createStrokePipeline} = await import('/static/js/editor/stroke-pipeline.js');
const {renderLayerWithAdjLayers} = await import('/static/js/editor/fx/pixel-pass.js');
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 30;
const ctx = canvas.getContext('2d'); ctx.fillStyle = 'rgb(100,100,100)'; ctx.fillRect(0,0,30,30);
const layer = {id:'target', canvas, ctx, adjLayers:[{id:'adj', type:'brightness-contrast',
params:{brightness:1.1,contrast:1}, visible:true, opacity:1}]};
state.layers = [layer]; state.layerOffsets = new Map(); state.tool = tool;
Object.assign(state,{brushSize:12, brushOpacity:100, brushFlow:100, brushSoftness:0,
brushSpacing:20, brushSmoothing:0, dodgeBurnIntensity:50});
const original = ctx.getImageData(0,0,30,30);
const pixel = () => renderLayerWithAdjLayers(layer).getContext('2d').getImageData(15,15,1,1).data[0];
const before = pixel();
const pipeline = createStrokePipeline({activeLayer:()=>layer, getActiveMaskLayer:()=>null, composite:()=>{}});
pipeline.beginStroke({x:15,y:15,pressure:1},tool);
const during = pixel();
pipeline.endStroke(null); // Finish before Undo restores the saved pixels.
ctx.putImageData(original,0,0); layer._adjFinalKey = null; layer._adjCacheKey = null;
pipeline.endStroke({x:28,y:28,pressure:1}); // A late pointer-up cannot replay this stroke.
return {before,during,after:pixel(),corner:ctx.getImageData(28,28,1,1).data[0]};
}""", tool)
assert result['during'] > result['before'] if tool == 'dodge' else result['during'] < result['before']
assert result['after'] == result['before']
assert result['corner'] == 100
def test_undo_finishes_active_stroke_before_restoring_history():
source = (ROOT / 'static/js/galleryEditor.js').read_text()
undo = source.split('function undo() {', 1)[1].split('function redo()', 1)[0]
assert undo.index('if (state.drawing) _strokeTool.tryEnd();') < undo.index('state.undoStack.pop()')
@pytest.mark.parametrize('width,height', [(1000,700), (390,844)])
def test_filter_submenu_rows_have_equal_heights_with_and_without_icons(page, width, height):
page.set_viewport_size({'width':width,'height':height})
page.add_style_tag(content=(ROOT / 'static/css/13-image-editor.css').read_text())
page.evaluate("""() => {
document.body.innerHTML = '<div class="ge-submenu" style="display:block;left:8px;top:8px;">'
+ '<button class="dropdown-item-compact"><span>Plain</span></button>'
+ '<button class="dropdown-item-compact"><svg width="24" height="24"><circle cx="12" cy="12" r="8"/></svg><span>Icon</span></button>'
+ '<button class="dropdown-item-compact"><span>Longer label</span><svg width="10" height="10"></svg></button></div>';
}""")
heights = page.locator('.dropdown-item-compact').evaluate_all('(rows)=>rows.map(row=>row.getBoundingClientRect().height)')
assert heights == ([28]*3 if width > 820 else [32]*3)
page.screenshot(path=f'/tmp/editor-filter-row-heights-{width}.png')
def test_ai_settings_escape_closes_only_the_dialog(page):
page.route('**/api/auth/settings', lambda route: route.fulfill(json={'image_model':'', 'image_edit_model':''}))
result = page.evaluate("""async () => {
const {createAiSettings} = await import('/static/js/editor/ai-settings.js');
const container = document.createElement('div'); document.body.append(container);
const settings = createAiSettings({container}); settings.open();
let reachedEditor = false;
document.addEventListener('keydown', () => reachedEditor = true, {capture:true, once:true});
document.dispatchEvent(new KeyboardEvent('keydown', {key:'Escape', bubbles:true, cancelable:true}));
const result = {closed:settings.element.hidden, reachedEditor}; settings.destroy();
return result;
}""")
assert result == dict(closed=True, reachedEditor=False)
def test_image_model_settings_sync_both_directions_and_survive_reopening(page):
saved = {'image_model':'openai/gpt-5-image', 'image_edit_model':'', 'image_quality':'medium', 'image_gen_enabled':True}
writes = []
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))
page.route('**/api/auth/settings', settings_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_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']}
+2 -2
View File
@@ -143,8 +143,8 @@ def test_crop_can_start_outside_the_canvas_without_panning():
# image (so it can be pulled out past the edge) instead of the canvas-area
# pan grabbing the pointer and switching to hand mode.
CE = (ROOT / "static/js/editor/canvas-events.js").read_text(encoding="utf-8")
assert "if (state.tool !== 'lasso' && state.tool !== 'crop') return;" in CE
assert "(state.tool === 'lasso' || state.tool === 'crop') && !directPan" in CE
assert "['marquee', 'lasso', 'crop'].includes(state.tool)" in CE
assert "if (canStartOutside() && !directPan)" in CE
# Pressing editor chrome inside the area (e.g. the crop Apply panel) must
# not start a fresh crop.
assert ".ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup" in CE
+5 -6
View File
@@ -32,13 +32,12 @@ def test_preflight_module_has_the_four_setup_steps():
assert "required ready" in src
def test_preflight_is_wired_into_the_editor():
def test_editor_uses_ai_settings_without_redundant_setup():
editor = _read("static/js/galleryEditor.js")
assert "import { createPreflight } from './editor/preflight.js';" in editor
assert "createPreflight({ canvasArea })" in editor
assert "_preflight?.maybeOpen();" in editor
assert "setupBtn.id = 'ge-setup-btn';" in editor
assert "_preflight?.destroy();" in editor
assert "createPreflight" not in editor
assert "setupBtn.id = 'ge-setup-btn';" not in editor
assert "createAiSettings({ container })" in editor
assert "aiBtn.addEventListener('click', () => _aiSettings?.open());" in editor
def test_ai_models_exposes_status_for_the_checklist():
+36 -1
View File
@@ -49,7 +49,42 @@ def test_generate_route_forwards_the_picked_model():
assert 'model_choice = str((body or {}).get("model") or "").strip()' in route
assert 'payload["model"] = model_choice' in route
assert "content = _json.dumps(payload)" in route
assert "localStorage.getItem('ge-ai-command-model')" in JS
operation = JS.split("'ai.generateImage':", 1)[1].split("\n", 1)[0]
generation = (ROOT / 'static/js/editor/image-generation.js').read_text()
assert '_generateImage(p || {})' in operation
assert "_use_image_settings: true" in generation
assert "document.getElementById('ge-ai-model')" not in operation
assert "_getSelectedQuickEditModel()" not in operation
assert "localStorage.getItem" not in operation
def test_ai_settings_separates_generation_from_editing_without_setup():
settings = (ROOT / "static/js/editor/ai-settings.js").read_text()
assert 'id="ge-ai-model"' in settings
assert 'id="ge-ai-command-model"' in settings
assert "Image generation model" in settings
assert "createPreflight" not in JS
assert "ge-setup-btn';" not in JS.split("const aiBtn", 1)[0]
def test_generate_uses_live_picker_and_auto_edit_fallback():
import subprocess
module = (ROOT / "static/js/editor/ai-models.js").as_uri()
script = """
import assert from 'node:assert/strict';
const {resolveQuickEditModel} = await import(MODULE);
const selected = 'https://openrouter.ai/api/v1::openai/gpt-5-image';
const option = {value: selected, textContent: 'GPT-5 Image'};
const picker = {value: selected, selectedIndex: 0, options: [option]};
const fallback = {value: '', options: [option]};
globalThis.document = {getElementById: id => id === 'ge-ai-command-model' ? picker : fallback};
globalThis.localStorage = {getItem: () => {throw new Error('must not read stale storage');}};
assert.equal(resolveQuickEditModel().model, 'openai/gpt-5-image');
picker.value = '';
assert.equal(resolveQuickEditModel().model, '');
""".replace("MODULE", repr(module))
subprocess.run(["node", "--input-type=module", "-e", script], check=True)
def test_cloud_image_model_is_resolved_before_the_local_fallback():
+7 -6
View File
@@ -52,7 +52,7 @@ def test_quick_edit_flip_and_rotate_accept_numeric_degrees():
def test_quick_edit_transforms_do_not_add_a_second_undo_snapshot():
start = EDITOR.index("if (/\\brotate\\b.*\\b180\\b")
end = EDITOR.index("if (_looksBackgroundRemoval(p)) {", start)
end = EDITOR.index("if (!/\\b(text|title|caption|font|heading|subtitle)", start)
transform_block = EDITOR[start:end]
assert "_saveState('Rotate" not in transform_block
assert "_saveState('Flip" not in transform_block
@@ -372,9 +372,10 @@ def test_quick_edit_has_a_model_picker():
assert "function _getSelectedQuickEditModel()" in EDITOR
assert "getModel: () => _getSelectedQuickEditModel()," in EDITOR
# Populated from the provider list and persisted per user.
assert "aiCommandSelect.innerHTML = '<option value=\"\">Auto (default)</option>';" in models
assert "localStorage.setItem('ge-ai-command-model', aiCommandSelect.value)" in models
assert "localStorage.getItem('ge-ai-command-model')" in models
assert "aiCommandSelect.innerHTML = '<option value=\"\">Same as generation</option>';" in models
assert "localStorage.setItem('ge-ai-command-model'" not in models
assert "localStorage.getItem('ge-ai-command-model')" not in models
assert 'saveImagePreferences({ [key]: model })' in AI_SETTINGS
# Styled.
assert ".ge-ai-settings-card" in STYLE
assert ".ge-ai-command-model" in STYLE
@@ -698,7 +699,7 @@ def test_quick_edit_shows_a_real_progress_bar():
assert "fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}`" in EDITOR
# The edit request carries the request_id the server keys progress on.
assert "const requestId = 'qe-' + Date.now().toString(36)" in EDITOR
assert "const body = JSON.stringify({ image, request_id: requestId, ...payload });" in EDITOR
assert "const body = JSON.stringify({ image, width: flat.width, height: flat.height, request_id: requestId, ...payload });" in EDITOR
assert "_startAiProgress(requestId);" in EDITOR
assert "_stopAiProgress();" in EDITOR
# Styling exists.
@@ -718,7 +719,7 @@ def test_generation_503_is_handled_gracefully():
# (returns 503). Surface a clear message and do not retry immediately.
assert "if (status === 503) return _GEN_DISABLED_MSG;" in EDITOR
assert "_GEN_DISABLED_MSG = 'Image generation is switched off on the server at the moment.'" in EDITOR
assert "window.__geGenerationDisabled" in EDITOR
assert "formatError: _generationErrorMessage" in EDITOR
def test_sending_a_prompt_returns_focus_to_the_document():
+73 -8
View File
@@ -5,6 +5,7 @@ import json
from types import SimpleNamespace
import httpx
import pytest
from PIL import Image
import routes.gallery_routes as gallery
@@ -16,7 +17,10 @@ def png(image):
return base64.b64encode(output.getvalue()).decode()
def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog):
@pytest.mark.parametrize('model', ['openai/gpt-5-image', 'openai/gpt-image-2'])
@pytest.mark.parametrize('saved_settings', [False, True])
@pytest.mark.parametrize('full_frame', [False, True])
def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog, model, saved_settings, full_frame):
import logging
caplog.set_level(logging.DEBUG)
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', api_key='test')
@@ -29,36 +33,97 @@ def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog):
monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice')
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *args: endpoint)
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, ''))
monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {'image_model':'wrong-generation-model', 'image_edit_model':model})
def resolve_model(selected, **kwargs):
assert selected == model
assert kwargs['owner'] == 'alice'
return endpoint.base_url + '/chat/completions', model, {}
monkeypatch.setattr('src.ai_interaction._resolve_model', resolve_model)
requests = []
def respond(request):
requests.append(request)
assert str(request.url) == endpoint.base_url + '/images'
assert str(request.url) == endpoint.base_url + ('/chat/completions' if model == 'openai/gpt-5-image' else '/images')
payload = json.loads(request.content)
assert payload['model'] == 'openai/gpt-5-image'
assert len(payload['input_references']) == 2
assert payload['model'] == model
if model == 'openai/gpt-5-image':
assert len(payload['messages'][0]['content']) == 3
else:
assert len(payload['input_references']) == 2
assert request.headers['authorization'] == 'Bearer test'
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (2, 1), 'blue'))}]})
image = png(Image.new('RGB', (4, 4) if full_frame else (2, 1), 'blue'))
if model == 'openai/gpt-5-image':
return httpx.Response(200, json={'choices':[{'message':{'images':[{'image_url':{'url':'data:image/png;base64,'+image}}]}}]})
return httpx.Response(200, json={'data': [{'b64_json': image}]})
client = httpx.AsyncClient
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: client(transport=httpx.MockTransport(respond), **kwargs))
mask = Image.new('L', (2, 1))
if full_frame:
mask.paste(255, (0,0,2,1))
mask.putpixel((1, 0), 255)
class Request:
async def json(self):
return {'_endpoint': endpoint.base_url, '_model': 'openai/gpt-5-image',
return {'_endpoint': endpoint.base_url, '_model': 'stale-model' if saved_settings else model,
'_use_image_settings':saved_settings,
'image': png(Image.new('RGB', (2, 1), 'red')), 'mask': png(mask), 'prompt': 'private-cloud-prompt'}
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
result = asyncio.run(route.endpoint(Request()))
output = Image.open(io.BytesIO(base64.b64decode(result['image'])))
assert output.getpixel((0, 0)) == (255, 0, 0, 255)
assert output.getpixel((1, 0)) == (0, 0, 255, 255)
if full_frame:
assert output.size == (4,4)
assert result['full_frame'] is True
else:
assert output.getpixel((0, 0)) == (255, 0, 0, 255)
assert output.getpixel((1, 0)) == (0, 0, 255, 255)
assert len(requests) == 1
assert 'private-cloud-prompt' not in caplog.text
def test_partial_image_edit_preserves_ratio_and_unmasked_pixels():
source = Image.new('RGBA',(4,4),'red')
generated = Image.new('RGBA',(4,2),'blue')
mask = Image.new('L',(4,4),255)
mask.putpixel((0,0),0)
result, full = gallery._compose_cloud_image_edit(source,generated,mask)
assert full is False
assert result.size == (4,4)
assert result.getpixel((2,0)) == source.getpixel((2,0))
assert result.getpixel((2,3)) == source.getpixel((2,3))
assert result.getpixel((2,1)) == (0,0,255,255)
def test_quick_edit_inpaint_requests_source_ratio_without_width_height_fields(monkeypatch):
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1',api_key='test')
monkeypatch.setattr(gallery,'SessionLocal',lambda:SimpleNamespace(close=lambda:None))
monkeypatch.setattr(gallery,'require_privilege',lambda *args:'alice')
monkeypatch.setattr(gallery,'_visible_image_endpoint_for_base',lambda *args:endpoint)
monkeypatch.setattr('src.url_safety.check_outbound_url',lambda *args,**kwargs:(True,''))
sent = []
original = httpx.AsyncClient
def respond(request):
payload = json.loads(request.content)
sent.append(payload)
return httpx.Response(200,json={'data':[{'b64_json':png(Image.new('RGB',(1536,864),'blue'))}]})
monkeypatch.setattr(httpx,'AsyncClient',lambda **kwargs:original(transport=httpx.MockTransport(respond),**kwargs))
class Request:
async def json(self):
return {'_endpoint':endpoint.base_url,'_model':'openai/gpt-image-2','prompt':'change the sky',
'image':png(Image.new('RGB',(1280,720),'red')),'mask':png(Image.new('L',(1280,720),255))}
route = next(route for route in gallery.setup_gallery_routes().routes if route.path == '/api/image/inpaint')
result = asyncio.run(route.endpoint(Request()))
assert sent[0]['aspect_ratio'] == '16:9'
assert 'size' not in sent[0]
image = Image.open(io.BytesIO(base64.b64decode(result['image'])))
assert image.size == (1536,864)
def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', owner='alice',
model_type='llm', cached_models='["openai/gpt-5-image", "text-model"]', pinned_models=None)
+67 -10
View File
@@ -23,7 +23,7 @@ def test_native_generation_uses_configured_model_and_multiline_prompt(image_tran
json.dumps({'prompt': prompt}), session_id='fixture', owner='alice'))
assert 'error' not in result
assert seen == [('openai/gpt-5-image', 'alice')]
assert json.loads(requests[0].content)['prompt'] == prompt
assert json.loads(requests[0].content)['messages'][0]['content'] == prompt
assert result['image_id']
@@ -87,13 +87,17 @@ def image_transport(monkeypatch, tmp_path):
original_client = httpx.AsyncClient
requests = []
def configure(base, status=200, body=None):
def configure(base, status=200, body=None, model='openai/gpt-5-image'):
monkeypatch.setattr(ai_interaction, '_resolve_model', lambda *args, **kwargs: (
base + '/chat/completions', 'openai/gpt-5-image', {'Authorization': 'Bearer test'},
base + '/chat/completions', model, {'Authorization': 'Bearer test'},
))
def handle(request):
requests.append(request)
if body is None and 'openrouter.ai' in base and request.url.path.endswith('/chat/completions'):
return httpx.Response(status, json={'choices': [{'message': {'images': [{
'image_url': {'url': 'data:image/png;base64,' + base64.b64encode(source.read_bytes()).decode()},
}]}}]})
return httpx.Response(status, json=body if body is not None else {
'data': [{'b64_json': base64.b64encode(source.read_bytes()).decode()}],
})
@@ -130,12 +134,13 @@ def test_image_provider_protocol(image_transport, editing, base):
assert request.headers['authorization'] == 'Bearer test'
if 'openrouter.ai' in base:
assert len(requests) == 1 # No unsupported local progress/fallback probes.
assert str(request.url) == base + '/images'
assert str(request.url) == base + '/chat/completions'
payload = json.loads(request.content)
assert payload['n'] == 1
assert payload['model'] == 'openai/gpt-5-image'
assert payload['modalities'] == ['image', 'text']
assert 'prompt' not in payload
if editing:
assert payload['size'] == '1536x1024'
reference = payload['input_references'][0]['image_url']['url']
reference = payload['messages'][0]['content'][1]['image_url']['url']
assert reference.startswith('data:image/png;base64,')
assert base64.b64decode(reference.split(',', 1)[1]) == source.read_bytes()
assert 'request_id' not in payload
@@ -181,13 +186,65 @@ def test_edit_dimensions_respect_exif_and_explicit_size(image_transport, explici
exif = Image.Exif()
exif[274] = 6
Image.new('RGB', (120, 80)).save(source, exif=exif)
configure('https://openrouter.ai/api/v1')
configure('https://openrouter.ai/api/v1', model='openai/gpt-image-1')
result = asyncio.run(ai_interaction.do_edit_image(
'Edit', str(source), model_spec='openai/gpt-5-image',
'Edit', str(source), model_spec='openai/gpt-image-1',
size='1024x1024' if explicit else 'auto',
))
assert 'error' not in result
assert json.loads(requests[0].content)['size'] == ('1024x1024' if explicit else '1024x1536')
assert json.loads(requests[0].content)['aspect_ratio'] == ('1:1' if explicit else '2:3')
def test_native_openrouter_image_model_keeps_dedicated_image_api(image_transport):
configure, source, requests, rows = image_transport
configure('https://openrouter.ai/api/v1', model='openai/gpt-image-1')
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-image-1', incognito=True))
assert 'error' not in result
assert str(requests[0].url).endswith('/api/v1/images')
assert json.loads(requests[0].content)['model'] == 'openai/gpt-image-1'
@pytest.mark.parametrize('editing', [False, True])
def test_http_200_provider_deprecation_is_not_reported_as_empty_image(image_transport, editing):
configure, source, requests, rows = image_transport
configure('https://openrouter.ai/api/v1', body={'choices': [{
'finish_reason': 'error', 'message': {'content': None},
'error': {'code': 502, 'message': 'The model gpt-image-1 has been deprecated'},
}]})
if editing:
result = asyncio.run(ai_interaction.do_edit_image('Edit', str(source), model_spec='openai/gpt-5-image'))
else:
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-5-image'))
assert 'deprecated' in result['error']
assert 'openai/gpt-5-image' in result['error']
assert 'AI settings' in result['error']
assert len(requests) == 1
assert not rows
def test_empty_image_response_identifies_the_selected_model(image_transport):
configure, source, requests, rows = image_transport
configure('https://openrouter.ai/api/v1', body={'choices': [{'message': {'content': 'No image.'}}]})
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-5-image'))
assert "'openai/gpt-5-image' returned no image" in result['error']
assert 'AI settings' in result['error']
assert len(requests) == 1
@pytest.mark.parametrize('editing', [False,True])
def test_openrouter_1280_by_720_requests_16_by_9_not_square(image_transport, editing):
configure, source, requests, rows = image_transport
Image.new('RGB',(1280,720),'red').save(source)
configure('https://openrouter.ai/api/v1',model='openai/gpt-image-2')
if editing:
result = asyncio.run(ai_interaction.do_edit_image('Change the sky',str(source),model_spec='openai/gpt-image-2',incognito=True))
else:
result = asyncio.run(ai_interaction.do_generate_image(json.dumps({
'prompt':'a landscape','model':'openai/gpt-image-2','size':'1280x720'}),incognito=True))
assert 'error' not in result
body = json.loads(requests[0].content)
assert body['aspect_ratio'] == '16:9'
assert 'size' not in body
def test_unreadable_image_does_not_call_provider(image_transport):