mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 16:22:20 +01:00
Merge current dev and refresh PR #6741 from the 7011 integration
This commit is contained in:
@@ -10,19 +10,22 @@ async function canvasPoint(page, xRatio, yRatio) {
|
||||
test('brush presets and shared stroke controls remain reusable', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await selectTool(page, 'brush');
|
||||
// Spacing / Smoothing / Blend and the pen-pressure toggles now live behind
|
||||
// the brush caret ("v") in the options strip instead of the retired "...".
|
||||
await page.locator('#ge-brush-more').click();
|
||||
await expect(page.locator('#ge-brush-spacing')).toBeVisible();
|
||||
await page.locator('#ge-brush-spacing').fill('28');
|
||||
await page.locator('#ge-brush-smoothing').fill('52');
|
||||
await page.locator('#ge-brush-blend').selectOption('multiply');
|
||||
// The pen-pressure and preset rows live behind the options-bar overflow ('...').
|
||||
await page.locator('#ge-options-more').click();
|
||||
await page.locator('.ge-pressure-option').filter({ hasText: 'Opacity' }).locator('.toggle-slider').click();
|
||||
await expect(page.locator('#ge-pressure-opacity')).toBeChecked();
|
||||
await page.locator('.ge-pressure-option').filter({ hasText: 'Flow' }).locator('.toggle-slider').click();
|
||||
await expect(page.locator('#ge-pressure-flow')).not.toBeChecked();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
page.once('dialog', dialog => dialog.accept('My Detail Brush'));
|
||||
await page.locator('#ge-brush-preset-save').click();
|
||||
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(1);
|
||||
|
||||
await page.locator('#ge-brush-more').click();
|
||||
await page.locator('#ge-brush-spacing').fill('5');
|
||||
await page.locator('#ge-brush-preset').selectOption({ label: 'My Detail Brush' });
|
||||
await expect(page.locator('#ge-brush-spacing')).toHaveValue('28');
|
||||
@@ -36,8 +39,10 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page }
|
||||
await openBlankEditor(page);
|
||||
await selectTool(page, 'brush');
|
||||
await page.locator('.ge-size-slider').fill('700');
|
||||
await page.locator('#ge-brush-more').click();
|
||||
await page.locator('#ge-brush-spacing').fill('8');
|
||||
await page.locator('#ge-brush-smoothing').fill('65');
|
||||
await page.keyboard.press('Escape');
|
||||
const activeLayerId = (await editorState(page)).activeLayerId;
|
||||
const thumb = page.locator(`.ge-layer-item[data-layer-id="${activeLayerId}"] .ge-layer-inline-thumb`);
|
||||
const thumbBefore = await thumb.evaluate(canvas => canvas.toDataURL());
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
// Quick Edit benchmark v2 grader (gpt-5.4 vision). Reads the runner output
|
||||
// (quickedit-bench.json) plus the saved before/after PNGs, scores each case
|
||||
// 0-3, and writes quickedit-bench-grades.json with per-case scores and
|
||||
// per-category averages. If an eye-scores JSON is provided (id->score), it
|
||||
// merges the human (eye) score, averages eye+gpt, and flags disagreements.
|
||||
//
|
||||
// Usage:
|
||||
// OUT=/tmp/opencode/qe-bench \
|
||||
// EYE=/path/eye-scores.json \
|
||||
// node quickedit-bench-grade.js
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const https = require('node:https');
|
||||
|
||||
const OUT = process.env.OUT || '/tmp/opencode/qe-bench';
|
||||
const MANIFEST = process.env.MANIFEST || path.join(OUT, 'quickedit-bench.json');
|
||||
const EYE = process.env.EYE || '';
|
||||
const MODEL = process.env.GRADE_MODEL || 'gpt-5.4';
|
||||
const KEY_FILE = process.env.OPENAI_KEY_FILE || '/home/pewds/.config/tiger/openai_user_20261009.key';
|
||||
|
||||
function getKey() {
|
||||
if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY.trim();
|
||||
return fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
}
|
||||
const KEY = getKey();
|
||||
const URL = 'https://api.openai.com/v1/chat/completions';
|
||||
function b64(p) { return fs.readFileSync(p).toString('base64'); }
|
||||
|
||||
function call(messages) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify({ model: MODEL, messages, max_completion_tokens: 2000 });
|
||||
const req = https.request(URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${KEY}`, 'Content-Length': Buffer.byteLength(body) },
|
||||
}, (res) => {
|
||||
let d = ''; res.on('data', (c) => (d += c));
|
||||
res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(new Error('bad json: ' + d.slice(0, 300))); } });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(body); req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function rubric(prompt) {
|
||||
return [
|
||||
'You are a STRICT photo-editing grader. You are given the BEFORE image and the AFTER image.',
|
||||
`The user asked: "${prompt}".`,
|
||||
'Score the AFTER versus the BEFORE on an integer 0-3:',
|
||||
'0 = worse than the original, or broken/ugly/artifacts; 1 = did something but weak, irrelevant, or barely visible; 2 = decent, clearly visible and natural; 3 = what a good retoucher would produce.',
|
||||
'Judge harshly. If a face/eyes/lips/hair are smeared, distorted, blown out, plastic, or lost natural texture, score 0. If highlights are clipped or the image is washed out, cap at 1.',
|
||||
'Reply with ONLY JSON: {"score": <int>, "worse": <bool>, "artifacts": "<short>", "reason": "<short>"}.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
|
||||
const eye = EYE && fs.existsSync(EYE) ? JSON.parse(fs.readFileSync(EYE, 'utf8')) : {};
|
||||
const out = [];
|
||||
for (const c of manifest.results) {
|
||||
const before = path.join(OUT, c.tag + '-before.png');
|
||||
const after = path.join(OUT, c.tag + '-after.png');
|
||||
let json = null, raw = null;
|
||||
if (fs.existsSync(before) && fs.existsSync(after)) {
|
||||
const content = [
|
||||
{ type: 'text', text: rubric(c.prompt) },
|
||||
{ type: 'text', text: 'BEFORE:' },
|
||||
{ type: 'image_url', image_url: { url: 'data:image/png;base64,' + b64(before) } },
|
||||
{ type: 'text', text: 'AFTER:' },
|
||||
{ type: 'image_url', image_url: { url: 'data:image/png;base64,' + b64(after) } },
|
||||
];
|
||||
for (let attempt = 0; attempt < 3 && !json; attempt++) {
|
||||
try {
|
||||
const res = await call([{ role: 'user', content }]);
|
||||
raw = res.choices && res.choices[0] && res.choices[0].message && res.choices[0].message.content;
|
||||
if (!raw && res.error) raw = 'API error: ' + JSON.stringify(res.error);
|
||||
const m = String(raw || '').match(/\{[\s\S]*\}/);
|
||||
if (m) { try { json = JSON.parse(m[0]); } catch (_) {} }
|
||||
} catch (e) { raw = String(e.message || e); }
|
||||
}
|
||||
} else {
|
||||
raw = 'missing before/after images';
|
||||
}
|
||||
const gpt = json && typeof json.score === 'number' ? json.score : null;
|
||||
const eyeScore = typeof eye[c.id] === 'number' ? eye[c.id] : null;
|
||||
const combined = (gpt != null && eyeScore != null) ? (gpt + eyeScore) / 2 : (gpt != null ? gpt : eyeScore);
|
||||
out.push({ id: c.id, cat: c.cat, prompt: c.prompt, ms: c.ms, ok: c.ok, gpt, eye: eyeScore, avg: combined, disagree: (gpt != null && eyeScore != null && Math.abs(gpt - eyeScore) >= 2), artifacts: json && json.artifacts, reason: json && json.reason });
|
||||
console.log(`GRADE #${c.id} [${c.cat}] gpt=${gpt} eye=${eyeScore} avg=${combined} ${c.prompt}`);
|
||||
}
|
||||
|
||||
const byCat = {};
|
||||
for (const r of out) {
|
||||
if (typeof r.avg !== 'number') continue;
|
||||
(byCat[r.cat] = byCat[r.cat] || []).push(r.avg);
|
||||
}
|
||||
const cats = {};
|
||||
for (const [k, v] of Object.entries(byCat)) cats[k] = +(v.reduce((a, b) => a + b, 0) / v.length).toFixed(2);
|
||||
const scored = out.filter(r => typeof r.avg === 'number');
|
||||
const overall = scored.length ? +(scored.reduce((a, b) => a + b.avg, 0) / scored.length).toFixed(2) : null;
|
||||
const disagreements = out.filter(r => r.disagree).map(r => r.id);
|
||||
|
||||
fs.writeFileSync(path.join(OUT, 'quickedit-bench-grades.json'), JSON.stringify({ model: MODEL, overall, byCat, disagreements, results: out }, null, 2));
|
||||
console.log(`BENCH_GRADES overall=${overall}/3 byCat=${JSON.stringify(cats)} disagreements=${JSON.stringify(disagreements)} -> ${path.join(OUT, 'quickedit-bench-grades.json')}`);
|
||||
})().catch((e) => { console.error('FAIL', e); process.exit(1); });
|
||||
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env node
|
||||
// Quick Edit benchmark v2 runner (standalone; not a Playwright spec).
|
||||
//
|
||||
// For each fixed prompt: import a fixed photo, run the editor agent, and
|
||||
// record the executed op trace, latency (ms), undo-group count (history.mark
|
||||
// delta), new layers, and before/after screenshots. Grading (eye + gpt-5.4)
|
||||
// happens separately on the saved images (see quickedit-bench-grade.js).
|
||||
//
|
||||
// Usage:
|
||||
// BASE_URL=http://127.0.0.1:7011 SESSION_FILE=/path/sessions.json \
|
||||
// NODE_PATH=/path/node_modules node quickedit-bench.js
|
||||
//
|
||||
// CATS=structural,looks limit to categories
|
||||
// PROMPTS=1,2,3 limit to ids
|
||||
// OUT=/tmp/opencode/qe-bench
|
||||
// PHOTOS=/tmp/opencode where the fixed jpg fixtures live
|
||||
// HEADFUL=1
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('@playwright/test');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:7011';
|
||||
const OUT = process.env.OUT || '/tmp/opencode/qe-bench';
|
||||
const PHOTOS = process.env.PHOTOS || '/tmp/opencode';
|
||||
const SESSION_FILE = process.env.SESSION_FILE || '/home/pewds/odysseus-cookbook-fresh/data/sessions.json';
|
||||
|
||||
// photo fixtures (in PHOTOS/): real-person.jpg pic-64.jpg pic-1015.jpg
|
||||
// pic-1018.jpg real-sky.jpg real-city.jpg real-mountainlake.jpg
|
||||
// real-beachsunset.jpg old-faded.jpg food.jpg night.jpg pic-1016.jpg
|
||||
// pic-1027.jpg demo-street.jpg
|
||||
const P = {
|
||||
portrait: 'real-person.jpg',
|
||||
portrait2: 'pic-64.jpg',
|
||||
fjord: 'pic-1015.jpg',
|
||||
hills: 'pic-1018.jpg',
|
||||
sky: 'real-sky.jpg',
|
||||
city: 'real-city.jpg',
|
||||
lake: 'real-mountainlake.jpg',
|
||||
sunset: 'real-beachsunset.jpg',
|
||||
old: 'old-faded.jpg',
|
||||
canyon: 'pic-1016.jpg',
|
||||
face: 'pic-1027.jpg',
|
||||
street: 'demo-street.jpg',
|
||||
};
|
||||
|
||||
const CASES = [
|
||||
// ---- structural (30) ----
|
||||
{ id: 1, cat: 'structural', photo: P.sky, prompt: 'crop to a square' },
|
||||
{ id: 2, cat: 'structural', photo: P.sky, prompt: 'make this 16:9' },
|
||||
{ id: 3, cat: 'structural', photo: P.portrait, prompt: 'crop to 9:16 for a story' },
|
||||
{ id: 4, cat: 'structural', photo: P.fjord, prompt: 'crop to 4:5' },
|
||||
{ id: 5, cat: 'structural', photo: P.city, prompt: 'trim the empty borders' },
|
||||
{ id: 6, cat: 'structural', photo: P.hills, prompt: 'resize to 1920 wide' },
|
||||
{ id: 7, cat: 'structural', photo: P.fjord, prompt: 'resize to 1080 tall' },
|
||||
{ id: 8, cat: 'structural', photo: P.hills, prompt: 'rotate the whole image 90 degrees' },
|
||||
{ id: 9, cat: 'structural', photo: P.city, prompt: 'rotate the canvas 180' },
|
||||
{ id: 10, cat: 'structural', photo: P.fjord, prompt: 'flip the canvas horizontally' },
|
||||
{ id: 11, cat: 'structural', photo: P.sky, prompt: 'flip the whole image vertically' },
|
||||
{ id: 12, cat: 'structural', photo: P.hills, prompt: 'rotate only this layer 90 degrees' },
|
||||
{ id: 13, cat: 'structural', photo: P.city, prompt: 'flip this layer horizontally' },
|
||||
{ id: 14, cat: 'structural', photo: P.sky, prompt: 'add the title "SUMMER"' },
|
||||
{ id: 15, cat: 'structural', photo: P.fjord, setup: 'text', prompt: 'make the text bigger' },
|
||||
{ id: 16, cat: 'structural', photo: P.city, setup: 'text', prompt: 'make the text smaller' },
|
||||
{ id: 17, cat: 'structural', photo: P.hills, setup: 'text', prompt: 'make the title red' },
|
||||
{ id: 18, cat: 'structural', photo: P.sky, prompt: 'align the layer to the centre', setup: 'align' },
|
||||
{ id: 19, cat: 'structural', photo: P.fjord, prompt: 'align the layer to the left', setup: 'align' },
|
||||
{ id: 20, cat: 'structural', photo: P.city, prompt: 'align the layer to the right', setup: 'align' },
|
||||
{ id: 21, cat: 'structural', photo: P.sky, setup: 'text', prompt: 'add a drop shadow to the title' },
|
||||
{ id: 22, cat: 'structural', photo: P.fjord, setup: 'text', prompt: 'add a stroke to the layer' },
|
||||
{ id: 23, cat: 'structural', photo: P.city, prompt: 'add a colour overlay' },
|
||||
{ id: 24, cat: 'structural', photo: P.sky, prompt: 'fill the white area with red' },
|
||||
{ id: 25, cat: 'structural', photo: P.sky, prompt: 'flood fill the sky with blue' },
|
||||
{ id: 26, cat: 'structural', photo: P.hills, prompt: 'add a new layer' },
|
||||
{ id: 27, cat: 'structural', photo: P.fjord, prompt: 'duplicate this layer' },
|
||||
{ id: 28, cat: 'structural', photo: P.city, prompt: 'flatten the image' },
|
||||
{ id: 29, cat: 'structural', photo: P.sky, prompt: 'merge the layer down' },
|
||||
{ id: 30, cat: 'structural', photo: P.hills, prompt: 'delete this layer' },
|
||||
|
||||
// ---- looks / adjustments (25) ----
|
||||
{ id: 31, cat: 'looks', photo: P.fjord, prompt: 'make it brighter' },
|
||||
{ id: 32, cat: 'looks', photo: P.hills, prompt: 'make it darker' },
|
||||
{ id: 33, cat: 'looks', photo: P.city, prompt: 'warm it up' },
|
||||
{ id: 34, cat: 'looks', photo: P.portrait, prompt: 'make it cooler' },
|
||||
{ id: 35, cat: 'looks', photo: P.fjord, prompt: 'make it black and white' },
|
||||
{ id: 36, cat: 'looks', photo: P.old, prompt: 'make it sepia' },
|
||||
{ id: 37, cat: 'looks', photo: P.hills, prompt: 'make it punchy' },
|
||||
{ id: 38, cat: 'looks', photo: P.fjord, prompt: 'make it vivid and colourful' },
|
||||
{ id: 39, cat: 'looks', photo: P.city, prompt: 'make it dramatic' },
|
||||
{ id: 40, cat: 'looks', photo: P.hills, prompt: 'make it moody and cinematic' },
|
||||
{ id: 41, cat: 'looks', photo: P.old, prompt: 'make it vintage' },
|
||||
{ id: 42, cat: 'looks', photo: P.old, prompt: 'give it a faded film look' },
|
||||
{ id: 43, cat: 'looks', photo: P.city, prompt: 'increase the contrast' },
|
||||
{ id: 44, cat: 'looks', photo: P.fjord, prompt: 'lower the contrast' },
|
||||
{ id: 45, cat: 'looks', photo: P.hills, prompt: 'sharpen it' },
|
||||
{ id: 46, cat: 'looks', photo: P.portrait, prompt: 'soften it' },
|
||||
{ id: 47, cat: 'looks', photo: P.fjord, prompt: 'blur it slightly' },
|
||||
{ id: 48, cat: 'looks', photo: P.city, prompt: 'add a vignette' },
|
||||
{ id: 49, cat: 'looks', photo: P.hills, prompt: 'dehaze it' },
|
||||
{ id: 50, cat: 'looks', photo: P.fjord, prompt: 'make it HDR' },
|
||||
{ id: 51, cat: 'looks', photo: P.city, prompt: 'auto enhance' },
|
||||
{ id: 52, cat: 'looks', photo: P.hills, prompt: 'give it a teal and orange look' },
|
||||
{ id: 53, cat: 'looks', photo: P.fjord, prompt: 'golden hour' },
|
||||
{ id: 54, cat: 'looks', photo: P.old, prompt: 'clean it up' },
|
||||
{ id: 55, cat: 'looks', photo: P.city, prompt: 'make it look professional' },
|
||||
|
||||
// ---- portrait (10) ----
|
||||
{ id: 56, cat: 'portrait', photo: P.portrait, prompt: 'smooth the skin' },
|
||||
{ id: 57, cat: 'portrait', photo: P.portrait, prompt: 'whiten the teeth' },
|
||||
{ id: 58, cat: 'portrait', photo: P.portrait, prompt: 'remove the blemishes' },
|
||||
{ id: 59, cat: 'portrait', photo: P.portrait, prompt: 'brighten the face' },
|
||||
{ id: 60, cat: 'portrait', photo: P.portrait, prompt: 'brighten the eyes' },
|
||||
{ id: 61, cat: 'portrait', photo: P.portrait2, prompt: 'make her look glamorous' },
|
||||
{ id: 62, cat: 'portrait', photo: P.portrait, prompt: 'give it a professional headshot look' },
|
||||
{ id: 63, cat: 'portrait', photo: P.portrait, prompt: 'reduce the shine on the face' },
|
||||
{ id: 64, cat: 'portrait', photo: P.portrait2, prompt: 'warm up the portrait' },
|
||||
{ id: 65, cat: 'portrait', photo: P.portrait, prompt: 'soft smooth skin with a nice glow' },
|
||||
|
||||
// ---- generative (20) ----
|
||||
{ id: 66, cat: 'generative', photo: P.city, prompt: 'remove the person on the left' },
|
||||
{ id: 67, cat: 'generative', photo: P.hills, prompt: 'remove the distractions' },
|
||||
{ id: 68, cat: 'generative', photo: P.portrait, prompt: 'put her on a beach' },
|
||||
{ id: 69, cat: 'generative', photo: P.portrait, prompt: 'replace the background with mountains' },
|
||||
{ id: 70, cat: 'generative', photo: P.fjord, prompt: 'add a full moon in the sky' },
|
||||
{ id: 71, cat: 'generative', photo: P.sky, prompt: 'add dramatic clouds' },
|
||||
{ id: 72, cat: 'generative', photo: P.sky, prompt: 'extend the canvas to 16:9 with generative fill' },
|
||||
{ id: 73, cat: 'generative', photo: P.sky, prompt: 'extend the canvas to 9:16' },
|
||||
{ id: 74, cat: 'generative', photo: P.fjord, prompt: 'add a bird flying in the sky' },
|
||||
{ id: 75, cat: 'generative', photo: P.hills, prompt: 'add wildflowers in the foreground' },
|
||||
{ id: 76, cat: 'generative', photo: P.fjord, prompt: 'make it look snowy' },
|
||||
{ id: 77, cat: 'generative', photo: P.hills, prompt: 'make it autumn with orange leaves' },
|
||||
{ id: 78, cat: 'generative', photo: P.city, prompt: 'add rain and wet streets' },
|
||||
{ id: 79, cat: 'generative', photo: P.city, prompt: 'remove the car' },
|
||||
{ id: 80, cat: 'generative', photo: P.city, prompt: 'remove the sign' },
|
||||
{ id: 81, cat: 'generative', photo: P.fjord, prompt: 'change the sky to a sunset' },
|
||||
{ id: 82, cat: 'generative', photo: P.portrait, prompt: 'put sunglasses on her' },
|
||||
{ id: 83, cat: 'generative', photo: P.portrait, prompt: 'add a hat' },
|
||||
{ id: 84, cat: 'generative', photo: P.fjord, prompt: 'add a rainbow' },
|
||||
{ id: 85, cat: 'generative', photo: P.sky, prompt: 'generative fill the empty sky' },
|
||||
|
||||
// ---- multi-step (15) ----
|
||||
{ id: 86, cat: 'multistep', photo: P.city, prompt: 'crop to a square, make it warmer and add the title SUMMER' },
|
||||
{ id: 87, cat: 'multistep', photo: P.fjord, prompt: 'make it black and white and add a vignette' },
|
||||
{ id: 88, cat: 'multistep', photo: P.hills, prompt: 'brighten it and sharpen it' },
|
||||
{ id: 89, cat: 'multistep', photo: P.city, setup: 'text', prompt: 'warm it up and add a drop shadow to the title' },
|
||||
{ id: 90, cat: 'multistep', photo: P.portrait, prompt: 'remove the background and make it brighter' },
|
||||
{ id: 91, cat: 'multistep', photo: P.fjord, prompt: 'crop to 16:9 and give it a moody cinematic look' },
|
||||
{ id: 92, cat: 'multistep', photo: P.hills, prompt: 'flip it horizontally and add a vignette' },
|
||||
{ id: 93, cat: 'multistep', photo: P.city, prompt: 'sharpen it and increase the contrast' },
|
||||
{ id: 94, cat: 'multistep', photo: P.fjord, prompt: 'make it punchy and warm it up' },
|
||||
{ id: 95, cat: 'multistep', photo: P.hills, prompt: 'rotate the whole image 90 and add a vignette' },
|
||||
{ id: 96, cat: 'multistep', photo: P.sky, prompt: 'add the title HELLO and a drop shadow' },
|
||||
{ id: 97, cat: 'multistep', photo: P.fjord, prompt: 'select the subject, invert it and blur it' },
|
||||
{ id: 98, cat: 'multistep', photo: P.old, prompt: 'make it black and white then add a title' },
|
||||
{ id: 99, cat: 'multistep', photo: P.city, prompt: 'warm and brighten with a vignette' },
|
||||
{ id: 100, cat: 'multistep', photo: P.hills, prompt: 'crop to a square and make it vintage' },
|
||||
|
||||
// ---- v2.1 harder prompts (30): multi-step with generative, poster/text
|
||||
// design, reference looks, and targeted local recolours. ----
|
||||
|
||||
// hardmulti (10): compound instructions where at least one step is generative.
|
||||
{ id: 101, cat: 'hardmulti', photo: P.city, prompt: 'remove the person on the left and then warm up the photo' },
|
||||
{ id: 102, cat: 'hardmulti', photo: P.portrait, prompt: 'put her on a beach and add a warm sunset' },
|
||||
{ id: 103, cat: 'hardmulti', photo: P.fjord, prompt: 'add a full moon and make the sky more purple' },
|
||||
{ id: 104, cat: 'hardmulti', photo: P.city, prompt: 'remove the sign and add some flowers' },
|
||||
{ id: 105, cat: 'hardmulti', photo: P.portrait, prompt: 'replace the background with mountains and make it cooler' },
|
||||
{ id: 106, cat: 'hardmulti', photo: P.sky, prompt: 'add dramatic clouds then increase the contrast' },
|
||||
{ id: 107, cat: 'hardmulti', photo: P.hills, prompt: 'make it snowy and add a warm glow' },
|
||||
{ id: 108, cat: 'hardmulti', photo: P.street, prompt: 'remove the red car and add a bicycle' },
|
||||
{ id: 109, cat: 'hardmulti', photo: P.sky, prompt: 'extend to 16:9 with generative fill and add a title SUMMER' },
|
||||
{ id: 110, cat: 'hardmulti', photo: P.city, prompt: 'add rain and make it moody' },
|
||||
|
||||
// poster (8): title / tagline text design.
|
||||
{ id: 111, cat: 'poster', photo: P.canyon, prompt: 'make a movie poster titled THE SUMMIT' },
|
||||
{ id: 112, cat: 'poster', photo: P.fjord, prompt: 'add a bold title "ESCAPE" at the bottom and a tagline' },
|
||||
{ id: 113, cat: 'poster', photo: P.canyon, prompt: 'add large centred title text in white with a drop shadow' },
|
||||
{ id: 114, cat: 'poster', photo: P.fjord, prompt: 'make a travel poster titled VISIT NORWAY' },
|
||||
{ id: 115, cat: 'poster', photo: P.face, prompt: 'add the title "DREAM" in a stylish font and a thin border' },
|
||||
{ id: 116, cat: 'poster', photo: P.canyon, prompt: 'turn it into a vintage travel poster with the text EXPLORE' },
|
||||
{ id: 117, cat: 'poster', photo: P.city, setup: 'text', prompt: 'add a subtitle under the title and align it to the centre' },
|
||||
{ id: 118, cat: 'poster', photo: P.fjord, prompt: 'design a minimalist poster: add the title "SILENCE" and desaturate the background' },
|
||||
|
||||
// reference (20): "make it look like <style>" — any film, photographer or era.
|
||||
{ id: 119, cat: 'reference', photo: P.city, prompt: 'make it look like a Wes Anderson film' },
|
||||
{ id: 120, cat: 'reference', photo: P.hills, prompt: 'give it a Studio Ghibli animated look' },
|
||||
{ id: 121, cat: 'reference', photo: P.face, prompt: 'make it look like a 1970s film photograph' },
|
||||
{ id: 122, cat: 'reference', photo: P.city, prompt: 'give it a Blade Runner neon night look' },
|
||||
{ id: 123, cat: 'reference', photo: P.canyon, prompt: 'make it look like a National Geographic photo' },
|
||||
{ id: 124, cat: 'reference', photo: P.portrait, prompt: 'make it look like a vintage Polaroid' },
|
||||
{ id: 131, cat: 'reference', photo: P.fjord, prompt: 'make it look like a Kodak film photograph' },
|
||||
{ id: 132, cat: 'reference', photo: P.city, prompt: 'give it a cinematic teal and orange movie look' },
|
||||
{ id: 133, cat: 'reference', photo: P.canyon, prompt: 'make it look like an old western film' },
|
||||
{ id: 134, cat: 'reference', photo: P.hills, prompt: 'make it look like a National Geographic wildlife photo' },
|
||||
{ id: 135, cat: 'reference', photo: P.city, prompt: 'give it a moody film noir look' },
|
||||
{ id: 136, cat: 'reference', photo: P.street, prompt: 'make it look like a 1980s VHS still' },
|
||||
{ id: 137, cat: 'reference', photo: P.lake, prompt: 'make it look like a golden hour film photograph' },
|
||||
{ id: 138, cat: 'reference', photo: P.hills, prompt: 'give it a Kurosawa colour film look' },
|
||||
{ id: 139, cat: 'reference', photo: P.fjord, prompt: 'make it look like a David Attenborough nature documentary' },
|
||||
{ id: 140, cat: 'reference', photo: P.portrait2, prompt: 'make it look like a 1990s disposable camera photo' },
|
||||
{ id: 141, cat: 'reference', photo: P.sunset, prompt: 'give it a Technicolor vintage look' },
|
||||
{ id: 142, cat: 'reference', photo: P.lake, prompt: 'make it look like a black and white Ansel Adams landscape' },
|
||||
{ id: 143, cat: 'reference', photo: P.city, prompt: 'make it look like a Wong Kar-wai film' },
|
||||
{ id: 144, cat: 'reference', photo: P.old, prompt: 'give it a faded Kodachrome look' },
|
||||
|
||||
// targeted (6): recolour only a specific object/region.
|
||||
{ id: 125, cat: 'targeted', photo: P.street, prompt: 'make only the red car blue' },
|
||||
{ id: 126, cat: 'targeted', photo: P.street, prompt: 'change the yellow car to red' },
|
||||
{ id: 127, cat: 'targeted', photo: P.canyon, prompt: 'recolour the red rock to teal' },
|
||||
{ id: 128, cat: 'targeted', photo: P.fjord, prompt: 'turn the sky purple but keep the water natural' },
|
||||
{ id: 129, cat: 'targeted', photo: P.city, prompt: 'make the flowers bright pink' },
|
||||
{ id: 130, cat: 'targeted', photo: P.old, prompt: 'colourise the faded photo naturally' },
|
||||
|
||||
// bgremove (6): every common background-removal phrasing must map to the
|
||||
// deterministic ai.removeBackground op, never the AI agent / a tone.
|
||||
{ id: 145, cat: 'bgremove', photo: P.portrait, prompt: 'remove the background' },
|
||||
{ id: 146, cat: 'bgremove', photo: P.portrait, prompt: 'bg removal' },
|
||||
{ id: 147, cat: 'bgremove', photo: P.portrait, prompt: 'remove bg' },
|
||||
{ id: 148, cat: 'bgremove', photo: P.portrait, prompt: 'no background' },
|
||||
{ id: 149, cat: 'bgremove', photo: P.portrait, prompt: 'make the background transparent' },
|
||||
{ id: 150, cat: 'bgremove', photo: P.portrait, prompt: 'cut out the subject' },
|
||||
// ---- vague / misspelled (5) — the planner must not silently apply a
|
||||
// default brightness/vibrance tweak; it now falls back to the image model so
|
||||
// the prompt still does something meaningful.
|
||||
{ id: 151, cat: 'vague', photo: P.city, prompt: 'make it better' },
|
||||
{ id: 152, cat: 'vague', photo: P.fjord, prompt: 'fix it' },
|
||||
{ id: 153, cat: 'vague', photo: P.portrait, prompt: 'enhace the pic' },
|
||||
{ id: 154, cat: 'vague', photo: P.hills, prompt: 'do the thing' },
|
||||
{ id: 155, cat: 'vague', photo: P.portrait, prompt: 'make it look proffesional' },
|
||||
// ---- auto fall-through (model) — a generative prompt in Auto mode must land
|
||||
// as ONE NEW layer with a single undo group; a vague/unrecognised prompt now
|
||||
// falls through to the image model too (no dead-end "not sure" reply). ----
|
||||
{ id: 156, cat: 'auto', photo: P.city, prompt: 'add a man to the scene' },
|
||||
{ id: 157, cat: 'auto', photo: P.sky, prompt: 'add a hot air balloon' },
|
||||
{ id: 158, cat: 'auto', photo: P.fjord, prompt: 'do the thing' },
|
||||
// ---- restyle — must route to ai_edit_image (model edit), NOT ask and NOT
|
||||
// a filter tweak; run with MODE=agent to exercise the server planner. ----
|
||||
{ id: 159, cat: 'restyle', photo: P.fjord, prompt: 'turn it into an oil painting' },
|
||||
{ id: 160, cat: 'restyle', photo: P.hills, prompt: 'make it look like a watercolour' },
|
||||
{ id: 161, cat: 'restyle', photo: P.portrait, prompt: 'make it look like an anime' },
|
||||
{ id: 162, cat: 'restyle', photo: P.city, prompt: 'make it a comic book illustration' },
|
||||
];
|
||||
|
||||
// Cases that load a heavy in-process model on the app host (remove-background,
|
||||
// ~9.5 GB) and can OOM-kill the user's service; HARNESS is moving these to the
|
||||
// seneca image server. Marked pending instead of executed so a bench run never
|
||||
// crashes the app. Extend with SKIP=<ids> (e.g. SAM-heavy cases) if needed.
|
||||
// Heavy in-process cases to skip. Default #90 (remove-background) while it
|
||||
// risked the 7011 OOM; now that heavy image ops proxy to seneca, set
|
||||
// PENDING= (empty) to run it.
|
||||
const PENDING = new Set(String(process.env.PENDING != null ? process.env.PENDING : '90')
|
||||
.split(',').map(s => Number(s.trim())).filter(Boolean));
|
||||
for (const id of String(process.env.SKIP || '').split(',').map(s => Number(s.trim())).filter(Boolean)) PENDING.add(id);
|
||||
|
||||
function authToken() {
|
||||
const data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
||||
const now = Date.now() / 1000;
|
||||
for (const [token, info] of Object.entries(data)) {
|
||||
if (info.expiry == null || info.expiry > now) return token;
|
||||
}
|
||||
throw new Error(`no unexpired session in ${SESSION_FILE}`);
|
||||
}
|
||||
|
||||
function selected() {
|
||||
let cases = CASES;
|
||||
if (process.env.CATS) {
|
||||
const cats = new Set(process.env.CATS.split(',').map(s => s.trim()));
|
||||
cases = cases.filter(c => cats.has(c.cat));
|
||||
}
|
||||
if (process.env.PROMPTS) {
|
||||
const want = new Set(process.env.PROMPTS.split(',').map(s => Number(s.trim())));
|
||||
cases = cases.filter(c => want.has(c.id));
|
||||
}
|
||||
return cases;
|
||||
}
|
||||
|
||||
function saveShot(url, file) {
|
||||
const idx = url.indexOf(',');
|
||||
fs.writeFileSync(file, Buffer.from(url.slice(idx + 1), 'base64'));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: !process.env.HEADFUL });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 860 }, serviceWorkers: 'block' });
|
||||
await context.addCookies([{ name: 'odysseus_session', value: authToken(), domain: new URL(BASE).hostname, path: '/' }]);
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', e => console.log(' [pageerror]', String(e).slice(0, 240)));
|
||||
// Per-case image-op HTTP statuses so a real no-op (request succeeded but the
|
||||
// image is unchanged) is distinguishable from an outage (instant 5xx/000).
|
||||
let netLog = [];
|
||||
page.on('response', r => {
|
||||
const m = r.url().match(/\/api\/image\/([a-z-]+)/);
|
||||
if (m) netLog.push({ op: m[1], status: r.status() });
|
||||
});
|
||||
page.on('requestfailed', r => {
|
||||
const m = r.url().match(/\/api\/image\/([a-z-]+)/);
|
||||
if (m) netLog.push({ op: m[1], status: 'failed:' + ((r.failure() && r.failure().errorText) || '?') });
|
||||
});
|
||||
|
||||
const results = [];
|
||||
for (const c of selected()) {
|
||||
const tag = String(c.id).padStart(3, '0') + '-' + c.cat + '-' + c.prompt.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
|
||||
const rec = { id: c.id, cat: c.cat, prompt: c.prompt, photo: c.photo, ms: null, ok: null, blocked: null, reply: null, ops: [], newLayers: [], undoGroups: null, error: null, tag };
|
||||
if (PENDING.has(c.id)) {
|
||||
rec.pending = true;
|
||||
results.push(rec);
|
||||
console.log(`BENCH PENDING #${c.id} [${c.cat}] "${c.prompt}" (heavy in-process model; excluded)`);
|
||||
fs.writeFileSync(path.join(OUT, 'quickedit-bench.json'), JSON.stringify({ base: BASE, results }, null, 2));
|
||||
continue;
|
||||
}
|
||||
netLog = [];
|
||||
try {
|
||||
await page.goto(BASE + '/?nodraft=1', { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 30000 });
|
||||
await page.evaluate(async () => {
|
||||
const gallery = await import('/static/js/gallery.js?v=20260830editor4');
|
||||
gallery.openGallery();
|
||||
});
|
||||
await page.locator('#gallery-editor-tab').first().waitFor({ state: 'attached', timeout: 30000 });
|
||||
await page.evaluate(() => document.getElementById('gallery-editor-tab')?.click());
|
||||
const dataUrl = 'data:image/jpeg;base64,' + fs.readFileSync(path.join(PHOTOS, c.photo)).toString('base64');
|
||||
const selfCheck = process.env.SELFCHECK !== '0';
|
||||
const mode = process.env.MODE || '';
|
||||
await page.evaluate(async ({ selfCheck, mode, dataUrl }) => {
|
||||
const editor = await import('/static/js/galleryEditor.js?qb=' + Date.now());
|
||||
window.__geNoDraft = true;
|
||||
if (editor.isEditorOpen()) editor.closeEditor({ force: true });
|
||||
window.geQuickEditSelfCheck = selfCheck;
|
||||
try { if (mode) localStorage.setItem('ge-ai-command-mode', mode); else localStorage.removeItem('ge-ai-command-mode'); } catch (_) {}
|
||||
try { localStorage.setItem('ge-preflight-dismissed', '1'); } catch (_) {}
|
||||
// Size the document to the photo's aspect so importing never leaves
|
||||
// white letterbox margins (which the model then paints as an edge band).
|
||||
const dim = await new Promise((res) => { const im = new Image(); im.onload = () => res({ w: im.naturalWidth || 800, h: im.naturalHeight || 600 }); im.onerror = () => res({ w: 800, h: 600 }); im.src = dataUrl; });
|
||||
const W = 800; const H = Math.max(1, Math.min(1600, Math.round(W * dim.h / dim.w)));
|
||||
editor.openEditor(null, null, { w: W, h: H }, 'BENCH ' + Date.now());
|
||||
}, { selfCheck, mode, dataUrl });
|
||||
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 30000 });
|
||||
await page.waitForFunction(() => !!window.geEditorControl && !!window.geEditorAgent, null, { timeout: 20000 });
|
||||
|
||||
const out = await page.evaluate(async ({ dataUrl, prompt, setup }) => {
|
||||
const invoke = (op, params) => window.geEditorControl.invoke(op, params);
|
||||
await invoke('document.importImage', { dataUrl, name: 'photo', raster: true });
|
||||
if (setup === 'text') await invoke('text.add', { text: 'Summer', x: 80, y: 80, size: 64 });
|
||||
else if (setup === 'align') await invoke('layers.addRaster', {});
|
||||
const before = (await invoke('screenshot')).result.dataUrl;
|
||||
const mark0 = (await invoke('history.mark')).result;
|
||||
const layers0 = (await invoke('layers.list')).result.map(l => l.name + ':' + l.kind);
|
||||
const ops = [];
|
||||
const control = window.geEditorControl;
|
||||
const orig = control.invoke.bind(control);
|
||||
control.invoke = async (op, params) => { ops.push(op); return orig(op, params); };
|
||||
window.__selfCheckLog = [];
|
||||
const t0 = Date.now();
|
||||
let run = null, error = null;
|
||||
try {
|
||||
run = window.geQuickEditRun
|
||||
? await window.geQuickEditRun(prompt, {})
|
||||
: await window.geEditorAgent.run(prompt, {});
|
||||
}
|
||||
catch (err) { error = String((err && err.message) || err); }
|
||||
finally { control.invoke = orig; }
|
||||
const ms = Date.now() - t0;
|
||||
const after = (await invoke('screenshot')).result.dataUrl;
|
||||
const mark1 = (await invoke('history.mark')).result;
|
||||
const layers1 = (await invoke('layers.list')).result;
|
||||
const newLayers = layers1.map(l => l.name + ':' + l.kind).filter(s => !layers0.includes(s));
|
||||
return { before, after, mark0, mark1, newLayers, ms, ops, reply: run && (run.reply || run.message), ok: run && run.ok, blocked: run && run.blocked, error, selfChecks: window.__selfCheckLog || [] };
|
||||
}, { dataUrl, prompt: c.prompt, setup: c.setup || null });
|
||||
|
||||
rec.ms = out.ms; rec.ops = out.ops; rec.reply = out.reply; rec.ok = out.ok;
|
||||
rec.blocked = out.blocked; rec.newLayers = out.newLayers;
|
||||
rec.selfChecks = out.selfChecks || [];
|
||||
rec.undoGroups = (typeof out.mark1 === 'number' && typeof out.mark0 === 'number') ? out.mark1 - out.mark0 : null;
|
||||
rec.error = out.error;
|
||||
rec.net = netLog.slice();
|
||||
saveShot(out.before, path.join(OUT, tag + '-before.png'));
|
||||
saveShot(out.after, path.join(OUT, tag + '-after.png'));
|
||||
} catch (err) {
|
||||
rec.error = String((err && err.message) || err);
|
||||
}
|
||||
results.push(rec);
|
||||
for (const sc of (rec.selfChecks || [])) {
|
||||
console.log(`SELFCHECK #${c.id} verdict=${JSON.stringify(sc.verdict)} retry=${sc.retry ? JSON.stringify(sc.retry) : 'null'}`);
|
||||
}
|
||||
console.log(`BENCH ${rec.ok === false ? 'FAIL' : 'OK'} #${c.id} [${c.cat}] "${c.prompt}" ${rec.ms}ms ops=${rec.ops.join(',') || rec.error} net=${JSON.stringify(rec.net || [])}`);
|
||||
fs.writeFileSync(path.join(OUT, 'quickedit-bench.json'), JSON.stringify({ base: BASE, results }, null, 2));
|
||||
}
|
||||
|
||||
const pending = results.filter(r => r.pending).length;
|
||||
const ran = results.filter(r => !r.pending && r.ok !== false && !r.error).length;
|
||||
console.log(`QUICKEDIT_BENCH ${ran}/${results.length - pending} ran without error (${pending} pending) -> ${path.join(OUT, 'quickedit-bench.json')}`);
|
||||
await browser.close();
|
||||
process.exit(0);
|
||||
})().catch(err => { console.error('FAIL', err); process.exit(1); });
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
// Quick Edit coverage audit runner (standalone; not a Playwright spec).
|
||||
//
|
||||
// Drives the live editor agent for every prompt in CASES and grades each by
|
||||
// whether an expected control op appears in the executed-step trace.
|
||||
//
|
||||
// Usage:
|
||||
// BASE_URL=http://127.0.0.1:7011 SESSION_FILE=/path/sessions.json \
|
||||
// NODE_PATH=/path/node_modules node quickedit-coverage.js
|
||||
//
|
||||
// PROMPTS=1,2,3 limit to a subset
|
||||
// OUT=/tmp/qe output dir for the JSON report
|
||||
// HEADFUL=1 show the browser
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('@playwright/test');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:7011';
|
||||
const OUT = process.env.OUT || '/tmp/opencode/qe-coverage';
|
||||
const SESSION_FILE = process.env.SESSION_FILE || '/home/pewds/odysseus-cookbook-fresh/data/sessions.json';
|
||||
|
||||
const CASES = [
|
||||
{ id: 1, group: 'Fill', prompt: 'fill the white area with red', expect: ['fill.flood', 'selection.fill'] },
|
||||
{ id: 2, group: 'Fill', prompt: 'flood fill the sky with blue', expect: ['fill.flood', 'selection.fill', 'selection.sky'] },
|
||||
{ id: 3, group: 'Text', prompt: 'make the text bigger', expect: ['text.edit'], setup: 'text' },
|
||||
{ id: 4, group: 'Text', prompt: 'make the title red', expect: ['text.edit'], setup: 'text' },
|
||||
{ id: 5, group: 'Text', prompt: 'add the title "Summer"', expect: ['text.add'] },
|
||||
{ id: 6, group: 'Layer style', prompt: 'add a drop shadow to the title', expect: ['layers.addStyle'], setup: 'text' },
|
||||
{ id: 7, group: 'Layer style', prompt: 'add a stroke to the layer', expect: ['layers.addStyle'] },
|
||||
{ id: 8, group: 'Layer style', prompt: 'add a colour overlay', expect: ['layers.addStyle'] },
|
||||
{ id: 9, group: 'Align', prompt: 'align the logo to the centre', expect: ['layers.align'] },
|
||||
{ id: 10, group: 'Align', prompt: 'align the layer to the left', expect: ['layers.align'] },
|
||||
{ id: 11, group: 'Layer transform', prompt: 'rotate only this layer 90 degrees', expect: ['layer.rotate'] },
|
||||
{ id: 12, group: 'Layer transform', prompt: 'flip this layer horizontally', expect: ['layer.flip'] },
|
||||
{ id: 13, group: 'Document transform', prompt: 'rotate the whole image 90 degrees', expect: ['document.rotate'] },
|
||||
{ id: 14, group: 'Document transform', prompt: 'flip the canvas vertically', expect: ['document.flip'] },
|
||||
{ id: 15, group: 'Crop', prompt: 'crop to a square', expect: ['document.crop'] },
|
||||
{ id: 16, group: 'Crop', prompt: 'make this 16:9', expect: ['document.crop'] },
|
||||
{ id: 17, group: 'Crop', prompt: 'crop to 9:16 for a story', expect: ['document.crop'] },
|
||||
{ id: 18, group: 'Extend', prompt: 'extend the canvas to 16:9', expect: ['ai.outpaint'] },
|
||||
{ id: 19, group: 'Clipboard', prompt: 'copy the selection', expect: ['selection.copy'], setup: 'select' },
|
||||
{ id: 20, group: 'Clipboard', prompt: 'paste', expect: ['selection.paste'], setup: 'select' },
|
||||
{ id: 21, group: 'Selection', prompt: 'select the subject', expect: ['selection.subject'] },
|
||||
{ id: 22, group: 'Selection', prompt: 'select the sky', expect: ['selection.sky'] },
|
||||
{ id: 23, group: 'Selection', prompt: 'select all', expect: ['selection.selectAll'] },
|
||||
{ id: 24, group: 'Selection', prompt: 'invert the selection', expect: ['selection.invert'] },
|
||||
{ id: 25, group: 'Background', prompt: 'remove the background', expect: ['ai.removeBackground', 'selection.background'] },
|
||||
{ id: 26, group: 'Background', prompt: 'blur the background', expect: ['filters.applyMasked', 'filters.apply'] },
|
||||
{ id: 27, group: 'Adjustment', prompt: 'make it brighter', expect: ['photo.autoTone', 'adjustments.apply', 'shadows-highlights'] },
|
||||
{ id: 28, group: 'Adjustment', prompt: 'make it black and white', expect: ['adjustments.apply', 'black-white'], assert: 'black' },
|
||||
{ id: 29, group: 'Adjustment', prompt: 'warm it up', expect: ['adjustments.apply', 'photo.autoTone', 'white-balance'] },
|
||||
{ id: 30, group: 'Adjustment', prompt: 'make it punchy', expect: ['photo.autoTone', 'adjustments.apply', 'vibrance'] },
|
||||
{ id: 31, group: 'Adjustment', prompt: 'make it moody and cinematic', expect: ['adjustments.apply', 'white-balance'] },
|
||||
{ id: 32, group: 'Restore', prompt: 'restore this old photo', expect: ['photo.autoTone', 'adjustments.apply'] },
|
||||
{ id: 33, group: 'Retouch', prompt: 'smooth the skin', expect: ['retouch.skin', 'adjustments.apply'] },
|
||||
{ id: 34, group: 'Retouch', prompt: 'whiten the teeth', expect: ['retouch.teeth', 'adjustments.apply'] },
|
||||
{ id: 35, group: 'Retouch', prompt: 'remove the blemishes', expect: ['retouch.blemish', 'retouch.skin', 'adjustments.apply'] },
|
||||
{ id: 36, group: 'Filter', prompt: 'sharpen it', expect: ['filters.apply', 'adjustments.apply', 'photo.autoTone'] },
|
||||
{ id: 37, group: 'Filter', prompt: 'add a vignette', expect: ['filters.apply', 'adjustments.apply'] },
|
||||
{ id: 38, group: 'Layers', prompt: 'add a new layer', expect: ['layers.addRaster', 'layers.addAdjustment'] },
|
||||
{ id: 39, group: 'Layers', prompt: 'duplicate this layer', expect: ['layers.duplicate'] },
|
||||
{ id: 40, group: 'Layers', prompt: 'flatten the image', expect: ['layers.flatten'] },
|
||||
{ id: 41, group: 'Layers', prompt: 'merge the layer down', expect: ['layers.mergeDown'] },
|
||||
{ id: 42, group: 'Document', prompt: 'save as PSD', expect: ['document.savePsd'] },
|
||||
{ id: 43, group: 'Document', prompt: 'resize to 1920 wide', expect: ['document.resizeCanvas'] },
|
||||
{ id: 44, group: 'AI edit', prompt: 'remove the person on the left', expect: ['ai.generativeFill'] },
|
||||
{ id: 45, group: 'AI generate', prompt: 'add a full moon in the sky', expect: ['ai.generativeFill'] },
|
||||
];
|
||||
|
||||
function authToken() {
|
||||
const data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
||||
const now = Date.now() / 1000;
|
||||
for (const [token, info] of Object.entries(data)) {
|
||||
if (info.expiry == null || info.expiry > now) return token;
|
||||
}
|
||||
throw new Error(`no unexpired session in ${SESSION_FILE}`);
|
||||
}
|
||||
|
||||
function selected() {
|
||||
const raw = process.env.PROMPTS;
|
||||
if (!raw) return CASES;
|
||||
const want = new Set(raw.split(',').map(s => Number(s.trim())));
|
||||
return CASES.filter(c => want.has(c.id));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: !process.env.HEADFUL });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 860 }, serviceWorkers: 'block' });
|
||||
await context.addCookies([{ name: 'odysseus_session', value: authToken(), domain: new URL(BASE).hostname, path: '/' }]);
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', e => console.log(' [pageerror]', String(e).slice(0, 240)));
|
||||
|
||||
const results = [];
|
||||
for (const c of selected()) {
|
||||
let record = { id: c.id, group: c.group, prompt: c.prompt, expect: c.expect, ops: [], pass: false, error: null };
|
||||
try {
|
||||
await page.goto(BASE + '/?nodraft=1', { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 30000 });
|
||||
await page.evaluate(async () => {
|
||||
const gallery = await import('/static/js/gallery.js?v=20260830editor4');
|
||||
gallery.openGallery();
|
||||
});
|
||||
await page.locator('#gallery-editor-tab').first().waitFor({ state: 'attached', timeout: 30000 });
|
||||
await page.evaluate(() => document.getElementById('gallery-editor-tab')?.click());
|
||||
await page.evaluate(async () => {
|
||||
const editor = await import('/static/js/galleryEditor.js?qe=' + Date.now());
|
||||
window.__geNoDraft = true;
|
||||
if (editor.isEditorOpen()) editor.closeEditor({ force: true });
|
||||
editor.openEditor(null, null, { w: 640, h: 480 }, 'QE ' + Date.now());
|
||||
});
|
||||
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 30000 });
|
||||
await page.waitForFunction(() => !!window.geEditorControl, null, { timeout: 20000 });
|
||||
|
||||
if (c.setup) {
|
||||
await page.evaluate(async (setup) => {
|
||||
const control = window.geEditorControl;
|
||||
if (setup === 'text') await control.invoke('text.add', { text: 'Summer', x: 80, y: 80, size: 64 });
|
||||
else if (setup === 'select') await control.invoke('selection.rectangle', { x: 100, y: 100, width: 200, height: 160 });
|
||||
}, c.setup);
|
||||
}
|
||||
|
||||
const trace = await page.evaluate(async (prompt) => {
|
||||
const ops = [];
|
||||
const control = window.geEditorControl;
|
||||
const orig = control.invoke.bind(control);
|
||||
control.invoke = async (op, params) => { ops.push({ op, params }); return orig(op, params); };
|
||||
let result = null, error = null;
|
||||
try {
|
||||
result = window.geQuickEditRun
|
||||
? await window.geQuickEditRun(prompt, {})
|
||||
: await window.geEditorAgent.run(prompt, {});
|
||||
}
|
||||
catch (err) { error = String((err && err.message) || err); }
|
||||
finally { control.invoke = orig; }
|
||||
return { ops, reply: result && (result.reply || result.message), error };
|
||||
}, c.prompt);
|
||||
|
||||
record.ops = trace.ops.map(o => o.op);
|
||||
record.reply = trace.reply;
|
||||
record.error = trace.error;
|
||||
const opHit = c.expect.some(e => record.ops.some(o => o === e || o.startsWith(e + '.')));
|
||||
const assertHit = !c.assert || trace.ops.some(o => JSON.stringify(o.params || {}).includes(c.assert));
|
||||
record.pass = opHit && assertHit;
|
||||
} catch (err) {
|
||||
record.error = String((err && err.message) || err);
|
||||
}
|
||||
results.push(record);
|
||||
console.log(`QE ${record.pass ? 'PASS' : 'FAIL'} #${c.id} [${c.group}] "${c.prompt}" -> ${record.ops.join(', ') || record.error}`);
|
||||
}
|
||||
|
||||
const pass = results.filter(r => r.pass).length;
|
||||
fs.writeFileSync(path.join(OUT, 'quickedit-coverage.json'), JSON.stringify({ base: BASE, pass, total: results.length, results }, null, 2));
|
||||
console.log(`QUICKEDIT_COVERAGE ${pass}/${results.length} passed -> ${path.join(OUT, 'quickedit-coverage.json')}`);
|
||||
await browser.close();
|
||||
process.exit(0);
|
||||
})().catch(err => { console.error('FAIL', err); process.exit(1); });
|
||||
@@ -45,20 +45,10 @@ test('selection boundary animates, moves independently, and supports Quick Mask'
|
||||
expect((await editorState(page)).quickMaskActive).toBe(false);
|
||||
});
|
||||
|
||||
test('marquee supports exact fixed-size and fixed-ratio geometry', async ({ page }) => {
|
||||
test('marquee supports exact fixed-ratio geometry', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Precise marquee');
|
||||
await selectTool(page, 'marquee');
|
||||
|
||||
await page.locator('#ge-marquee-constraint').selectOption('size');
|
||||
await page.locator('#ge-marquee-width').fill('80');
|
||||
await page.locator('#ge-marquee-width').blur();
|
||||
await page.locator('#ge-marquee-height').fill('60');
|
||||
await page.locator('#ge-marquee-height').blur();
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.click(Math.round(box.x + box.width * 0.8), Math.round(box.y + box.height * 0.75));
|
||||
await expect.poll(async () => (await editorState(page)).selection?.bounds).toEqual({ x: 240, y: 180, width: 80, height: 60 });
|
||||
|
||||
await page.locator('#ge-marquee-clear').click();
|
||||
await page.locator('#ge-marquee-constraint').selectOption('ratio');
|
||||
await page.locator('#ge-marquee-width').fill('4');
|
||||
await page.locator('#ge-marquee-width').blur();
|
||||
@@ -80,7 +70,7 @@ test('transform selection moves, scales, rotates, cancels, and preserves layer p
|
||||
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('[data-selection-action="transform"]').click();
|
||||
await expect(page.locator('.ge-transform-popup')).toBeVisible();
|
||||
await expect(page.locator('#ge-transform-section')).toBeVisible();
|
||||
await expect(page.locator('.ge-transform-popup .ge-adj-title')).toHaveText('Transform Selection');
|
||||
await dragOnCanvas(page, { x: 0.35, y: 0.4 }, { x: 0.45, y: 0.47 });
|
||||
const moved = await editorState(page);
|
||||
@@ -116,14 +106,11 @@ test('transform selection moves, scales, rotates, cancels, and preserves layer p
|
||||
await page.waitForTimeout(250);
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('[data-selection-action="transform"]').click();
|
||||
const mobilePopup = await page.locator('.ge-transform-popup').boundingBox();
|
||||
const mobileTitle = await page.locator('.ge-transform-popup .ge-adj-title').boundingBox();
|
||||
expect(mobilePopup.x).toBeGreaterThanOrEqual(0);
|
||||
expect(mobilePopup.y).toBeGreaterThanOrEqual(0);
|
||||
expect(mobilePopup.x + mobilePopup.width).toBeLessThanOrEqual(390);
|
||||
expect(mobilePopup.y + mobilePopup.height).toBeLessThanOrEqual(844);
|
||||
expect(mobileTitle.x).toBeGreaterThanOrEqual(mobilePopup.x);
|
||||
expect(mobileTitle.x + mobileTitle.width).toBeLessThanOrEqual(mobilePopup.x + mobilePopup.width);
|
||||
const mobileSection = await page.locator('#ge-transform-section').boundingBox();
|
||||
expect(mobileSection.x).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileSection.y).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileSection.x + mobileSection.width).toBeLessThanOrEqual(390);
|
||||
expect(mobileSection.y + mobileSection.height).toBeLessThanOrEqual(844);
|
||||
await page.locator('#ge-transform-cancel-btn').click();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ const { openBlankEditor } = require('./helpers');
|
||||
|
||||
const FONTS = [
|
||||
'Inter', 'Roboto', 'Playfair Display', 'Lobster',
|
||||
'Bebas Neue', 'Roboto Mono', 'Georgia', 'Courier New',
|
||||
'Bebas Neue', 'Roboto Mono', 'Space Grotesk', 'Fraunces',
|
||||
'Syne', 'Bungee', 'Instrument Serif', 'Georgia', 'Courier New',
|
||||
];
|
||||
const md5 = (buf) => crypto.createHash('md5').update(buf).digest('hex');
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const { openBlankEditor } = require('./helpers');
|
||||
test('transform frame exposes eight accurate handles and supports edge resize', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform frame');
|
||||
await selectTool(page, 'transform');
|
||||
await expect(page.locator('.ge-transform-popup')).toBeVisible();
|
||||
await expect(page.locator('#ge-transform-section')).toBeVisible();
|
||||
|
||||
const frame = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
@@ -154,7 +154,7 @@ test('rotated frame moves only from its visible interior and supports keyboard n
|
||||
})).toEqual({ x: 250, y: 180 });
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.ge-transform-popup')).toBeHidden();
|
||||
await expect(page.locator('#ge-transform-section')).toBeHidden();
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformActive;
|
||||
@@ -216,7 +216,7 @@ test('mobile touch input can grab every transform handle', async ({ browser, bro
|
||||
try {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile transform handles');
|
||||
await selectTool(page, 'transform');
|
||||
await expect(page.locator('.ge-transform-popup')).toBeVisible();
|
||||
await expect(page.locator('#ge-transform-section')).toBeVisible();
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const ids = ['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l', 'rot'];
|
||||
|
||||
@@ -323,7 +323,7 @@ test('mobile touch input can grab every transform handle', async ({ browser, bro
|
||||
test('transform handle keeps tracking when the pointer crosses the right panel', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform overshoot');
|
||||
await selectTool(page, 'transform');
|
||||
await expect(page.locator('.ge-transform-popup')).toBeVisible();
|
||||
await expect(page.locator('#ge-transform-section')).toBeVisible();
|
||||
|
||||
const setup = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
|
||||
Reference in New Issue
Block a user