Merge current dev and refresh PR #6741 from the 7011 integration

This commit is contained in:
pewdiepie-archdaemon
2026-10-26 16:21:28 +00:00
295 changed files with 24233 additions and 1587 deletions
+45 -45
View File
@@ -1,10 +1,10 @@
{
"digest": "9868d50a542b7ad1",
"digest": "f7e60e4a567c46dc",
"elements": {
"app-shell": {
"app-loader": "f04ad5bf6312d53f",
"attach-strip": "a6474951e2481214",
"body": "58df17f0afa190b9",
"body": "4956f449e6a30e93",
"chat-container": "b00a7c6be4e107b1",
"chat-context-pill": "dd905b87245a9a71",
"chat-history": "e65bdc51f4d1675f",
@@ -23,8 +23,8 @@
"input-icon-btn": "3d1c773acd84a825",
"memory-close-btn": "c3f4ba99098a6e4b",
"memory-list": "da9a37a3de3c1a31",
"memory-modal": "5efc81537eab4526",
"memory-modal-content": "3cadc8581361436e",
"memory-modal": "e2f97b123dc9bc55",
"memory-modal-content": "5c5fe055c711e642",
"memory-modal-header": "a8c822fe8c6d20e7",
"memory-search-input": "8b0fbb33401750d4",
"memory-toolbar-btn": "9f985efa7c896f90",
@@ -42,7 +42,7 @@
"rail-new-chat": "f8f1c2f6be426ab9",
"reasoning-effort-btn": "2a07cdd8ec2b4d26",
"rename-session-modal": "9345624780009d14",
"root": "820190c144fda419",
"root": "a9f7ace2c1cefb24",
"save-custom-preset": "443da70ef5fe9b88",
"scroll-bottom-btn": "24602d72e2949991",
"search-input": "53b94f3e5fdb384d",
@@ -54,7 +54,7 @@
"settings-btn-sm": "c31bcb9bdfbae10d",
"settings-fallbacks": "e339280590935fe3",
"settings-logs-tab": "e470140373597143",
"settings-modal": "5efc81537eab4526",
"settings-modal": "e2f97b123dc9bc55",
"settings-nav-search": "f4484b2980f2ccc5",
"settings-row": "de568724268cec6e",
"settings-select": "a96e8430b0ec6668",
@@ -69,7 +69,7 @@
"theme-grid": "4d4b2aeb8952cc6f",
"theme-io-btn": "859f222c79a8c721",
"theme-modal": "0a65ca916656cdbb",
"theme-popup": "3aa65e6f313bbc65",
"theme-popup": "fb86cc5f81d0aaa6",
"theme-tabs": "0cc4987f39876dce",
"toast": "c60c6a60d4982005",
"tool-indicator": "0dee45eaca8b703a",
@@ -83,7 +83,7 @@
"#agent-indicator": "09a1b8d94b1487ee",
"#agent-indicator.active": "67e2ad3fa48acd3b",
"#compare-model-overlay .modal-body": "0b1ee357313551b9",
"#compare-model-overlay .modal-content": "56883147d181bd9c",
"#compare-model-overlay .modal-content": "9b2145292f439a88",
"#cookbook-modal .cookbook-body": "20bd8a7268cee788",
"#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card": "48f6787a3df3f19e",
"#cookbook-modal .modal-content": "0c4e1e686df0990e",
@@ -101,7 +101,7 @@
"#email-lib-modal .email-lib-sync-status": "6d1a0374a16cf7f4",
"#email-lib-modal .email-reader-atts": "c5d68ecda3c14bb8",
"#email-lib-modal .email-reader-header": "85afca11c8cbd38e",
"#email-lib-modal .modal-content": "d8b8fff38c613c95",
"#email-lib-modal .modal-content": "262753f287410717",
"#email-lib-settings-btn": "9ae15bf04b6a22ce",
"#email-unread-dot.sidebar-notif-dot": "c80f1e03592983b6",
"#export-compact-btn": "7d0325a38e51134b",
@@ -686,44 +686,44 @@
},
"variants": {
"app-shell": {
"desktop-dark-comfortable": "65b23a35b5a34126",
"desktop-dark-compact": "1bac3a0aa4467555",
"desktop-dark-spacious": "944583b1854fecbb",
"desktop-light-comfortable": "d279432f8c120b58",
"desktop-light-compact": "aa768c34ba3c7abc",
"desktop-light-spacious": "b74f53e95bfa1d82",
"laptop-dark-comfortable": "d98d16f3d60ed275",
"laptop-dark-compact": "c3eaf3c4278a0120",
"laptop-dark-spacious": "a085fe0f7d9eb66c",
"laptop-light-comfortable": "0d7c64f324bebe87",
"laptop-light-compact": "da50bf03b0f1be58",
"laptop-light-spacious": "a6789fd05a66bf5a",
"phone-dark-comfortable": "21b94f88da549eb5",
"phone-dark-compact": "b9aa7def2b85414e",
"phone-dark-spacious": "21b94f88da549eb5",
"phone-light-comfortable": "4a0cf45db775ea7d",
"phone-light-compact": "f7fbcb94d99c8730",
"phone-light-spacious": "4a0cf45db775ea7d",
"tablet-dark-comfortable": "e18256ce58398f11",
"tablet-dark-compact": "3cf3e52a10313d44",
"tablet-dark-spacious": "e18256ce58398f11",
"tablet-light-comfortable": "c2a45155c391f26c",
"tablet-light-compact": "9fa7dc03dd02b723",
"tablet-light-spacious": "c2a45155c391f26c"
"desktop-dark-comfortable": "0c9123117135e362",
"desktop-dark-compact": "c694f5d5118ac0e2",
"desktop-dark-spacious": "90a67613e8cfdf5d",
"desktop-light-comfortable": "17727a9945ccc77b",
"desktop-light-compact": "2a887c30d231e357",
"desktop-light-spacious": "94b4666734b87625",
"laptop-dark-comfortable": "26d334b139b653f2",
"laptop-dark-compact": "fea30bad6824357d",
"laptop-dark-spacious": "4fdb28deaaaec870",
"laptop-light-comfortable": "c7fc986a7e70aac8",
"laptop-light-compact": "147645431229ddff",
"laptop-light-spacious": "d513c285d30c8289",
"phone-dark-comfortable": "551761344bec8d09",
"phone-dark-compact": "293d66518c3ec994",
"phone-dark-spacious": "551761344bec8d09",
"phone-light-comfortable": "cb41acd3f9af3578",
"phone-light-compact": "dbef6e9e373e9c10",
"phone-light-spacious": "cb41acd3f9af3578",
"tablet-dark-comfortable": "0f682621b4ead393",
"tablet-dark-compact": "e6f3fece5e716a2c",
"tablet-dark-spacious": "0f682621b4ead393",
"tablet-light-comfortable": "6e398df9ca69c58b",
"tablet-light-compact": "dca75981c8675bfa",
"tablet-light-spacious": "6e398df9ca69c58b"
},
"bench": {
"desktop-dark-comfortable": "f9fa9d54db5ad45a",
"desktop-dark-compact": "7603a804cb2e23a2",
"desktop-dark-spacious": "b0c69306349fc078",
"desktop-light-comfortable": "d3e98c4c1e90686f",
"desktop-light-compact": "a04ae70088bc8b35",
"desktop-light-spacious": "4aa01a125f541429",
"laptop-dark-comfortable": "025e60ebe84e0f98",
"laptop-dark-compact": "53d6555bb7a6b5bb",
"laptop-dark-spacious": "c124d9ce1633d027",
"laptop-light-comfortable": "e8328c45f5127bb0",
"laptop-light-compact": "dc2da3534534c888",
"laptop-light-spacious": "abe5483724c987e0",
"desktop-dark-comfortable": "a9709cb47eec7544",
"desktop-dark-compact": "bfab96cb72fd6faf",
"desktop-dark-spacious": "25469178b443428a",
"desktop-light-comfortable": "2166794d643fae4c",
"desktop-light-compact": "81d8ab64407b1d9a",
"desktop-light-spacious": "d6d19f33476665af",
"laptop-dark-comfortable": "57169e64e1fb5324",
"laptop-dark-compact": "6ac903cd8a711bcf",
"laptop-dark-spacious": "a47535332e9c5de7",
"laptop-light-comfortable": "1e2b381de3556fac",
"laptop-light-compact": "6dbfb465121949c1",
"laptop-light-spacious": "1b8bfcd4bc4ed214",
"phone-dark-comfortable": "67ca3c78e6b9495a",
"phone-dark-compact": "ad6f3a9f5c8d81cd",
"phone-dark-spacious": "1e6f66e56519ba5d",
+9 -4
View File
@@ -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); });
+393
View File
@@ -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');
+17
View File
@@ -0,0 +1,17 @@
# CA trust regression fixtures
The public certificates embedded in `tests/test_ca_trust.py` exercise the October 2026 eMudhra trust-store check. They contain no private keys. `emsign-g3.pem` and `aaa.pem` are the incident's affected trust anchors; `emsign-g1.pem` is an unaffected emSign anchor that must remain usable. `localhost-leaf.pem` is a synthetic self-signed Ed25519 server certificate with `CA:FALSE`, generated solely to reproduce hashed non-CA certificates found in Ubuntu's OpenSSL directory; its private key was discarded. The test creates temporary PEM files from these public constants. The certificate fingerprints are the factual identity of each fixture.
Sources: the [incident root list](https://emudhra.we-have-a-problem.com/roots.json) and the public Mozilla root certificates distributed in Debian's `ca-certificates` package.
`incident.json` contains the four published roots, sixteen [compromised intermediate certificates and cross-signs](https://emudhra.we-have-a-problem.com/impacted-intermediates.json), and the [fourteen published server chains](https://emudhra.we-have-a-problem.com/chains.zip), retrieved on 2026-10-21 with chains refreshed on 2026-10-23. The refresh adds the two DV G3B hosts without Certificate Transparency entries and preserves the original twelve chains. Certificates are deduplicated by SHA-256 of DER; each chain lists its leaf first. The additional cross-signed `emSign Root TLS CA - G3` issuer comes from those chains and has fingerprint `3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd`. Like the intermediates, it must not be accepted as a custom trust anchor. All data is public certificate material, with no private keys.
Downloaded source SHA-256 values:
- `roots.json`: `119f32cd81a6b5a4e3c4c2df5fe07daa90dc43cafa123a62f0394f1895d92380`
- `impacted-intermediates.json`: `f67b3abe9b4879f4e91874ed041beccd71257f1db41c588c724ccf12aa90c59e`
- `chains.zip`: `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`
The offline OpenSSL regression verifies server purpose, hostname and strict certificate rules with partial-chain trust, at `2026-10-23T12:00:00Z` (`1792756800`). Fixing the verification date keeps later certificate expiry from masking a broken mitigation. All fourteen chains must verify against the affected positive-control roots and fail against the unaffected G1-only store. Adding the compromised DV G3B intermediate restores four paths, including the two new hosts, which the checker must reject. The regression makes no network requests and skips chain verification if the OpenSSL executable is unavailable; certificate-inventory tests still run using Python's standard library.
The upstream SearXNG image validation uses the same `incident.json` inventory and fixed verification timestamp, checking each stored DER fingerprint before use. See [the image documentation](../../../docs/searxng-trust-image.md) for its validation targets and source provenance.
+132
View File
@@ -0,0 +1,132 @@
{
"verification_time": 1792756800,
"roots": {
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3",
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3",
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "AAA Certificate Services",
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "emSign ECC Root CA - G3"
},
"intermediates": {
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "EM DV TLS CA - G3B (eMudhra)",
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "EM DV TLS CA - G3B (Sectigo)",
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "EM OV TLS CA - G3B (eMudhra)",
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "EM OV TLS CA - G3B (Sectigo)",
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "EM EV TLS CA - G3B (eMudhra)",
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "EM EV TLS CA - G3B (Sectigo)",
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "EM DV TLS CA - G3C (eMudhra)",
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "EM DV TLS CA - G3C (Sectigo)",
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "EM OV TLS CA - G3C (eMudhra)",
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "EM OV TLS CA - G3C (Sectigo)",
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "EM EV TLS CA - G3C (eMudhra)",
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "EM EV TLS CA - G3C (Sectigo)",
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "EM OV CS CA - G3B (eMudhra)",
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "EM OV CS CA - G3B (Sectigo)",
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "EM OV CS CA - G3C (eMudhra)",
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "EM OV CS CA - G3C (Sectigo)"
},
"certificates": {
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPWIsuPCu4zaE+GhIJJUMhMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM1\nMDlaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFy\npDPjYw8CvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpV\nbMyLpQVh3xsqRB0Bo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMEN2HWMocbuWdy1d33pH6JpMvP/V/DIS5PtZbv1Q7TXEbxrcyxg\nyuddOJscYDJJlAIwB4sH1BpbScDzkdD4tJBSwrG/1Mx82vb8YaySehNuu1+1oXNd\nAEKwaJ0mVzScy2+6\n-----END CERTIFICATE-----\n",
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcigAwIBAgIUG01Z/jOotSvB49K4K27kFjpdnWMwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMjU3MzFaFw0yNzA1MDUxMzAyMzFa\nMDQxMjAwBgNVBAMMKWR2LWczYi1ub2N0LWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEI805yWr+RxanILifET+/\nGwHsXo7dp8ghA3OgwR0Mg11Bl6iYyOzldy4iU7kM+3qfDNsUrzxvkUkJZQpYieuC\n+aOBrDCBqTAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATA0BgNVHREELTArgilkdi1nM2Itbm9jdC1lbXVkaHJhLndlLWhh\ndmUtYS1wcm9ibGVtLmNvbTAdBgNVHQ4EFgQUOlRrLQGIczrmr4H+Oak06aUmJ4Yw\nHwYDVR0jBBgwFoAU4h4v5xwZOQ5VS2LBICRXNSSKJRQwCgYIKoZIzj0EAwIDZwAw\nZAIwf4ekmRgXAxXP2AjpVY2f1bO42GC7Nnc80t4OpaWJeVdXpT+X3dOsLRwlIUm0\nYbMwAjBihTx7oHfaYB3KRnFuhKqlHYmUcOe9Hjxqs6bubZohhrqMQzPocyasDhJL\nUx3HsgQ=\n-----END CERTIFICATE-----\n",
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0": "-----BEGIN CERTIFICATE-----\nMIICPzCCAcagAwIBAgIUH0gWfBRnOiSjcduti/cukjDGAjIwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDJaFw0yNzA1MDMwMjI3NDJa\nMDMxMTAvBgNVBAMMKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3EfjVE+6fUm9gr7UoUv6N\nYPttIdILR9OMZnAdTj+RWT1Z4fp7dpC5mat1gbIo4J6EDelcOQQz5/oGeB3gFJKw\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFKzQPp6gTBOWb0TBxZbkL5zsQn3oMB8G\nA1UdIwQYMBaAFJvYZVGv5+csN60I7mkZ2b83hK4KMAoGCCqGSM49BAMCA2cAMGQC\nMARKrRRBEWY2Ix1d80DGUvC7bWENP1Q9pf6BOGF4+84UiwV5+nAuht/+hdpAanWB\nsQIwJF2dW8tpPN6lTm1kDerzG6yJAJOz4drotaTthMF2+efuv5W2FL7Dr8qZSd1z\n201o\n-----END CERTIFICATE-----\n",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "-----BEGIN CERTIFICATE-----\nMIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw\nCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy\nYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290\nIENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD\nVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg\nMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr\ngQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m\nWO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw\nLKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj\nQjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB\nBQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB\nFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi\nBggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290\nQ0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t\nP1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB\nATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg\nTroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0\n1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV\n-----END CERTIFICATE-----\n",
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5": "-----BEGIN CERTIFICATE-----\nMIICODCCAb+gAwIBAgIUbWvtwSNK/Qro223vAqyz323+VBAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM2WhcNMjcwNTAzMDEwNDM2\nWjAvMS0wKwYDVQQDDCRkdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAScdBQpNUOOCcag6YEVFccNRSFe\nUIl02iH53vDfoWPJFS005WzqF3CPDclB/kygP2gWF4pg/r5gPYdnI3/6Vdzro4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGR2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUBj2yGO/TzDOE/u/6QeUvcW8y1iMwHwYDVR0jBBgw\nFoAUrn4QXDwplsGoRycFWfHgPdTlCvMwCgYIKoZIzj0EAwIDZwAwZAIwBQOOQqG9\nccdzM/7XIlXLtota1esALaLmgEN/ZweosBIvk4xabHcERZInWVIwO45NAjBos8Ue\nNwDe1X3ahdK2lkxqBEoBMtUHJN18TnJKs2nK1gmXpMbmY5oKnAQQgCEZPA4=\n-----END CERTIFICATE-----\n",
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1\nNDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/J\nhZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p\n1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPO\nIbh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8\ngt2TlfJbHii+KNvK\n-----END CERTIFICATE-----\n",
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUXcJ4+VeNEICJ9JbBANtIyfP96EowCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRvdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATLEvRHdoPc0Z9ovgqlYmoKTHPU\nApwd6lAp0ou1G63SzuESyBzIW6Tl64CsKzlV9ggkZjX+L9/tOx97/1wmSTYvo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJG92LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUrytBb+px+b58Df+d5vP8GpTW+/wwHwYDVR0jBBgw\nFoAUNOrqDOvJCtr50MXi7Zt4yYeeG2wwCgYIKoZIzj0EAwIDaAAwZQIwNdoOUOKz\nMLV1OFEoMjBeUqvkQAwsajvVWu0A23qyyvYlosyMp8aFv0gxURW6Yj6DAjEAhe0z\nkOQmmqb0eyhjh7weF4fHtwMtLi4jNFA6M/EfPTfkqE91MN9fSin5uhua5xhk\n-----END CERTIFICATE-----\n",
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAIAJkopkEHYBrwX6LxnxCTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTUz\nOTMxWhcNNDAwNDI4MTgzMDAwWjBAMQswCQYDVQQGEwJVUzEUMBIGA1UECgwLZU11\nZGhyYSBJbmMxGzAZBgNVBAMMEkVNIEVWIFRMUyBDQSAtIEczQzB2MBAGByqGSM49\nAgEGBSuBBAAiA2IABC1W6Cmm+4LGSkaX2WCvlR6QZ66R+SbTQwbaSKiq63hptqZ+\nbSdCbgRCqgOzbhmI3/Y2UU7hzwf1uks/8qBmRa59MdWGatMao9JG6wsxd+6Zy3Vm\nxRbOdICKZo+R8MR/yqOCAZUwggGRMB8GA1UdIwQYMBaAFOE6qyF88eNNVK7Ucrf9\ni1lCIJBUMB0GA1UdDgQWBBSmJV6AqU2385Z8G8eR35K87kvrXzAdBgNVHSUEFjAU\nBggrBgEFBQcDAQYIKwYBBQUHAwIwXAYDVR0gBFUwUzA5BgsrBgEEAYOOIQEAATAq\nMCgGCCsGAQUFBwIBFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMA0GCysG\nAQQBg44hAQJ4MAcGBWeBDAEBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRVZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwBy1wTnzH4lXDTy2PD91ysLjvnlVN3JbJmc1BIMEU8TOLkYVHibMc\n9HeyonikFSfmAjEA6uE3QRxlrZTz/1/lXPTEMUhoauAtycagtlNFp79kjhvNa2/f\nAal1GNxsOutLnr0Y\n-----END CERTIFICATE-----\n",
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO\nL4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2\nPM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE\nwqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM\nQkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ\n7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl\nbN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V\nGUyRCEIDhF5TBUuKF4E3kf1h\n-----END CERTIFICATE-----\n",
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuGgAwIBAgIPVEt/kiwxriIKSpIpFvbmMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2Mzg0\nMFoXDTQwMDQyODE4MzAwMFowPzELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRo\ncmEgSW5jMRowGAYDVQQDDBFFTSBPViBDUyBDQSAtIEczQzB2MBAGByqGSM49AgEG\nBSuBBAAiA2IABLnw4GNHAqacpqrdRDz9YsQbm6SyCSRpfXl052kaEm+5avs+NML9\nO8X0gwYfJl3CpYQ2NHMjUQQ2so8E84YjYwZ5gOJQPgJTLWVGI1pxZaJZoe9Jb3Qb\nX97neHII5Lj676OCAYswggGHMB8GA1UdIwQYMBaAFGh0A/iFyo1UUFcdFuDI3kUN\nq80tMB0GA1UdDgQWBBTub3MpjW+/p7Mbw5U09hVWRHhmtzATBgNVHSUEDDAKBggr\nBgEFBQcDAzBeBgNVHSAEVzBVMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEW\nHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDgYMKwYBBAGDjiEBAoFIMAgG\nBmeBDAEEATB0BggrBgEFBQcBAQRoMGYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3Nw\nLWMuZW1zaWduLmNvbTA+BggrBgEFBQcwAoYyaHR0cDovL3JlcG9zaXRvcnkuZW1z\naWduLmNvbS9jZXJ0cy9FTU9WQ1NDQUczQy5wN2MwNgYDVR0fBC8wLTAroCmgJ4Yl\naHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290Y3NjYWczLmNybDAOBgNVHQ8BAf8E\nBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNoADBlAjEA8yPg\n8sntOhLwV2QXJ5LEaKSpo/fnE3uOgTadK73fDhyjiqr0Io9GVuQTYqBPbQ2TAjBg\neuApmBS486+iei03lRrp139dPw6k28jjGNvWsAU7A1N86J1zPJVDEyVH+nMAWC0=\n-----END CERTIFICATE-----\n",
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db": "-----BEGIN CERTIFICATE-----\nMIICSTCCAdCgAwIBAgIUO7oRaIOhMWD0jFgvxusAGNi5CI0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMzE0NDhaFw0yNzA1MDUxMzE5NDha\nMDgxNjA0BgNVBAMMLWR2LWczYi1hYWEtbm9jdC1lbXVkaHJhLndlLWhhdmUtYS1w\ncm9ibGVtLmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJ0yLMf28vw2E6pO\nc74doinU47pwTYD6xZ60nkrGkqI05DxVKXblos+V5S0+bgDjT2qw1F39EW9EUj8b\nNQmmqv6jgbAwga0wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0l\nBAwwCgYIKwYBBQUHAwEwOAYDVR0RBDEwL4ItZHYtZzNiLWFhYS1ub2N0LWVtdWRo\ncmEud2UtaGF2ZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBTruPPaZ1PiEFfpqdLo\n8Ra5Ah5a2jAfBgNVHSMEGDAWgBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjO\nPQQDAgNnADBkAjBUTUTymJylZiZRV/RqMayZdUPJKRDIcn9+sUPpG66AjVgx8M+h\nDGzxxgeroOSxzSwCMBM/r1y7g371RI10nDU16T0WEAO/Udqyyuc8mJ2LCQ+Gxv3c\njU2NXGQLsvosv6T0ww==\n-----END CERTIFICATE-----\n",
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIRANj4aF5PK9nYJAKoWhwetzEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQl26haZNOX2gsQUunqVG5Ie+/qnJ2daKDF6tz+73Wmm7XW/AH0K/vKbrQL\nzWTAdn42/9Bdl6gTxaoMbJUw55z18IZkaPE4BkIKpchRhAseJSVA55DktlTDhS7W\nSvHoZX2jggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUPtwg2YlGmId7hz/YgUeyXBXYRwYwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAJNFuAFW9Gnw/8+kCO4KIlZdnM1Sh2u+8cK0EYAHhXpb5FlGvYkcARigo5oS7VoM\n7FOYlTueV6yICZKNAYawZPJPhNCGK+rp3cQBEz6d0n8OZEYZov9lb3ll8ORgSDuf\nFVIW6Gagg/UnOamLBTOR8eD5V73SCme/B2cHUDw+m5E8cc+0Ujzz3Cn3ex5JrhXk\n2T3s8PoVzEmhTgGboFfcWO1EYhPPrYwdxPYRQIXAXUXhLEOVNL9ASbZJLFI8E1IM\nm7NwxVN2s38jWpaCUaaF0tg7hfiy7oe13rzJfHsdqldJVTTUTPWVx8TC4ju0VLet\nbAlwhwFT6AP8uIVMGUHNw24=\n-----END CERTIFICATE-----\n",
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcegAwIBAgIULuWnWW6kFV6QZxp2ls8r18NCjXkwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEb7+5g2YHNYwH2eQERrZc\nTmVuf/RmvzsGGMSwXmZke6vn35Ut5HME7UOOU3Muy+BSfjfpo6fjL5KOs3Ndz67d\nOKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBQ7oBzq3jpTuvntbIkzMV12Ps3O0TAf\nBgNVHSMEGDAWgBQ06uoM68kK2vnQxeLtm3jJh54bbDAKBggqhkjOPQQDAgNoADBl\nAjEA/Y7AZ4M2MYbN5Od9tREaUfFjZKoOjczfIKPjKNMjtGxTdL9WUXvAT4QDgktn\n/IJdAjAbORTkAXD5hggyG6K7p0MBz3lFZJb2l/LndZlrttJWBLP8QGrc44hrZZtK\nIq0d1dE=\n-----END CERTIFICATE-----\n",
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAOlCI73pL5FOWrmsq/2sNxAwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK56Ks1xwy\nKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDftDIVOTMEv\nTPhkbQ2jggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQICMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJuMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQALRoOSvkzRa+tN5Rje9W5GiXd/qsicd9N6Gnh0SWC7VyWH+HycIqAnFCMYZznj\nCeGusomFYHE42SHWr85JDIbgzy6y3dO4i9n9w0Bx7yrXWBbDzFS8VgA3+EY4Cg7y\nmtoC3RPOJVtaXXPZ+68X804g1Mj+3vCSHj2lNPVgco0OzSIOJ/KMg1R9MLBkiKzb\nbERAGZHejY0HBhYd76IINZN8z6bBNah2UdFYxsOisLynPQUkol6gRLum024/FzVl\nCCDfnUusrkr+mAfPAvD/Vb8bhpA7jYtepuU+2mIdShj5lLlvAwZphn+ypWycuo2x\n0k9mldz2dqI9nhCiY+TTIOOR\n-----END CERTIFICATE-----\n",
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAJHzR/Glt9OZYrleDuTimdEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFypDPjYw8C\nvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpVbMyLpQVh\n3xsqRB0Bo4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECATANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECZDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAr7b6Mx11TIJgVzHt6EH5qGNWeiiqoMrp8E5/k8s+jvupx1n083pZ5XOzUzND\nwEUe0W4C4FIGUHNQ4XDwqOw0yK42xqm2dvCKgsko2UvDlT0haZVtTCeWMQOe5J/J\nziaMfZwsZNfZ3aaI6XPtWDZtwY8AaNfyaeCpbtm5GcGnyNWk4jSNpbtpo6NjfXuP\nCFOm3rTjJ4zytP7Tso6iMm0L01cYFOlTmoPv4515R2r3SPnKDtijoB/D/vgxzojv\nDafgyG9afX8+laO9NbMMz4sqC1vZx3984i/sBNFiugm3Vvdq5OdJRjuGC1QILIs6\ndg/K3i8VQqRpbfaM9kIlMzZ3dQ==\n-----END CERTIFICATE-----\n",
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed": "-----BEGIN CERTIFICATE-----\nMIICNzCCAb6gAwIBAgIUPfthO0PYGAYKs9lQ4gp1IOkOr2MwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGV2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPZPwlA3io/AX/pMwu0dNl9WMxrr\nC9j/xMLHgG72cWVzoX62REmvp9zyXQ04kHP9/sW6KnCcm5ZZ5i4YoJbDPASjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZXYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBSvX4QR4ERW5uNUaMq0yYzXCZf0ZjAfBgNVHSMEGDAW\ngBQ+3CDZiUaYh3uHP9iBR7JcFdhHBjAKBggqhkjOPQQDAgNnADBkAjACy/uJR01M\n/O9IEZyRHX1LSU28bmg542K0IX9jDRTrZXUqkDWfiZIL7xfnrRrBpHoCMFbBIaOa\nWwWtFtDg9ZqQvktsWS950u5qfW1sChgWNya/E3IMKFgtoHm2xrZsmfwLSQ==\n-----END CERTIFICATE-----\n",
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAI1aSBGTnvRH13ERzdmEmukwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv\n1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSf\nHAKycO1no4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECAjANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECbjBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAHlbhZpjhkPEAQVhPHRtBDaHqiisVD20pQ1TUeSx3l62Qe7NQ0b7vWQy7DN0i\nKOu7BGpP5EnIjvXbz9FhpfPJ4FhKEt3tQ51rqXz2oD8vRaryvExWPobDUz3j7HN6\nOzBsVMIe3PD7AAkpky0x8zMSi/ep5R9OMzybmNptbtppyuB5ytgq+h7lQdRIP+CQ\nTaJX3qTgfMqzntYre5GljmEB5hU2/rSn8zoSU2APkJidlZFpghtgbD4I2VRHZvCw\nFFr/SrFM9pwrgbJNZ1MtyTSVgI5NlNb0aMfLR+/r6jJt3ROeY2yh2QQ2dx+Yqgdb\nuL5ChFWOQU+ErSDnUq7yr+pjBQ==\n-----END CERTIFICATE-----\n",
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "-----BEGIN CERTIFICATE-----\nMIICKTCCAa+gAwIBAgIQDnYGcvFDRZ/I/gqwvAXjlDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjQwMjA4MDgx\nOTMyWhcNNDkwMjA3MDgxOTMyWjBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11\nZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3Qg\nVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARfD9w/q1cWxL6D9bMG\nH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8mWO/RwXQy10DCSxT8vSIJClHKp+Ao\n4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFwLKYf8EdDXAyjQjBAMB0GA1UdDgQW\nBBThOqshfPHjTVSu1HK3/YtZQiCQVDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/\nBAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjBhCFTkly7Yl7+s4hiw+k6ZJTXT2AiI\nrvmsmfqPr5E7ecwXb6kXA5LTFd6rjwTuV3sCMQC9w6ZSbVq1mveMO6dKFziOzClT\nKCT2RFT6yXtq6nzgRICgBmiXbJAXsmZ8yAwjyWc=\n-----END CERTIFICATE-----\n",
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUU3mgjls/8ZJ8upmukdlSQR1IF/UwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATK3sfcOxxtM2Vdq9Qbw/GR\nNsHlJcnqINEInXO3fkWhw/FYT80hkqGk0jSN/EKib3YZJsr5GyfkYcpEiA2rwMgm\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFDggfJFz1eeToKmzFBSYkMCvpAu0MB8G\nA1UdIwQYMBaAFOIeL+ccGTkOVUtiwSAkVzUkiiUUMAoGCCqGSM49BAMCA2gAMGUC\nMDqDDOt7K313QIH0rhn/ZKnzFu8+/9W8OlTuFtDfVCTRDfRmqdyfsCfLsEk7xqHq\nuQIxAJqyLERABmU98U46OCuT5bp5h0ytuSEIGM7orTpKci5S1u1B/AOmwv162U7R\nTcVPHA==\n-----END CERTIFICATE-----\n",
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "-----BEGIN CERTIFICATE-----\nMIIDxTCCAq2gAwIBAgIRANal72E7OQ8y75zfNJ93KEEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEaMBgGA1UEAwwRRU0gT1YgQ1MgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAS58OBjRwKmnKaq3UQ8/WLEG5uksgkkaX15dOdpGhJvuWr7PjTC/TvF9IMG\nHyZdwqWENjRzI1EENrKPBPOGI2MGeYDiUD4CUy1lRiNacWWiWaHvSW90G1/e53hy\nCOS4+u+jggEsMIIBKDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU7m9zKY1vv6ezG8OVNPYVVkR4ZrcwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwMgYDVR0gBCsw\nKTAIBgZngQwBBAEwDQYLKwYBBAGDjiEBAAEwDgYMKwYBBAGDjiEBAoFIMEMGA1Ud\nHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FBQUNlcnRpZmlj\nYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYYaHR0\ncDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBgGbZ+NDkn\n2Fr5dCVs/51+6cT5iEPP1stglX4TI2UaNh9LmPdltxXw3crV8gK5L8i+d+ScGibF\ny0obtBHNTldXDgLnAHIzY3n+pceyKj7O2ae9CYnyuu5RBifeI3XYKowWE+oXnVGF\nOy0CvCJ/JsdP6aYgoCa9hGnAYicYQQL6MOMQZ5PuhcSM8t4giIzrpgrEkUP93GYE\nemDG5Z8BYwKs80lLeQNBnwg/VHJ3iEF/o84RXYwrlrKqJ+33C094o6LNikMJCqOK\n/WYHZSR8Xyhf8GXFAj5pZuI6Bcpug2V/E7i42j8OBhc25aNYDqK+B0qmb90sjNUB\ng3tJeSIb/599\n-----END CERTIFICATE-----\n",
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "-----BEGIN CERTIFICATE-----\nMIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG\nEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo\nbm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g\nRzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ\nTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s\nb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw\ndjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0\nWXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS\nfvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB\nzhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq\nhkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB\nCUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD\n+JbNR6iC8hZVdyR+EhCVBCyj\n-----END CERTIFICATE-----\n",
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52": "-----BEGIN CERTIFICATE-----\nMIICQjCCAcegAwIBAgIUMH3z62Z6agEULBZqrfhpw9/ZpCAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQxWhcNMjcwNTAzMDIyNzQx\nWjAzMTEwLwYDVQQDDChkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAMPpHsQqEqIerrj/durO\nEN7gkUVJSVgIPN6+VJ2sEGXDyqXAN1fnNzGuASP/4WEr0RIhht3cYvuUYp2SYofr\nw6OBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBT8CNQz4OLVnIwrQYZBF+++9jTCvzAf\nBgNVHSMEGDAWgBSufhBcPCmWwahHJwVZ8eA91OUK8zAKBggqhkjOPQQDAgNpADBm\nAjEAp3ZLdfBvKcZxgR2p8LZaxWzP6uB7zU/EZ9BA49Ds2P5ByYkRodwK5qcwt4J/\nVL/vAjEAnnxrcrDX7SzoJxuho604GBh6eyBLJLCdmcy+qb+LA3u5oxP/OjAzCA4x\nv1kw6Tm4\n-----END CERTIFICATE-----\n",
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "-----BEGIN CERTIFICATE-----\nMIIDZjCCAu2gAwIBAgIPbK2jZ/scEACIEg6iAtVoMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNjAw\nMTJaFw00MDA0MjgxODMwMDBaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVk\naHJhIEJWMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIB\nBgUrgQQAIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK\n56Ks1xwyKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDft\nDIVOTMEvTPhkbQ2jggGWMIIBkjAfBgNVHSMEGDAWgBThOqshfPHjTVSu1HK3/YtZ\nQiCQVDAdBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowHQYDVR0lBBYwFAYI\nKwYBBQUHAwEGCCsGAQUFBwMCMF0GA1UdIARWMFQwOQYLKwYBBAGDjiEBAAEwKjAo\nBggrBgEFBQcCARYcaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTANBgsrBgEE\nAYOOIQECbjAIBgZngQwBAgIwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1PVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNnADBkAjBAk+5BizdJhrED3wb7F2Gm7bWI60dlNd075nghT2TrR4NWKZtJZcbc\n28xxIY/NONICMF6mPdsjosaqmvEMWqKcBh4vsIvXSKnQj5o8zrFB4oKS+accjYTe\n8Dz3bQjauvy9uw==\n-----END CERTIFICATE-----\n",
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb6gAwIBAgIUY1R/PHrtXShV0uhLJ5EyjsHvMi0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGR2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFpE24ACBFHQLzpg+7D767thJ3+Q\nNIoJJugUMT/OftEz6G6uDdxlS+9VfXYBDglHjJirjEPFNglOGJoQNQuiz7Gjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZHYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBT4r5v4etYNuG21BqBqs273OkcHiDAfBgNVHSMEGDAW\ngBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjOPQQDAgNpADBmAjEAlxNF+/8l\nAPpgibawACy15yn5bcvTksz2Zw0R9z9nUK9e3MSSk+hXagg7LVb7V4NtAjEA4qyR\nlaFPkaokrnTjqQieq8CgDQ6tIz3FjBvj3D7vkhKXHaml6zD2zT5ih2Y+lD2h\n-----END CERTIFICATE-----\n",
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuCgAwIBAgIPeBiTP4+FtGGaKzX7qD0KMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2MDgy\nN1oXDTQwMDQyODE4MzAwMFowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRo\ncmEgQlYxGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUOb\nNKBDHK/+W/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jord\nd7EAZPKr5eBQo4IBizCCAYcwHwYDVR0jBBgwFoAUaHQD+IXKjVRQVx0W4MjeRQ2r\nzS0wHQYDVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMBMGA1UdJQQMMAoGCCsG\nAQUFBwMDMF4GA1UdIARXMFUwOQYLKwYBBAGDjiEBAAEwKjAoBggrBgEFBQcCARYc\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTAOBgwrBgEEAYOOIQECgUgwCAYG\nZ4EMAQQBMHQGCCsGAQUFBwEBBGgwZjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3At\nYi5lbXNpZ24uY29tMD4GCCsGAQUFBzAChjJodHRwOi8vcmVwb3NpdG9yeS5lbXNp\nZ24uY29tL2NlcnRzL0VNT1ZDU0NBRzNCLnA3YzA2BgNVHR8ELzAtMCugKaAnhiVo\ndHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3Rjc2NhZzMuY3JsMA4GA1UdDwEB/wQE\nAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMAoGCCqGSM49BAMDA2kAMGYCMQC/zr03\nYEELsNKqzUtH2dVFi4BaKFdyrr/6G1dIWp8KAVgsw2/u4lyyboUDaC9jDvICMQC9\nHTxku84tNX9dmHOtH7aC2Nu/JM0jTf6KjZAvovCJzzv1IFWSZpVLZibynMLXOrM=\n-----END CERTIFICATE-----\n",
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "-----BEGIN CERTIFICATE-----\nMIIDwzCCAqugAwIBAgIQQeMrc6wYXWzg10gLrPhOaTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYx\nGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACID\nYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUObNKBDHK/+\nW/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jordd7EAZPKr\n5eBQo4IBLDCCASgwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD\nVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMA4GA1UdDwEB/wQEAwIBBjASBgNV\nHRMBAf8ECDAGAQH/AgEAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMDIGA1UdIAQrMCkw\nCAYGZ4EMAQQBMA0GCysGAQQBg44hAQABMA4GDCsGAQQBg44hAQKBSDBDBgNVHR8E\nPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9BQUFDZXJ0aWZpY2F0\nZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAGGGGh0dHA6\nLy9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAIh0OzWsdBYhY\n7+yxPGyosRL+uUSN6/Mrd30fBuougs5FVWQ3cRgLhImnUUgXADY9buugWhMmfT3c\nyU5T1MFXZXClCILm5mfg+oqm/ikUWJl3Ayf2GvYf2O++6vzqvkn418nI5Lpdt0O7\nLi524YS/3CZHDA+zZQ7rrHpZXgQ9Hmp8AUQ4FneaGjNanFoYxiZxwCyW5z4u7AoK\n0bmOVqKtcDFoVNz5++G9TYDJdhWOXmKKgzqRo1MK0PjazNT7MGHqknP93SqMVJ1g\nmX/qNWxYteAjlvFanQxBNX/+TRxDjuFsEHO/7BvqSpIIck0UEEfizhN8zNpWLbcl\nEqVgl0g39A==\n-----END CERTIFICATE-----\n",
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUUYu3O8bpN1GPvCIHqWpT/FYUNDowCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASjDLpnVdmvXM6R3UB2o42a\n1GnrYfxRXo7UII36mBl9mIS7p9MkAryhPdxitFHkgTrrbypVtzpydNtJIGGGXUwE\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFEVBY1ecTN9bLMDl4ifdnN2WZL+WMB8G\nA1UdIwQYMBaAFD7cINmJRpiHe4c/2IFHslwV2EcGMAoGCCqGSM49BAMCA2gAMGUC\nMQCyvr9gktgmtsuTuFleFV+YExNNKLf3GZieuUprQcERrzh798oYKavTqZeYf9Kz\nRhECMCVs1B7KPh7/lgHRly14Z8RJA8ApR7e2cRREW5uPKFOSDlZ/RIezxsfEzsQ4\nlAODeA==\n-----END CERTIFICATE-----\n",
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcegAwIBAgIUTMUrGzSU+0BySFdrN3ALZq9Spv8wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEglClDbotLFFeY9SpryT4\nk958tvriY2DDnaPsmjQaecRhB6+L/8hLw6FMt+HT8SLvjIZnFY+AH92cBjedcNST\nAKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBSVtGbn6xUT1iosUeaGA15Nq640aTAf\nBgNVHSMEGDAWgBSmJV6AqU2385Z8G8eR35K87kvrXzAKBggqhkjOPQQDAgNnADBk\nAjAg7b5bIK9X50QEX0U6G8fQh12BnFQ079Iluzd0Y2TJWCjtVXZi3nOciM8RzNKM\ntZECMCBf0JOyMt8RNS93Wj8kYbWkCtoKoUrqx7G3JTfGReKci+DdTvkF8/3F6doi\n9KB22Q==\n-----END CERTIFICATE-----\n",
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3\nNDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZ\nfjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+\nN7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44c\nNVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR79\n4rSb9vGqxZzelQth\n-----END CERTIFICATE-----\n",
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUOcw5N4XGLiB/3GvB05bJuGoZPR0wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRldi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQo7U02MAUO8EEMomXwIl+kSQzm\nb+nvXKIZ/hC4rZ1GpXchv4SzTrV1SB/Qg+pD6OBEXbBBnPmQNkePNQTlujqRo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGV2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUqxe+7qS+MxNyioJZhQsL5MEEw6swHwYDVR0jBBgw\nFoAUpiVegKlNt/OWfBvHkd+SvO5L618wCgYIKoZIzj0EAwIDaAAwZQIwC/CxSIov\nwcar3sV98B8cL+QuR2kkJdLdXsFKzg/HDSJ1MOjqTGm924LB6r9nl0f7AjEAzcwr\nWQOYY9eycqIz+LG2daYS6LOctj+CjoAk/CLugWPxGxp4Aerfau2P1z1h5jAA\n-----END CERTIFICATE-----\n",
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "-----BEGIN CERTIFICATE-----\nMIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb\nMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow\nGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj\nYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL\nMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE\nBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM\nGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP\nADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua\nBtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe\n3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4\nYgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR\nrOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm\nez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU\noBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v\nQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t\nb2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF\nAAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q\nGE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz\nRt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2\nG9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi\nl2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3\nsmPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==\n-----END CERTIFICATE-----\n",
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu2gAwIBAgIQAMfEHv6S2CXwXq6EGkqCwTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTYw\nMzMxWhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRVYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEJduoWmTTl9oLEFLp6lRuSHvv6pydnWigxerc/u91ppu11vwB\n9Cv7ym60C81kwHZ+Nv/QXZeoE8WqDGyVMOec9fCGZGjxOAZCCqXIUYQLHiUlQOeQ\n5LZUw4Uu1krx6GV9o4IBlTCCAZEwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFD7cINmJRpiHe4c/2IFHslwV2EcGMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBcBgNVHSAEVTBTMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAngwBwYFZ4EMAQEwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1FVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNpADBmAjEA9Jq999oaOUKhFwopw33iCmt0OHIn3gjT43GfuOHR/YckXdwoNdDA\nlIEhz0NgRO3/AjEAvY/a3OMvktIZLYjn+WLKmp0QhrIMV+e6EpS7j4GVeOKouNp6\nMN/P+ByGeiRMghzB\n-----END CERTIFICATE-----\n",
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "-----BEGIN CERTIFICATE-----\nMIICKDCCAa2gAwIBAgIQAY/OySfBFvWcmX7cDM0uKzAKBggqhkjOPQQDAzBVMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEfMB0GA1UEAwwWZW1TaWduIFJvb3QgQ1MgQ0EgLSBHMzAeFw0yNDAyMDgwODMz\nMTRaFw00OTAyMDcwODMzMTRaMFUxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVk\naHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBD\nUyBDQSAtIEczMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAESl9kM5D0VA/Og76dz5Xz\nw1hnGQ4fSR9993/qBkkvI48pXQVKOdyHCgAZThgWejQ8OpFRiTISjpabn4tfRFOY\nuzhApR/XxGbkg9X+2cEtbyghF9CdoF91W22V/R2EVe4xo0IwQDAdBgNVHQ4EFgQU\naHQD+IXKjVRQVx0W4MjeRQ2rzS0wDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wCgYIKoZIzj0EAwMDaQAwZgIxAK9OlACrFod3UgWY1ai+DF9sGFhahU59\nrv4jJyV01QaxGn0HErZ8gjHkngqe+tcFogIxAPTdW0hBmrGjhxBWdv9mZNdVFiJ4\nlrZ3ahvimsLhTQpnDBgiGKv6Ov1Fx/CE6yjIAg==\n-----END CERTIFICATE-----\n",
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe": "-----BEGIN CERTIFICATE-----\nMIICODCCAb6gAwIBAgIUPXjC97k7buQHZ7p5ehDU8QNyrR4wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzdaFw0yNzA1MDMwMTA0Mzda\nMC8xLTArBgNVBAMMJG92LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABA1haD+95Albk0UN6YtDCPpqevTv\nYUmEhsIVpb/hnhsbBlH0yLthSE2JLH14ywiQJXttoc/pDWC9eoICW6CJXL2jgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkb3YtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBQUhzc4s202Hn62G0srmgguYdY2MTAfBgNVHSMEGDAW\ngBSb2GVRr+fnLDetCO5pGdm/N4SuCjAKBggqhkjOPQQDAgNoADBlAjEAoJ6d4zzA\nxE349yGHHrwKZpVCC/HBFutcOpOfzQoe8ikbmaaBTdtKai5TEllwt3dBAjBeHGiX\nVheDhJfewbei99cw1MfYB5tPwdfuY5pe1MJz0Tg9zJ4JgxBvF6LTT0OOXZ4=\n-----END CERTIFICATE-----\n",
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIQBfB4dS3s56G7hZXTAxZxcTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5j\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQtVugppvuCxkpGl9lgr5UekGeukfkm00MG2kioqut4abamfm0nQm4EQqoD\ns24ZiN/2NlFO4c8H9bpLP/KgZkWufTHVhmrTGqPSRusLMXfumct1ZsUWznSAimaP\nkfDEf8qjggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUpiVegKlNt/OWfBvHkd+SvO5L618wDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAIFfHiaMSjnrdZygRYV4ekRBmG7ybcFk4hGQWMMyMFi0cAEk2+dGfz5bl8IaIbLq\nz+uVJzhOgVEX7WqIfgbWfFsfPYHYjPHBmy7SlZ3BwA86IQ6qF/etpX2/qL9A5yRN\nN3nVowUpaqV6H3aCpkAh3wR2ZNkvAbLDvPz3J0l+sEWjWv+ZISOV4R+hXDTdPXV0\n1lKxnbwqEsdo/sE2QkCDKwFrZh/0uKjWspydJj+jSaDLTrGNx50Dgmn7VEcD1ZI5\nFXjknVUiO25YlmS1dtVZSBbyUKSjlrP/yDwPcZunoAsmCAAtCKsFLviLaymzvsKI\neSSDGxpSySp2VCxompGDk8g=\n-----END CERTIFICATE-----\n"
},
"chains": {
"dv-g3b-aaa-emudhra.we-have-a-problem.com": [
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424",
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
],
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com": [
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db",
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
],
"dv-g3b-emudhra.we-have-a-problem.com": [
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7",
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
],
"dv-g3b-noct-emudhra.we-have-a-problem.com": [
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70",
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
],
"dv-g3c-aaa-emudhra.we-have-a-problem.com": [
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52",
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72"
],
"dv-g3c-emudhra.we-have-a-problem.com": [
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5",
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
],
"ev-g3b-aaa-emudhra.we-have-a-problem.com": [
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145",
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512"
],
"ev-g3b-emudhra.we-have-a-problem.com": [
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed",
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
],
"ev-g3c-aaa-emudhra.we-have-a-problem.com": [
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753",
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f"
],
"ev-g3c-emudhra.we-have-a-problem.com": [
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191",
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
],
"ov-g3b-aaa-emudhra.we-have-a-problem.com": [
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0",
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699"
],
"ov-g3b-emudhra.we-have-a-problem.com": [
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe",
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
],
"ov-g3c-aaa-emudhra.we-have-a-problem.com": [
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906",
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489"
],
"ov-g3c-emudhra.we-have-a-problem.com": [
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8",
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
]
},
"cross_signed_roots": {
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "emSign Root TLS CA - G3 (emSign ECC Root CA - G3 cross-sign)"
}
}
+36
View File
@@ -0,0 +1,36 @@
"""Tests for src/activity.py — in-flight heavy-image-op counters (safe restart)."""
from src import activity
def test_image_op_counter_increments_and_decrements():
assert activity.heavy_image_ops_active() == 0
activity.begin_image_op("a")
activity.begin_image_op("a")
assert activity.heavy_image_ops_active() == 2
activity.end_image_op("a")
assert activity.heavy_image_ops_active() == 1
activity.end_image_op("a")
assert activity.heavy_image_ops_active() == 0
# never goes negative
activity.end_image_op("a")
assert activity.heavy_image_ops_active() == 0
def test_image_op_context_manager_and_busy():
assert activity.busy() is False
with activity.image_op("/api/image/inpaint"):
assert activity.busy() is True
snap = activity.activity()
assert snap["image_ops"] == 1
assert snap["image_ops_by_name"].get("/api/image/inpaint") == 1
assert activity.busy() is False
assert activity.activity()["image_ops"] == 0
def test_image_op_released_on_exception():
try:
with activity.image_op("boom"):
raise RuntimeError("x")
except RuntimeError:
pass
assert activity.heavy_image_ops_active() == 0
File diff suppressed because it is too large Load Diff
+80 -4
View File
@@ -123,12 +123,88 @@ def test_blocks_listing_the_data_directory_itself():
_resolve_tool_path(DATA_DIR)
def test_blocks_app_state_reached_by_relative_path(monkeypatch):
"""The data directory is a relative hop from the checkout root, so
def test_blocks_app_state_reached_by_relative_path(tmp_path, monkeypatch):
"""The data directory is a relative hop from the agent workspace, so
confinement cannot depend on the model supplying an absolute path."""
monkeypatch.chdir(os.path.dirname(DATA_DIR))
current_execution = importlib.import_module("src.tool_execution")
data_dir = tmp_path / "data"
data_dir.mkdir()
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
workspace.mkdir()
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
with pytest.raises(ValueError, match="application state"):
_resolve_tool_path(os.path.join(os.path.basename(DATA_DIR), "sessions.json"))
current_execution._resolve_tool_path(os.path.join("..", "sessions.json"))
def test_relative_path_resolves_in_agent_workspace_not_server_cwd(tmp_path, monkeypatch):
"""With no workspace bound, bash runs in the agent workspace, so a
relative write_file/read_file path must land there too (#6679), not in
the server's cwd, which is outside the allowed roots."""
current_execution = importlib.import_module("src.tool_execution")
data_dir = tmp_path / "data"
data_dir.mkdir()
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
workspace.mkdir()
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
server_cwd = tmp_path / "checkout"
server_cwd.mkdir()
monkeypatch.chdir(server_cwd)
token = current_execution._active_workspace.set(None)
try:
assert current_execution._resolve_tool_path("test.txt") == os.path.join(
os.path.realpath(workspace), "test.txt"
)
assert current_execution._resolve_search_root("sub") == os.path.join(
os.path.realpath(workspace), "sub"
)
finally:
current_execution._active_workspace.reset(token)
def test_request_without_workspace_seals_the_agent_workspace(tmp_path, monkeypatch):
"""With no workspace, the native file tools get the folder bash/python
already run in (#6679) instead of no root at all, and a relative path
lands there while app state next to it stays out of reach."""
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority
from src.agent_runtime.resource_binding import resolve_filesystem_operation
current_execution = importlib.import_module("src.tool_execution")
data_dir = tmp_path / "data"
data_dir.mkdir()
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
workspace.mkdir()
(data_dir / "sessions.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
authority = RequestAuthority("request", "alice", "s", "", (OperationGrant("write_file"),))
assert [r.path for r in authority.resource_roots] == [os.path.realpath(workspace)]
bound = resolve_filesystem_operation(
ExactOperation.normalize("write_file", "test.txt\nhello"), roots=authority.resource_roots)
assert bound.bindings[0].resource.path == os.path.join(os.path.realpath(workspace), "test.txt")
with pytest.raises(ValueError, match="outside the sealed roots"):
resolve_filesystem_operation(
ExactOperation.normalize("read_file", "../sessions.json"), roots=authority.resource_roots)
def test_symlinked_agent_workspace_is_not_sealed(tmp_path, monkeypatch):
"""A symlinked agent workspace must not become the file-tool root:
agent_cwd() and create_directories() refuse the same layout."""
from src.agent_runtime.authority import OperationGrant, RequestAuthority
current_execution = importlib.import_module("src.tool_execution")
data_dir = tmp_path / "data"
data_dir.mkdir()
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
(elsewhere / "notes.txt").write_text("private", encoding="utf-8")
try:
workspace.symlink_to(elsewhere, target_is_directory=True)
except OSError:
pytest.skip("cannot create symlink")
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
authority = RequestAuthority("request", "alice", "s", "", (OperationGrant("read_file"),))
assert authority.resource_roots == ()
def test_blocks_app_state_reached_through_a_symlink(tmp_path):
+2 -1
View File
@@ -281,7 +281,8 @@ async def test_browser_approval_application_binding_is_exact(producer, field, va
original = authority()
content = '{"action":"session_info"}'
approval = approval_for(original, "private_browser", content)
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=())
# resource_roots=None re-seals the root for the changed owner.
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=(), resource_roots=None)
_, result = await dispatch(changed, "private_browser", content, approval)
assert result["exit_code"] == 1 and not approval._claimed
+387
View File
@@ -0,0 +1,387 @@
"""Offline regressions for incident roots, unaffected roots, and CA directories."""
import contextlib
import hashlib
import importlib.util
import io
import json
import os
from pathlib import Path
import shutil
import ssl
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("check_ca_trust", ROOT / "scripts/check_ca_trust.py")
checker = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(checker)
INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text())
class CATrustTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Public certificate data is embedded below. Tests create temporary
# PEM files from these constants; no fixture contains a private key.
cls.temporary = tempfile.TemporaryDirectory()
cls.fixtures = Path(cls.temporary.name)
for name, pem in PUBLIC_CERTIFICATES.items():
(cls.fixtures / name).write_text(pem)
for fingerprint, pem in INCIDENT["certificates"].items():
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != fingerprint:
raise ValueError("incident fixture fingerprint mismatch")
(cls.fixtures / f"{fingerprint}.pem").write_text(pem)
@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()
def test_unaffected_emsign_root_remains_usable(self):
checker.check_bundle(self.fixtures / "emsign-g1.pem")
def test_verbose_python_check_reports_and_rejects_affected_user_bundle(self):
# A fresh interpreter must see persisted user packages, independently
# of pytest's imported certifi/pip modules and any active test venv.
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
env = dict(os.environ, HOME=str(home), PYTHONUSERBASE=str(home / ".local"))
env.pop("PYTHONNOUSERSITE", None)
env.pop("PYTHONPATH", None)
python = getattr(sys, "_base_executable", sys.executable)
site_result = subprocess.run(
[python, "-m", "site", "--user-site"], env=env, capture_output=True, text=True,
)
self.assertEqual(site_result.returncode, 0, site_result.stderr)
user_site = Path(site_result.stdout.strip())
for package in ("certifi", "pip", "pip/_vendor", "pip/_vendor/certifi"):
directory = user_site / package
directory.mkdir(parents=True, exist_ok=True)
(directory / "__init__.py").write_text(
"from pathlib import Path\ndef where(): return str(Path(__file__).with_name('cacert.pem'))\n"
)
application_bundle = user_site / "certifi/cacert.pem"
pip_bundle = user_site / "pip/_vendor/certifi/cacert.pem"
for bundle in (application_bundle, pip_bundle):
bundle.write_text(PUBLIC_CERTIFICATES["emsign-g1.pem"])
command = [python, str(ROOT / "scripts/check_ca_trust.py"), "--python", "--verbose"]
clean = subprocess.run(command, env=env, capture_output=True, text=True)
self.assertEqual(clean.returncode, 0, clean.stderr)
self.assertIn(str(application_bundle), clean.stdout)
self.assertIn(str(pip_bundle), clean.stdout)
self.assertIn(str(home), clean.stdout)
self.assertIn("Python executable:", clean.stdout)
application_bundle.write_text(PUBLIC_CERTIFICATES["emsign-g3.pem"])
affected = subprocess.run(command, env=env, capture_output=True, text=True)
self.assertEqual(affected.returncode, 1, affected.stderr)
self.assertIn(str(application_bundle), affected.stderr)
self.assertIn("emSign ECC Root CA - G3", affected.stderr)
def test_compromised_emsign_path_is_rejected(self):
with self.assertRaisesRegex(ValueError, "emSign ECC Root CA - G3"):
checker.check_bundle(self.fixtures / "emsign-g3.pem")
def test_aaa_cross_sign_path_is_rejected(self):
with self.assertRaisesRegex(ValueError, "AAA Certificate Services"):
checker.check_bundle(self.fixtures / "aaa.pem")
def test_mixed_bundle_does_not_hide_an_affected_root(self):
with tempfile.TemporaryDirectory() as tmp:
bundle = Path(tmp) / "mixed.pem"
bundle.write_bytes((self.fixtures / "emsign-g1.pem").read_bytes() + (self.fixtures / "aaa.pem").read_bytes())
with self.assertRaisesRegex(ValueError, "AAA Certificate Services"):
checker.check_bundle(bundle)
def test_all_published_intermediates_and_cross_signs_are_rejected(self):
self.assertEqual(len(INCIDENT["intermediates"]), 16)
for fingerprint, name in INCIDENT["intermediates"].items():
with self.subTest(certificate=name), self.assertRaisesRegex(ValueError, "affected CA certificates"):
checker.check_bundle(self.fixtures / f"{fingerprint}.pem")
def test_published_roots_and_cross_signed_issuer_are_rejected(self):
certificates = INCIDENT["roots"] | INCIDENT["cross_signed_roots"]
self.assertEqual(len(certificates), 5)
for fingerprint, name in certificates.items():
with self.subTest(certificate=name), self.assertRaises(ValueError):
checker.check_bundle(self.fixtures / f"{fingerprint}.pem")
def test_clean_roots_do_not_hide_an_added_compromised_intermediate(self):
fingerprint = next(iter(INCIDENT["intermediates"]))
with tempfile.TemporaryDirectory() as tmp:
bundle = Path(tmp) / "custom.pem"
bundle.write_text(PUBLIC_CERTIFICATES["emsign-g1.pem"] + INCIDENT["certificates"][fingerprint])
with self.assertRaisesRegex(ValueError, "EM DV TLS CA - G3B"):
checker.check_bundle(bundle)
@unittest.skipUnless(shutil.which("openssl"), "offline chain verification requires OpenSSL")
def test_published_chains_and_custom_anchor_with_affected_positive_control(self):
# Freeze time inside the public fixtures' validity interval. Otherwise
# expiry could make an ineffective trust-store fix appear successful.
help_result = subprocess.run(["openssl", "verify", "-help"], capture_output=True, text=True)
no_store = ["-no-CAstore"] if "-no-CAstore" in help_result.stdout + help_result.stderr else []
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
affected = directory / "affected.pem"
affected.write_text("".join(INCIDENT["certificates"][key] for key in INCIDENT["roots"]))
clean = self.fixtures / "emsign-g1.pem"
custom = directory / "custom.pem"
fingerprint = next(iter(INCIDENT["intermediates"]))
custom.write_text(clean.read_text() + INCIDENT["certificates"][fingerprint])
self.assertEqual(len(INCIDENT["chains"]), 14)
custom_accepted = []
for hostname, chain in INCIDENT["chains"].items():
leaf, intermediates = directory / "leaf.pem", directory / "chain.pem"
leaf.write_text(INCIDENT["certificates"][chain[0]])
intermediates.write_text("".join(INCIDENT["certificates"][key] for key in chain[1:]))
def verify(bundle):
return subprocess.run([
"openssl", "verify", "-no-CApath", *no_store,
"-attime", str(INCIDENT["verification_time"]),
"-partial_chain", "-x509_strict", "-purpose", "sslserver",
"-verify_hostname", hostname, "-CAfile", str(bundle),
"-untrusted", str(intermediates), str(leaf),
], capture_output=True, text=True)
with self.subTest(hostname=hostname):
positive = verify(affected)
self.assertEqual(positive.returncode, 0, positive.stderr)
negative = verify(clean)
self.assertNotEqual(negative.returncode, 0)
self.assertIn("unable to get local issuer certificate", negative.stderr)
if verify(custom).returncode == 0:
custom_accepted.append(hostname)
self.assertCountEqual(custom_accepted, [
"dv-g3b-aaa-emudhra.we-have-a-problem.com",
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com",
"dv-g3b-emudhra.we-have-a-problem.com",
"dv-g3b-noct-emudhra.we-have-a-problem.com",
])
checker.check_bundle(clean)
for bundle in (affected, custom):
with self.subTest(bundle=bundle.name), self.assertRaises(ValueError):
checker.check_bundle(bundle)
def test_empty_malformed_and_missing_bundles_fail_closed(self):
with tempfile.TemporaryDirectory() as tmp:
for content in ("", "not a certificate"):
path = Path(tmp) / "bad.pem"
path.write_text(content)
with self.subTest(content=content), self.assertRaises((ssl.SSLError, ValueError)):
checker.check_bundle(path)
with self.assertRaises(OSError):
checker.check_bundle(Path(tmp) / "missing.pem")
def test_each_configured_override_is_checked(self):
fingerprint = next(iter(INCIDENT["intermediates"]))
affected = self.fixtures / f"{fingerprint}.pem"
clean = self.fixtures / "emsign-g1.pem"
for name in ("LLM_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE",
"PIP_CERT", "NODE_EXTRA_CA_CERTS", "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH"):
for bundle, expected in ((clean, 0), (affected, 1)):
with self.subTest(variable=name, bundle=bundle.name), patch.dict(
os.environ, {name: str(bundle)}, clear=True
), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--overrides", str(clean)]), expected)
def test_invalid_override_is_not_hidden_by_a_clean_explicit_bundle(self):
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
for name, content in (("empty.pem", ""), ("malformed.pem", "invalid")):
(directory / name).write_text(content)
for name in ("missing.pem", "empty.pem", "malformed.pem"):
with self.subTest(file=name), patch.dict(
os.environ, {"LLM_CA_BUNDLE": str(directory / name)}, clear=True
), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--overrides", str(self.fixtures / "emsign-g1.pem")]), 1)
def test_no_configured_overrides_does_not_report_validated_stores(self):
with patch.dict(os.environ, {"LLM_CA_BUNDLE": " "}, clear=True), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--overrides"]), 1)
def test_requests_directory_override_checks_hashed_intermediates(self):
fingerprint = next(iter(INCIDENT["intermediates"]))
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
for name in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "PIP_CERT"):
with self.subTest(variable=name), patch.dict(os.environ, {name: str(directory)}, clear=True):
for content, expected in ((PUBLIC_CERTIFICATES["emsign-g1.pem"], 0),
(INCIDENT["certificates"][fingerprint], 1)):
(directory / "12345678.0").write_text(content)
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--overrides"]), expected)
(directory / "12345678.0").unlink()
with contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--overrides"]), 1)
def test_safe_cafile_does_not_hide_stale_hashed_directory(self):
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
(directory / "14bc7599.0").symlink_to(self.fixtures / "emsign-g3.pem")
defaults = ssl.get_default_verify_paths()._replace(
cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory)
)
errors = io.StringIO()
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
os.environ, {defaults.openssl_capath_env: str(directory)}
):
with contextlib.redirect_stderr(errors):
result = checker.main(["--system"])
self.assertEqual(result, 1)
self.assertIn("emSign ECC Root CA - G3", errors.getvalue())
def test_hashed_non_ca_certificate_does_not_reject_clean_store(self):
# Ubuntu's OpenSSL directory can also hash ssl-cert-snakeoil.pem.
# A valid server certificate has no CA entries in get_ca_certs().
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
(directory / "ce275665.0").symlink_to(self.fixtures / "localhost-leaf.pem")
defaults = ssl.get_default_verify_paths()._replace(
cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory)
)
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
os.environ, {defaults.openssl_capath_env: str(directory)}
), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(checker.main(["--system"]), 0)
def test_multiple_ca_directories_are_all_checked(self):
with tempfile.TemporaryDirectory() as tmp:
clean, stale = Path(tmp) / "clean", Path(tmp) / "stale"
clean.mkdir()
stale.mkdir()
(stale / "ee64a828.0").symlink_to(self.fixtures / "aaa.pem")
defaults = ssl.get_default_verify_paths()._replace(
cafile=str(self.fixtures / "emsign-g1.pem"), capath=None
)
errors = io.StringIO()
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
os.environ, {defaults.openssl_capath_env: os.pathsep.join(map(str, (clean, stale)))}
):
with contextlib.redirect_stderr(errors):
result = checker.main(["--system"])
self.assertEqual(result, 1)
self.assertIn("AAA Certificate Services", errors.getvalue())
def test_grpc_bundled_roots_are_checked_without_an_override(self):
with tempfile.TemporaryDirectory() as temporary:
package = Path(temporary) / "grpc"
bundle = package / "_cython" / "_credentials" / "roots.pem"
bundle.parent.mkdir(parents=True)
bundle.write_bytes((self.fixtures / "emsign-g3.pem").read_bytes())
errors = io.StringIO()
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": ""}), patch.object(
checker.importlib.util, "find_spec", return_value=SimpleNamespace(origin=str(package / "__init__.py"))
), contextlib.redirect_stderr(errors):
self.assertEqual(checker.main(["--grpc"]), 1)
self.assertIn("emSign ECC Root CA - G3", errors.getvalue())
def test_grpc_explicit_clean_store_replaces_the_independent_bundle(self):
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(self.fixtures / "emsign-g1.pem")}), patch.object(
checker.importlib.util, "find_spec", side_effect=AssertionError("must use the explicit store")
), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(checker.main(["--grpc"]), 0)
def test_grpc_invalid_override_does_not_fall_back_silently(self):
with tempfile.TemporaryDirectory() as temporary:
for name, content in (("empty.pem", ""), ("malformed.pem", "not a certificate"), ("affected.pem", PUBLIC_CERTIFICATES["aaa.pem"])):
bundle = Path(temporary) / name
bundle.write_text(content)
with self.subTest(name=name), patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(bundle)}), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--grpc"]), 1)
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(Path(temporary) / "missing.pem")}), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--grpc"]), 1)
def test_grpc_check_reports_a_missing_installation(self):
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": ""}), patch.object(
checker.importlib.util, "find_spec", return_value=None
), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--grpc"]), 1)
# Public Mozilla CA certificates; provenance: tests/fixtures/ca-trust/README.md.
PUBLIC_CERTIFICATES = {
'localhost-leaf.pem': """-----BEGIN CERTIFICATE-----
MIIBOTCB7KADAgECAhRcVxnOQNEFWf92K4dm4I8ChyjVMzAFBgMrZXAwFDESMBAG
A1UEAwwJbG9jYWxob3N0MB4XDTI2MTAyMTE4NTgwN1oXDTM2MTAxODE4NTgwN1ow
FDESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEARglphvNgp9qRCESLHnuu
W8VC5urdZl+YgS+Ux1BoLryjUDBOMB0GA1UdDgQWBBSaEckyR/76lX3a5Ijh8B42
oJwN1DAfBgNVHSMEGDAWgBSaEckyR/76lX3a5Ijh8B42oJwN1DAMBgNVHRMBAf8E
AjAAMAUGAytlcANBAJkq9ayy+d4AUZnjhWM31rFke5DT+l9QFcPl34+dpVhqJV/L
m4IpPHYagqrBRHn9Dv/C8qYyPBKnAwJRpNptsgw=
-----END CERTIFICATE-----
""",
'aaa.pem': """-----BEGIN CERTIFICATE-----
MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb
MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow
GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj
YXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL
MAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE
BwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM
GEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP
ADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua
BtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe
3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4
YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR
rOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm
ez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU
oBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF
MAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v
QUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t
b2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF
AAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q
GE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz
Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2
G9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi
l2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3
smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==
-----END CERTIFICATE-----
""",
'emsign-g1.pem': """-----BEGIN CERTIFICATE-----
MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYD
VQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBU
ZWNobm9sb2dpZXMgTGltaXRlZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBH
MTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgxODMwMDBaMGcxCzAJBgNVBAYTAklO
MRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVkaHJhIFRlY2hub2xv
Z2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIBIjAN
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQz
f2N4aLTNLnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO
8oG0x5ZOrRkVUkr+PHB1cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aq
d7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHWDV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhM
tTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ6DqS0hdW5TUaQBw+jSzt
Od9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrHhQIDAQAB
o0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQD
AgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31x
PaOfG1vR2vjTnGs2vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjM
wiI/aTvFthUvozXGaCocV685743QNcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6d
GNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q+Mri/Tm3R7nrft8EI6/6nAYH
6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeihU80Bv2noWgby
RQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx
iN66zB+Afko=
-----END CERTIFICATE-----
""",
'emsign-g3.pem': """-----BEGIN CERTIFICATE-----
MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG
EwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo
bm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g
RzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ
TjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s
b2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw
djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0
WXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS
fvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB
zhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq
hkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB
CUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD
+JbNR6iC8hZVdyR+EhCVBCyj
-----END CERTIFICATE-----
""",
}
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -0,0 +1,41 @@
"""The managed browser policy must distrust exactly the incident's eight keys."""
import base64
import hashlib
import json
from pathlib import Path
import unittest
from cryptography import x509
from cryptography.hazmat.primitives import serialization
ROOT = Path(__file__).resolve().parents[1]
# Public incident inventory: impacted-intermediates.json, 2026-10-21.
COMPROMISED_SPKIS = {
"43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b",
"49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f",
"5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0",
"63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8",
"92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30",
"e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41",
"ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105",
"ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6",
}
class ChromiumCATrustTests(unittest.TestCase):
def test_policy_distrusts_each_compromised_key_without_adding_trust(self):
policy = json.loads((ROOT / "docker" / "chromium-emudhra.json").read_text())
self.assertEqual(set(policy), {"CADistrustedCertificates"})
self.assertEqual(len(policy["CADistrustedCertificates"]), 8)
keys = set()
for encoded in policy["CADistrustedCertificates"]:
certificate = x509.load_der_x509_certificate(base64.b64decode(encoded, validate=True))
self.assertTrue(certificate.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
self.assertNotEqual(certificate.subject, certificate.issuer)
public_key = certificate.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
)
keys.add(hashlib.sha256(public_key).hexdigest())
self.assertEqual(keys, COMPROMISED_SPKIS)
+13
View File
@@ -33,3 +33,16 @@ def test_python_tests_are_authoritative():
assert "python -m pytest -q" in python_tests
assert "continue-on-error:" not in python_tests
def test_ci_checks_system_trust_before_project_installation():
python_tests = _indented_block(_WORKFLOW.read_text(), "python-tests", 2)
system_check = python_tests.index("python scripts/check_ca_trust.py --system\n")
system_update = python_tests.index("sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap ca-certificates")
bootstrap = python_tests.index("uv pip install --system --require-hashes")
python_check = python_tests.index("python scripts/check_ca_trust.py --python\n")
application_install = python_tests.index("uv pip install --system -r requirements.txt")
assert system_update < system_check < bootstrap < python_check < application_install
assert system_check < python_tests.index("npm ci")
assert system_check < python_tests.index("npx playwright install")
+111
View File
@@ -0,0 +1,111 @@
"""Agent chat history uses the same scope as existing chat and history routes."""
import pytest
from fastapi import APIRouter, HTTPException
from starlette.requests import Request
from routes import codex_routes
def _endpoint(router, method: str, path: str):
for route in router.routes:
if route.path == path and method in route.methods:
return route.endpoint
raise AssertionError(f"{method} {path} route not found")
def _request(scopes: list[str]) -> Request:
request = Request({
"type": "http",
"method": "GET",
"path": "/api/codex/chat/session-test",
"headers": [],
"state": {},
})
request.state.current_user = "api"
request.state.api_token = True
request.state.api_token_owner = "alice"
request.state.api_token_scopes = scopes
return request
def _history_router(calls: list[tuple]):
router = APIRouter()
@router.get("/api/history/{session_id}")
async def history(
request: Request,
session_id: str,
limit: int | None = None,
offset: int | None = None,
):
calls.append((request, session_id, limit, offset))
return {
"name": "Model debugging",
"model": "org/model",
"history": [{"role": "assistant", "content": "second"}],
"total": 3,
"offset": offset,
"limit": limit,
"endpoint_url": "http://private-endpoint",
}
return router
@pytest.mark.asyncio
async def test_history_read_uses_existing_paged_handler_without_exposing_endpoint_url():
calls = []
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
request = _request(["chat"])
result = await read_chat(request, "session-test", offset=1, limit=1)
assert calls == [(request, "session-test", 1, 1)]
assert result == {
"session_id": "session-test",
"name": "Model debugging",
"model": "org/model",
"messages": [{"role": "assistant", "content": "second"}],
"total": 3,
"offset": 1,
"limit": 1,
}
@pytest.mark.asyncio
async def test_token_without_chat_scope_cannot_read_history_or_reach_handler():
calls = []
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
with pytest.raises(HTTPException) as exc_info:
await read_chat(_request(["todos:read"]), "session-test")
assert exc_info.value.status_code == 403
assert calls == []
@pytest.mark.asyncio
async def test_history_read_uses_existing_page_limit():
calls = []
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
await read_chat(_request(["chat"]), "session-test", limit=1000)
assert calls[0][2:] == (100, 0)
def test_capabilities_report_history_permission_from_chat_scope():
router = codex_routes.setup_codex_routes(history_router=_history_router([]))
capabilities = _endpoint(router, "GET", "/api/codex/capabilities")
without_chat = capabilities(_request(["todos:read"]))["tools"]["chat"]
reader = capabilities(_request(["chat"]))["tools"]["chat"]
assert without_chat["read"] is False
assert reader["read"] is True
assert reader["available"] is True
assert reader["actions"] == ["read"]
+34
View File
@@ -0,0 +1,34 @@
"""Sidecar images must be pinned to a versioned tag and an immutable digest.
A bare or ``:latest`` reference changes underneath an existing install on the
next ``docker compose pull``/``up``, so two hosts on the same commit can run
different ChromaDB or ntfy builds. The ``odysseus`` service is excluded: it is
the project's own image, selected through ``ODYSSEUS_IMAGE``. The standalone
GPU files are held equal to these services by test_gpu_compose_standalone.py.
"""
import re
from pathlib import Path
import pytest
import yaml
COMPOSE = Path(__file__).resolve().parents[1] / "docker-compose.yml"
PINNED = re.compile(r"^[\w.\-/]+:(?P<tag>[\w.\-]+)@sha256:[0-9a-f]{64}$")
def _sidecars():
services = yaml.safe_load(COMPOSE.read_text(encoding="utf-8"))["services"]
return {name: svc["image"] for name, svc in services.items() if name != "odysseus"}
def test_expected_sidecars_present():
assert {"chromadb", "searxng", "ntfy"} <= set(_sidecars())
@pytest.mark.parametrize("name", sorted(_sidecars()))
def test_sidecar_image_has_version_tag_and_digest(name):
image = _sidecars()[name]
match = PINNED.match(image)
assert match, f"{name}: expected <repo>:<tag>@sha256:<digest>, got {image!r}"
assert match.group("tag") != "latest", f"{name}: pin a release tag, not latest"
@@ -0,0 +1,136 @@
"""Read-only Cookbook tools must be admin-only for the agent.
Regression: `list_cached_models`, `list_served_models`, `tail_serve_output`,
`list_downloads`, `search_hf_models`, `list_serve_presets` and
`list_cookbook_servers` sat in the plan-mode read-only allowlist but not in
NON_ADMIN_BLOCKED_TOOLS or the dispatcher's _ADMIN_TOOLS. Their
implementations call require_admin routes over the internal loopback with the
process-wide INTERNAL_TOOL_TOKEN and never look at the owner, so a non-admin
user's agent could reach admin capability through them. The worst case is
`list_cached_models`: a caller-chosen `host` is forwarded to
/api/model/cached, which SSHes to that host with the app user's keys and
walks caller-chosen `model_dir`s. THREAT_MODEL.md lists model serving as
admin-only, so the read side has to be gated the same way as serve/download.
"""
import sys
import types
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from src.tool_security import (
NON_ADMIN_BLOCKED_TOOLS,
blocked_tools_for_owner,
delegated_credential_blocked_tools,
)
from src.tool_execution import _ADMIN_TOOLS
from src.agent_runtime.authority import OperationGrant, RequestAuthority
READONLY_COOKBOOK_TOOLS = (
"list_cached_models",
"list_served_models",
"tail_serve_output",
"list_downloads",
"search_hf_models",
"list_serve_presets",
"list_cookbook_servers",
)
def _install_core_auth_stub(monkeypatch, *, is_admin: bool):
"""Narrow auth surface: AUTH enabled, configured, owner admin or not."""
core_mod = types.ModuleType("core")
core_mod.__path__ = []
auth_mod = types.ModuleType("core.auth")
class FakeAuth:
is_configured = True
def is_admin(self, username):
return is_admin
auth_mod.AuthManager = lambda: FakeAuth()
core_mod.auth = auth_mod
monkeypatch.setitem(sys.modules, "core", core_mod)
monkeypatch.setitem(sys.modules, "core.auth", auth_mod)
monkeypatch.setattr("src.auth_helpers._auth_disabled", lambda: False)
return auth_mod
def test_readonly_cookbook_tools_are_in_every_admin_gate():
from src.agent_loop import _ADMIN_TOOLS as prompt_admin_tools
# Spelled out (not imported from a shared tuple in src/) so dropping one
# from either set fails here instead of silently reopening the hole.
for tool in READONLY_COOKBOOK_TOOLS:
assert tool in NON_ADMIN_BLOCKED_TOOLS, tool
assert tool in _ADMIN_TOOLS, tool
assert tool in prompt_admin_tools, tool
def test_readonly_cookbook_tools_hidden_from_non_admin_and_bearer_runs(monkeypatch):
_install_core_auth_stub(monkeypatch, is_admin=False)
hidden = blocked_tools_for_owner("regular-user")
for tool in READONLY_COOKBOOK_TOOLS:
assert tool in hidden, tool
assert tool in delegated_credential_blocked_tools(), tool
def test_admin_owner_keeps_readonly_cookbook_tools(monkeypatch):
_install_core_auth_stub(monkeypatch, is_admin=True)
assert blocked_tools_for_owner("admin-user") == set()
@pytest.mark.asyncio
@pytest.mark.parametrize("tool_name", READONLY_COOKBOOK_TOOLS)
async def test_non_admin_agent_cannot_execute_readonly_cookbook_tools(monkeypatch, tool_name):
_install_core_auth_stub(monkeypatch, is_admin=False)
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
# The loopback must never be touched: if the gate fails, the tool would
# hit a require_admin route with the internal token.
import httpx
def _boom(*args, **kwargs):
raise AssertionError(f"{tool_name} reached the internal loopback for a non-admin owner")
monkeypatch.setattr(httpx, "AsyncClient", _boom)
monkeypatch.setattr(httpx, "get", _boom)
monkeypatch.setattr(httpx, "post", _boom)
payload = '{"host": "root@10.0.0.5", "ssh_port": "22", "model_dir": "/root,/etc"}'
desc, result = await execute_tool_block(
SimpleNamespace(tool_type=tool_name, content=payload),
owner="regular-user",
security_context=NO_TOOL_SECURITY_CONTEXT,
request_authority=RequestAuthority(
"cookbook-test", "regular-user", "", "", (OperationGrant(tool_name),)
),
)
assert desc == f"{tool_name}: BLOCKED"
assert result["exit_code"] == 1
assert "admin" in result["error"]
@pytest.mark.asyncio
@pytest.mark.parametrize("tool_name", READONLY_COOKBOOK_TOOLS)
async def test_admin_agent_can_dispatch_readonly_cookbook_tools(monkeypatch, tool_name):
from src import tool_implementations
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
_install_core_auth_stub(monkeypatch, is_admin=True)
handler = AsyncMock(return_value={"output": "Cookbook result", "exit_code": 0})
monkeypatch.setattr(tool_implementations, f"do_{tool_name}", handler)
desc, result = await execute_tool_block(
SimpleNamespace(tool_type=tool_name, content="{}"),
owner="admin-user",
security_context=NO_TOOL_SECURITY_CONTEXT,
request_authority=RequestAuthority(
"cookbook-test", "admin-user", "", "", (OperationGrant(tool_name),)
),
)
assert desc == tool_name
assert result["exit_code"] == 0
handler.assert_awaited_once_with("{}", owner="admin-user")
+63
View File
@@ -0,0 +1,63 @@
"""Execute the publication workflow's image-name setup without contacting GHCR."""
import os
import shutil
import subprocess
from pathlib import Path
import pytest
import yaml
WORKFLOW = (
Path(__file__).resolve().parents[1]
/ ".github"
/ "workflows"
/ "docker-publish.yml"
)
@pytest.mark.skipif(shutil.which("bash") is None, reason="Docker publication requires Bash")
@pytest.mark.parametrize("job", ["build", "merge"])
@pytest.mark.parametrize(
("repository", "expected"),
[
("RaresKeY/odysseus-dev", "rareskey/odysseus-dev"),
("ExampleOwner/Example-Repo", "exampleowner/example-repo"),
("odysseus-dev/odysseus", "odysseus-dev/odysseus"),
],
)
def test_image_name_is_lowercase_before_docker_steps(tmp_path, job, repository, expected):
workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
image_name = workflow["env"].get("IMAGE_NAME", "").replace(
"${{ github.repository }}", repository
)
github_env = tmp_path / "github-env"
env = {
"PATH": os.environ["PATH"],
"GITHUB_REPOSITORY": repository,
"GITHUB_ENV": str(github_env),
"GITHUB_OUTPUT": str(tmp_path / "github-output"),
"GITHUB_SHA": "a" * 40,
}
# Run the real setup shell before the job starts using Docker actions.
# Checkout, login, builds and registry publication are deliberately excluded.
for step in workflow["jobs"][job]["steps"]:
if step.get("uses", "").startswith("docker/"):
break
if "run" not in step:
continue
subprocess.run(
["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]],
cwd=WORKFLOW.parents[2],
env=env,
check=True,
capture_output=True,
text=True,
)
if github_env.exists():
env.update(line.split("=", 1) for line in github_env.read_text().splitlines())
image_name = env.get("IMAGE_NAME", image_name)
assert image_name == expected
+26 -4
View File
@@ -2,6 +2,7 @@ import asyncio
import json
from types import SimpleNamespace
import pytest
from PIL import Image
from src.tools.image import do_edit_image
@@ -122,7 +123,8 @@ def test_edit_image_rejects_actions_without_complete_input_contract():
assert "Use prompt, upscale or rembg" in result["error"]
def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, tmp_path):
@pytest.mark.parametrize('incognito', [False, True])
def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, tmp_path, incognito):
source_path = tmp_path / 'source.png'
Image.new('RGB', (3, 2), 'red').save(source_path)
original = source_path.read_bytes()
@@ -135,9 +137,9 @@ def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, t
return {'image_id': 'edited', 'image_url': '/api/generated-image/edited.png'}
from pathlib import Path
monkeypatch.setattr('src.ai_interaction.do_edit_image', edit)
result = asyncio.run(do_edit_image(json.dumps({'image_id': 'source-id', 'action': 'prompt', 'prompt': 'Add another cow'}), owner='alice'))
result = asyncio.run(do_edit_image(json.dumps({'image_id': 'source-id', 'action': 'prompt', 'prompt': 'Add another cow'}), owner='alice', incognito=incognito))
assert result['image_id'] == 'edited'
assert calls == [('Add another cow', original, {'session_id': 'session-1', 'owner': 'alice', 'size': 'auto'})]
assert calls == [('Add another cow', original, {'session_id': 'session-1', 'owner': 'alice', 'size': 'auto', 'incognito': incognito})]
assert source_path.read_bytes() == original
denied = asyncio.run(do_edit_image('{"image_id":"source-id","action":"prompt","prompt":"edit"}', owner=None))
assert denied['error'] == 'Image not found'
@@ -165,7 +167,7 @@ def test_uploaded_image_edit_uses_owner_scoped_reference(monkeypatch, tmp_path):
monkeypatch.setattr('src.ai_interaction.do_edit_image', edit)
args = '{"image_id":"odysseus://attachment/upload.jpg","action":"prompt","prompt":"Make more realistic"}'
assert asyncio.run(do_edit_image(args, owner='alice')) == {'image_id': 'edited'}
assert calls == [('Make more realistic', str(path), {'owner': 'alice', 'size': 'auto'})]
assert calls == [('Make more realistic', str(path), {'owner': 'alice', 'size': 'auto', 'incognito': False})]
assert resolutions == [('upload.jpg', {'owner': 'alice', 'allow_admin': False})]
assert 'error' in asyncio.run(do_edit_image(args, owner='bob'))
assert len(calls) == 1
@@ -173,3 +175,23 @@ def test_uploaded_image_edit_uses_owner_scoped_reference(monkeypatch, tmp_path):
disabled = asyncio.run(do_edit_image(args, owner='alice'))
assert 'disabled' in disabled['error']
assert len(calls) == 1
def test_private_upscale_does_not_save_image_or_gallery_record(monkeypatch, tmp_path):
source_path = tmp_path / 'source.png'
Image.new('RGB', (3, 2), 'red').save(source_path)
original = source_path.read_bytes()
db = _Db(_source(source_path.name))
monkeypatch.setattr('core.database.SessionLocal', lambda: db)
monkeypatch.setattr('src.constants.GENERATED_IMAGES_DIR', str(tmp_path))
result = asyncio.run(do_edit_image(
'{"image_id":"source-id","action":"upscale","scale":2}',
owner='alice', incognito=True,
))
assert result['exit_code'] == 0
assert result['image_url'].startswith('data:image/png;base64,')
assert not result['image_id']
assert db.added == []
assert not db.committed
assert list(tmp_path.iterdir()) == [source_path]
assert source_path.read_bytes() == original
+9
View File
@@ -26,6 +26,15 @@ def test_plus_button_adds_a_pixel_layer_directly():
assert "Pixel Layer" not in AI_MISC
def test_new_layer_is_inserted_above_the_selected_layer():
block = AI_MISC[AI_MISC.index("function addEmptyLayer()"):AI_MISC.index("const buildFxMenu")]
assert "const activeIndex = state.layers.findIndex(item => item.id === state.activeLayerId);" in block
assert "const insertIndex = activeIndex >= 0 ? activeIndex + 1 : state.layers.length;" in block
assert "state.layers.splice(insertIndex, 0, layer);" in block
# it must not jump to the very top of the stack
assert "state.layers.push(layer);" not in block
def test_fx_button_opens_the_adjustment_menu():
assert "const buildFxMenu = (anchor) => {" in AI_MISC
assert "const addFxButton = document.getElementById('ge-add-fx');" in AI_MISC
@@ -0,0 +1,83 @@
"""Server health audit fix: the local editor VLM returned 400s when asked for a
model id it does not serve (the client default 'Qwen3.5-9B-MLX-8bit', or the
stale caption model 'Qwen/Qwen3-VL-30B'). ``_resolve_model`` maps the requested
id to one the base actually serves."""
import asyncio
from src import editor_agent_model as eam
def _cache(base, ids):
eam._MODELS_CACHE[base] = set(ids)
def test_resolve_model_keeps_a_served_request():
base = "http://x:1/v1"
_cache(base, ["Qwen/Qwen3.5-9B", "other"])
assert asyncio.run(eam._resolve_model(base, "Qwen/Qwen3.5-9B", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
def test_resolve_model_falls_back_when_requested_is_not_served():
base = "http://x:2/v1"
_cache(base, ["Qwen/Qwen3.5-9B"])
# e.g. the client's MLX build name, or a stale caption model id.
assert asyncio.run(eam._resolve_model(base, "Qwen3.5-9B-MLX-8bit", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
assert asyncio.run(eam._resolve_model(base, "Qwen/Qwen3-VL-30B", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
def test_resolve_model_picks_a_served_id_when_fallback_absent():
base = "http://x:3/v1"
_cache(base, ["B", "A"])
assert asyncio.run(eam._resolve_model(base, "nope", "also-nope")) == "A"
def test_resolve_model_keeps_request_when_served_list_unknown():
base = "http://x:4/v1"
_cache(base, [])
# Server unreachable / no models listed -> don't rewrite the request.
assert asyncio.run(eam._resolve_model(base, "some/model", "fallback")) == "some/model"
def test_self_check_resolves_the_model_before_calling(monkeypatch):
monkeypatch.setattr(eam, "local_bases", lambda: ["http://editor.test/v1"])
base = eam.local_bases()[0]
_cache(base, ["Qwen/Qwen3.5-9B"])
seen = {}
async def fake_post(b, model, messages, headers, timeout, **kwargs):
seen["model"] = model
return "DONE"
monkeypatch.setattr(eam, "_post_chat", fake_post)
out = asyncio.run(eam.self_check(
"data:image/png;base64,AAAA", "data:image/png;base64,BBBB",
"make it brighter", model="Qwen3.5-9B-MLX-8bit"))
assert seen["model"] == "Qwen/Qwen3.5-9B"
assert out["done"] is True
def test_caption_resolves_the_caption_model(monkeypatch):
monkeypatch.setattr(eam, "caption_base", lambda: "http://caption.test/v1")
seen = {}
async def fake_post(b, model, messages, headers, timeout, **kwargs):
seen["base"] = b
seen["model"] = model
return "a photo"
monkeypatch.setattr(eam, "_post_chat", fake_post)
import src.editor_agent_model as m
_cache(m.caption_base(), ["Qwen/Qwen3.5-9B"])
txt = asyncio.run(m.caption_image("data:image/png;base64,AAAA"))
assert txt == "a photo"
assert seen["model"] == "Qwen/Qwen3.5-9B"
def test_unconfigured_editor_helpers_do_not_probe_private_hosts(monkeypatch):
monkeypatch.setattr(eam, "local_bases", lambda: [])
monkeypatch.setattr(eam, "caption_base", lambda: "")
monkeypatch.setattr(eam, "probe_models", lambda *a: (_ for _ in ()).throw(AssertionError("unexpected probe")))
eam._probe_worker()
assert asyncio.run(eam.caption_image("data:image/png;base64,AAAA")) is None
result = asyncio.run(eam.self_check("data:image/png;base64,AAAA", "data:image/png;base64,BBBB", "brighter"))
assert result["reason"] == "unavailable"
+42
View File
@@ -0,0 +1,42 @@
"""Holding Alt over a paint tool borrows the eyedropper (Photoshop parity)."""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
EYE = (ROOT / "static/js/editor/tools/eyedropper.js").read_text(encoding="utf-8")
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
def test_alt_eyedropper_tool_list_excludes_clone_and_heal():
assert (
"export const ALT_EYEDROPPER_TOOLS = new Set(['brush', 'pixel', 'eraser', 'smudge', 'dodge', 'burn', 'gradient', 'bucket']);"
in EYE
)
listed = EYE.split("ALT_EYEDROPPER_TOOLS = new Set([")[1].split("]);")[0]
# Alt already picks the clone source, so clone/heal must not borrow.
assert "clone" not in listed
assert "heal" not in listed
def test_begin_draw_borrows_the_eyedropper_on_alt():
assert "ALT_EYEDROPPER_TOOLS as _altEyedropperTools" in EDITOR
assert "if (e.altKey && _altEyedropperTools.has(state.tool)) {" in EDITOR
assert "_eyedropperTool.pick(e);" in EDITOR
def test_alt_held_switches_the_cursor_and_hides_the_ring():
assert "const _syncAltEyedropper = (on) => {" in EDITOR
assert "document.body.classList.toggle('ge-alt-eyedropper', !!on && _altEyedropperTools.has(state.tool));" in EDITOR
assert "document.addEventListener('keydown', (ev) => { if (state.editorOpen) _syncAltEyedropper(ev.altKey); });" in EDITOR
assert "document.addEventListener('keyup', (ev) => { if (state.editorOpen) _syncAltEyedropper(ev.altKey); });" in EDITOR
assert "window.addEventListener('blur', () => _syncAltEyedropper(false));" in EDITOR
assert "body.ge-alt-eyedropper .ge-main-canvas {\n cursor: url(\"data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22" in CSS
assert "2 22, crosshair !important;" in CSS
assert "body.ge-alt-eyedropper .ge-brush-cursor {\n display: none !important;\n}" in CSS
def test_sw_cache_name_bumped():
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
+99
View File
@@ -0,0 +1,99 @@
"""Visual audit clean-up regressions (inpaint popover, tool rail, Quick Edit pill,
compact Text/Shape/Rembg strips, label sizes, dock/layer sizing)."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
TOOLBAR = (ROOT / "static/js/editor/build/toolbar.js").read_text(encoding="utf-8")
CONTROLS = (ROOT / "static/js/editor/build/controls.js").read_text(encoding="utf-8")
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
def test_inpaint_popover_stacks_its_rows_vertically():
start = CSS.index("#ge-inpaint-section.ge-inpaint-popover {")
block = CSS[start:CSS.index("}", start)]
assert "display: flex !important;" in block
assert "flex-direction: column !important;" in block
assert "flex-wrap: nowrap !important;" in block
assert "overflow-x: hidden;" in block
assert ".ge-inpaint-section.ge-inpaint-popover .ge-inpaint-prompt" in CSS
prompt = CSS[CSS.index(".ge-inpaint-section.ge-inpaint-popover .ge-inpaint-prompt"):]
assert "width: 100%;" in prompt[:200]
def test_tool_rail_scroller_keeps_swatches_out_of_the_tool_column():
assert "scroller.className = 'ge-toolbar-scroll';" in TOOLBAR
assert "toolbar.appendChild(scroller);" in TOOLBAR
assert "scroller.appendChild(slotEl);" in TOOLBAR
assert "toolbar.appendChild(swatches);" in TOOLBAR
toolbar = CSS[CSS.index(".ge-toolbar {"):]
toolbar = toolbar[:toolbar.index("}")]
assert "display: flex;" in toolbar
assert "flex-direction: column;" in toolbar
assert "overflow: visible;" in toolbar
assert ".ge-toolbar-scroll {" in CSS
# The scroller must stretch its slots to the full rail width; centring
# shrank every tool button to its icon width (~19px instead of 43px).
scroller = CSS[CSS.index(".ge-toolbar-scroll {"):]
scroller = scroller[:scroller.index("}")]
assert "align-items: stretch;" in scroller
assert "align-items: center;" not in scroller
swatches = CSS[CSS.index(".ge-tool-swatches {"):]
swatches = swatches[:swatches.index("}")]
assert "position: static;" in swatches
assert "position: sticky;" not in swatches
assert "attachScrollAffordance(container.querySelector('.ge-toolbar-scroll'))" in EDITOR
def test_collapsed_quick_edit_pill_does_not_add_canvas_scroll():
start = CSS.index(".ge-ai-command-collapsed {")
block = CSS[start:CSS.index("}", start)]
assert "bottom: 0;" in block
assert "-16px" not in block
def test_text_and_shape_rare_controls_move_into_a_more_popover():
assert 'id="ge-text-more"' in CONTROLS
assert 'class="ge-btn ge-btn-sm ge-more-toggle" id="ge-text-more-toggle"' in CONTROLS
assert 'id="ge-shape-more"' in CONTROLS
assert 'id="ge-shape-more-toggle"' in CONTROLS
assert "ge-tool-more" not in CONTROLS
assert ".ge-opt-more {" in CSS
assert "position: absolute;" in CSS[CSS.index(".ge-opt-more {"):CSS.index(".ge-opt-more {") + 260]
assert "['ge-text-more-toggle', 'ge-text-more']" in EDITOR
assert "['ge-shape-more-toggle', 'ge-shape-more']" in EDITOR
# Escape is delegated through the gallery's single capture handler.
esc = EDITOR[EDITOR.index("window.__galleryEditorHandleEscape = "):]
esc = esc[:esc.index("return false;")]
assert "document.querySelector('.ge-opt-more:not([hidden])')" in esc
assert "openMore.hidden = true;" in esc
def test_tall_strips_fit_on_one_row():
assert "display: none;" in CSS[CSS.index(".ge-options-bar.ge-options-tall .ge-options-tool"):][:80]
tall = CSS[CSS.index(".ge-options-bar.ge-options-tall .ge-controls--in-options {"):]
tall = tall[:tall.index("}")]
assert "flex: 1 1 auto;" in tall
assert 'rows="2"' in CONTROLS
def test_small_labels_grow_and_transform_steppers_are_bigger():
dock_tab = CSS[CSS.index(".ge-dock-tab {"):]
dock_tab = dock_tab[:dock_tab.index("}")]
assert "font-size: 10px;" in dock_tab
spin = CSS[CSS.index(".ge-transform-spin {"):]
spin = spin[:spin.index("}")]
assert "height: 20px;" in spin
spin_btn = CSS[CSS.index("\n.ge-transform-spin button {"):]
spin_btn = spin_btn[:spin_btn.index("}")]
assert "width: 14px;" in spin_btn
assert "font-size: 9px;" in spin_btn
assert "#ge-transform-section .ge-transform-spin button {" in CSS
def test_right_panel_gives_layers_more_room():
dock = CSS[CSS.index(".ge-dock {"):]
dock = dock[:dock.index("}")]
assert "max-height: 56%;" in dock
assert ".ge-right-panel--dock > .ge-layers { flex: 1 1 auto; min-height: 240px; }" in CSS
+6 -5
View File
@@ -145,12 +145,13 @@ def test_stroke_pipeline_has_pixel_stamping():
assert "brushPixel: false" in state
def test_pixel_brush_shows_a_size_grid():
def test_pixel_brush_does_not_force_a_grid_overlay():
guides = (ROOT / "static/js/editor/precision-guides.js").read_text()
assert "const pixelBrush = !!state.brushPixel && (state.tool === 'brush' || state.tool === 'eraser');" in guides
assert "if (state.gridVisible || pixelBrush) {" in guides
assert "Math.max(1, Math.round(Number(state.brushSize) || 1))" in guides
assert "const multiplier = pixelBrush ? 1 :" in guides
# The Pixel tool must not paint a blue grid over the whole document (it
# read as an unwanted blue tint); the grid follows View > Grid only.
assert "state.gridVisible || pixelBrush" not in guides
assert "122, 208, 255" not in guides
assert "if (state.gridVisible) {" in guides
def test_pixel_brush_snaps_only_while_shift_is_held():
+175
View File
@@ -0,0 +1,175 @@
"""The brush options strip fits on one line and hides its secondary controls
(Spacing / Smoothing / Blend / pen-pressure) behind a caret ("v") dropdown,
matching the wand's "More" treatment."""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
CONTROLS = (ROOT / "static/js/editor/build/controls.js").read_text(encoding="utf-8")
OPTIONS = (ROOT / "static/js/editor/build/options-bar.js").read_text(encoding="utf-8")
STROKE = (ROOT / "static/js/editor/stroke-tool-sliders.js").read_text(encoding="utf-8")
GALLERY = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
def test_brush_strip_has_a_caret_more_dropdown():
assert '<span class="ge-brush-more-wrap">' in CONTROLS
assert 'id="ge-brush-more"' in CONTROLS
assert 'aria-haspopup="true" aria-expanded="false"' in CONTROLS
assert "ge-brush-more-btn" in CONTROLS
assert '<path d="m6 9 6 6 6-6"/>' in CONTROLS
assert 'class="ge-brush-more-menu" id="ge-brush-more-menu" hidden' in CONTROLS
def test_secondary_brush_controls_live_after_the_menu_open():
i = CONTROLS.index('id="ge-brush-more-menu"')
j = CONTROLS.index('ge-brush-preset-row', i)
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"'):
assert marker in block, marker
def test_brush_size_label_drops_the_redundant_word():
# The px value stays but the redundant "Size" word next to the brush is gone.
assert '<label><span class="ge-size-label">' in CONTROLS
assert ">Size <span" not in CONTROLS
assert "nodeValue = (toolId === 'eraser') ? 'Brush Size ' : 'Size ';" not in GALLERY
def test_stroke_groups_are_gated_per_tool():
# Every brush-family tool shares one strip; the caret menu swaps the
# secondary sliders by data-stroke-tool group.
assert "const strokeTool = (toolId === 'brush' || toolId === 'pixel' || toolId === 'dodge' || toolId === 'burn') ? 'brush'" in GALLERY
assert "document.querySelectorAll('#ge-brush-more-menu [data-stroke-tool]')" in GALLERY
assert "row.dataset.strokeTool === strokeTool ? '' : 'none';" in GALLERY
for group in ('eraser', 'clone', 'smudge'):
assert f'class="ge-brush-stroke-group" data-stroke-tool="{group}"' in CONTROLS, group
# The former standalone sections are gone.
assert 'id="ge-brush-section"' not in CONTROLS
assert 'id="ge-eraser-section"' not in CONTROLS
def test_every_stroke_group_is_a_titled_card():
# Each brush-family tool's rows ride the same titled card as Eraser so every
# caret menu looks and reads the same, just with more or fewer rows.
for group in ('brush', 'eraser', 'clone', 'smudge'):
assert f'class="ge-brush-stroke-group" data-stroke-tool="{group}"' in CONTROLS, group
assert '<div class="ge-section-title" id="ge-stroke-title-brush">Brush</div>' in CONTROLS
assert '<div class="ge-section-title" id="ge-stroke-title-clone">Clone</div>' in CONTROLS
assert '<div class="ge-section-title">Eraser</div>' in CONTROLS
assert '<div class="ge-section-title">Smudge</div>' in CONTROLS
# The shared brush card takes the active tool's name (Pixel / Dodge / Burn);
# the clone card reads Healing for the heal brush.
assert "const brushStrokeTitle = document.getElementById('ge-stroke-title-brush');" in GALLERY
assert "brushStrokeTitle.textContent = _TOOLS[toolId]?.label || 'Brush';" in GALLERY
assert "const cloneStrokeTitle = document.getElementById('ge-stroke-title-clone');" in GALLERY
assert "cloneStrokeTitle.textContent = toolId === 'heal' ? 'Healing' : 'Clone';" in GALLERY
# The rows no longer carry data-stroke-tool themselves — the card does.
assert 'id="ge-brush-flow-row" data-stroke-tool="brush"' not in CONTROLS
assert 'id="ge-brush-softness-row" data-stroke-tool="brush"' not in CONTROLS
assert 'id="ge-brush-opacity-row" data-stroke-tool="brush"' not in CONTROLS
def test_generic_options_more_button_is_retired():
assert "const showMore = false;" in OPTIONS
assert "moreBtn.hidden = !showMore;" in OPTIONS
def test_brush_more_menu_is_wired():
assert "document.getElementById('ge-brush-more')" in STROKE
assert "document.getElementById('ge-brush-more-menu')" in STROKE
assert "const closeBrushMore = () => {" in STROKE
assert "if (event.key === 'Escape') closeBrushMore();" in STROKE
def test_brush_more_menu_css_and_overflow_release():
assert ".ge-brush-more-wrap { position: relative; display: inline-flex; }" in CSS
assert ".ge-brush-more-menu {" in CSS
assert ".ge-brush-more-menu[hidden] { display: none; }" in CSS
assert '.ge-brush-more-btn[aria-expanded="true"] svg { transform: rotate(180deg); }' in CSS
assert ".ge-options-bar:has(#ge-brush-more-menu:not([hidden])) { overflow: visible; }" in CSS
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
# 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.
assert '.ge-eraser-row[style*="display:none"],' in CSS
assert '.ge-eraser-row[style*="display: none"] { display: none !important; }' in CSS
# The preset Save + × sit a touch high to line up with the dropdown.
assert ".ge-brush-preset-row .ge-btn { flex: 0 0 auto; height: 24px; display: inline-flex; align-items: center; position: relative; top: -3px; }" in CSS
# ...and [hidden] must beat that inline-flex so a built-in preset hides them.
assert ".ge-brush-preset-row .ge-btn[hidden] { display: none !important; }" in CSS
def test_caret_sits_in_the_size_row_next_to_the_slider():
# The caret ("v") is inside the Size row, right after the size slider.
row_i = CONTROLS.index('class="ge-control-row ge-brush-size-row"')
preset_i = CONTROLS.index('ge-brush-preset-row', row_i)
row = CONTROLS[row_i:preset_i]
assert "ge-size-slider" in row
assert "ge-brush-more-wrap" in row
assert row.index("ge-size-slider") < row.index("ge-brush-more-wrap")
assert ".ge-brush-size-row .ge-brush-more-wrap { margin-left: 4px; }" in CSS
def test_custom_slot_and_save_button_visibility():
# Save/delete only appear for a user-defined preset, never for a built-in.
assert '<option value="">Custom</option>' in STROKE
assert "const syncPresetButtons = () => {" in STROKE
assert "const isBuiltIn = !!preset && !!preset.builtIn;" in STROKE
assert "const isUserCustom = !!preset && !preset.builtIn;" in STROKE
assert "if (presetSaveBtn) presetSaveBtn.hidden = isBuiltIn;" in STROKE
assert "if (presetDeleteBtn) presetDeleteBtn.hidden = !isUserCustom;" in STROKE
# The dropdown remembers the last picked preset instead of defaulting to Custom.
assert "savedPresetId = localStorage.getItem('ge-brush-preset') || '';" in STROKE
assert "renderPresets(presets.some(item => item.id === savedPresetId && !item.pixel) ? savedPresetId : '');" in STROKE
assert "localStorage.setItem('ge-brush-preset', presetSelect.value || '');" in STROKE
def test_custom_slot_drops_the_pixel_preset():
# Selecting "Custom" clears pixel mode so the 1px Pixel preset is escapable.
assert "if (!preset) {" in STROKE
assert "state.brushPixel = false;" in STROKE
def test_opacity_row_moved_into_the_caret_menu():
i = CONTROLS.index('id="ge-brush-more-menu"')
j = CONTROLS.index('ge-brush-preset-row', i)
block = CONTROLS[i:j]
assert 'id="ge-brush-opacity-row"' in block
assert 'id="ge-brush-opacity-label"' in block
assert 'id="ge-brush-opacity"' in block
# Opacity rides the brush stroke group, shown for brush/dodge/burn.
assert "data-stroke-tool=\"brush\"" in block
assert "row.dataset.strokeTool === strokeTool ? '' : 'none';" in GALLERY
def test_spacing_and_smoothing_have_preview_dots():
assert 'class="ge-eraser-preview" id="ge-brush-preview-spacing"' in CONTROLS
assert 'class="ge-eraser-preview" id="ge-brush-preview-smoothing"' in CONTROLS
assert "const spacingPrev = document.getElementById('ge-brush-preview-spacing');" in STROKE
assert "const smoothingPrev = document.getElementById('ge-brush-preview-smoothing');" in STROKE
assert "spacingPrev.style.opacity =" in STROKE
assert "smoothingPrev.style.background =" in STROKE
def test_brush_colour_syncs_to_the_sidebar_swatch():
assert "const _syncFgSwatch = (hex) => { if (fgSwatch) fgSwatch.value = hex; };" in GALLERY
assert "_syncFgSwatch(e.target.value);" in GALLERY
def test_blend_row_is_right_aligned():
assert ".ge-brush-more-menu #ge-brush-blend { margin-left: auto; }" in CSS
def test_sw_cache_name_bumped():
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
+30 -1
View File
@@ -96,7 +96,36 @@ def test_bucket_tool_is_registered_and_wired():
assert "const _bucketTool = createBucketTool({" in editor
assert "if (state.tool === 'bucket') return _bucketTool.fill(e);" in editor
assert "'fill.flood':" in editor
assert "_pixelPaintTools = new Set(['brush', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'gradient', 'bucket'])" in editor
assert "_pixelPaintTools = new Set(['brush', 'pixel', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'gradient', 'bucket', 'liquify'])" in editor
state = (ROOT / "static/js/editor/state.js").read_text()
assert "bucketTolerance: 32" in state
def test_bucket_rasterizes_a_pixel_layer_in_one_click():
# A text/shape/placed layer used to bounce the user through a rasterize
# dialog that could then sit behind the canvas, so the bucket looked broken.
# Now the layer is rasterized silently (undoable) and filled in one click.
editor = (ROOT / "static/js/galleryEditor.js").read_text()
assert "function _rasterizeLayerNow(layer) {" in editor
assert '_saveState(`Rasterize "${layer.name}"`);' in editor
assert "if (!_rasterizeLayerNow(layer)) { void _offerRasterizeForTool(); return; }" in editor
assert "if (!_pixelPaintTools.has(toolId)) void _offerRasterizeForTool(toolId);" in editor
def test_all_paint_tools_rasterize_silently_instead_of_prompting():
# Clone/heal/brush/etc. hit the same rasterize prompt on text/shape/placed
# layers, so every paint tool now rasterizes silently and continues.
editor = (ROOT / "static/js/galleryEditor.js").read_text()
assert "// fall through — `layer` is a pixel layer now, so the tool runs below." in editor
assert "if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive &&" in editor
def test_smudge_and_dodge_icons_are_distinct():
toolbar = (ROOT / "static/js/editor/build/toolbar.js").read_text()
# Smudge is a pointing hand/finger, not a swirl.
assert "M12.5 12V5.5a1.75 1.75 0 1 1 3.5 0V12" in toolbar
assert "M16 12v-1.5a1.5 1.5 0 0 1 3 0V16a5 5 0 0 1-5 5h-2.2" in toolbar
# Dodge is the mirrored, filled dot.
assert "m9.5 14.5-6 6" in toolbar
assert '<circle cx="14" cy="10" r="6" fill="currentColor"/>' in toolbar
+2
View File
@@ -81,6 +81,8 @@ def test_selection_tool_menu_has_selection_ops():
assert "Feather…" in ls
assert "Expand" in ls
assert "Contract" in ls
assert "Fill Selection" in ls
assert "Delete Selection" in ls
def test_selection_items_disabled_without_selection():
+15 -8
View File
@@ -35,19 +35,26 @@ def test_editor_paste_owns_clipboard_images_while_editor_open():
def test_editor_ctrl_v_pastes_the_internal_selection_clipboard():
"""Ctrl/Cmd+V must paste the internal selection clipboard directly.
"""Ctrl/Cmd+V must not shadow a newer OS-clipboard image.
On an insecure origin (the editor is served over http on a LAN IP)
``navigator.clipboard`` is unavailable, so copying a selection never reaches
the OS clipboard and the native ``paste`` event carries stale ``text/plain``
-> the internal-paste branch is skipped and Ctrl+V silently does nothing
(the toolbar buttons worked because they bypass the event entirely).
Intercepting the keystroke (preventDefault) bypasses the browser's paste
event and always re-pasted ``state.internalClipboard``, so a screenshot
taken outside the gallery could never be pasted. The key handler may only
fall back to the internal copy when the paste event delivered no image
(insecure origin: copy never reaches the OS clipboard).
"""
src = SRC.read_text(encoding="utf-8")
# Shared, hoisted paste helper (also used by the native paste listener).
assert "const pasteAsLayer = (imgSource, label, offset = { x: 0, y: 0 }) => {" in src
# Explicit keyboard handler for the internal clipboard.
# The paste listener must prefer real clipboard images.
assert "const imageItem = Array.from(e.clipboardData?.items || [])" in src
# Deferred keyboard fallback for the internal clipboard.
assert "window.addEventListener('keydown', (e) => {" in src
assert "if (String(e.key || '').toLowerCase() !== 'v') return;" in src
assert "if (!state.internalClipboard) return;" in src
assert "pasteKeyPending = true;" in src
assert "if (!pasteKeyPending) return;" in src
assert "pasteAsLayer(state.internalClipboard, 'Pasted Selection'" in src
# The key handler must NOT swallow the keystroke any more.
keydown = src[src.index("// ── Ctrl/Cmd+V ──"):src.index("// ── Drag-and-drop ──")]
assert "e.preventDefault();" not in keydown
assert "e.stopImmediatePropagation();" not in keydown
+1 -1
View File
@@ -23,7 +23,7 @@ def test_color_panels_module_shape():
def test_right_panel_spreads_extra_panels():
src = read(RIGHT_PANEL)
assert "export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = [] }) {" in src
assert "export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = [], onCloseGroup = null }) {" in src
assert "...(Array.isArray(extraPanels) ? extraPanels : [])," in src
@@ -0,0 +1,36 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
def test_async_composite_renders_into_a_back_buffer():
assert "let _documentBackCanvas = null;" in EDITOR
assert "const copyBack = () => {" in EDITOR
start = EDITOR.index(" if (asyncComposite) {")
end = EDITOR.index(" _asyncCompositeInFlight = job;", start)
block = EDITOR[start:end]
assert "_documentBackCanvas = document.createElement('canvas');" in block
assert "_drawDocumentLayersAsync(back.getContext('2d'), render.isCurrent)" in block
assert "_drawDocumentLayers(back.getContext('2d'), render.isCurrent);" in block
# The document canvas must only be written by a completed generation, so an
# invalidated render can never leave a half-drawn document on screen.
assert "_drawDocumentLayersAsync(documentCanvas.getContext('2d')" not in block
assert "_drawDocumentLayers(documentCanvas.getContext('2d')" not in block
assert block.count("if (!render.isCurrent()) return;") >= 2
def test_completed_generation_replaces_the_document_atomically():
copy = EDITOR[EDITOR.index(" const copyBack = () => {"):EDITOR.index(" const renderPromise =")]
assert "dctx.clearRect(0, 0, documentCanvas.width, documentCanvas.height);" in copy
assert "dctx.drawImage(back, 0, 0);" in copy
assert "_finishComposite(render, documentCanvas);" in copy
def test_direct_drawing_stays_synchronous_for_live_feedback():
start = EDITOR.index(" const asyncComposite = _hasRetainedEffects()")
end = EDITOR.index(";", EDITOR.index("typeof OffscreenCanvas !== 'undefined'", start))
gate = EDITOR[start:end]
assert "&& !state.drawing" in gate
assert "&& !state.moving" in gate
+62
View File
@@ -0,0 +1,62 @@
"""Each docked section's "..." menu closes that whole section.
Audit/request (2026-10-13): a dock section (Color, Adjustments, Panels,
History) has no way to be dismissed from itself — you had to hunt through
Window > panel toggles. The section's "..." (tabs-more) menu now carries a
"Close <group>" row (Photoshop's "Close Tab Group") that hides every panel
in the group; the group re-collapses out of the rail and returns when one of
its panels is re-enabled from the Window menu.
"""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
DOCK = (ROOT / "static/js/editor/build/dock.js").read_text(encoding="utf-8")
RIGHT = (ROOT / "static/js/editor/build/right-panel.js").read_text(encoding="utf-8")
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
def test_dock_accepts_and_uses_onCloseGroup():
assert "onCloseGroup = null" in DOCK
assert "typeof onCloseGroup === 'function'" in DOCK
assert "onCloseGroup(group.name)" in DOCK
def test_tabs_menu_offers_close_section():
assert "ge-dock-tabs-menu-close" in DOCK
assert "ge-dock-tabs-menu-sep" in DOCK
i = DOCK.index("ge-dock-tabs-menu-close")
block = DOCK[i - 400:i + 200]
assert "Close ${group.name}" in block
def test_more_button_shown_when_a_close_handler_exists():
i = DOCK.index("const overflowing = tabsEl.scrollWidth")
block = DOCK[i:i + 400]
assert "typeof onCloseGroup === 'function'" in block
def test_right_panel_forwards_onCloseGroup():
assert "onCloseGroup = null" in RIGHT
i = RIGHT.index("onCloseGroup,")
block = RIGHT[i:i + 60]
assert "});" in block
def test_composer_closes_every_panel_in_the_group():
i = EDITOR.index("onCloseGroup: (groupName) =>")
block = EDITOR[i:i + 260]
assert "panelVisibility.PANELS" in block
assert "p.group === groupName" in block
assert "panelVisibility.setVisible(p.id, false)" in block
def test_close_menu_styling_present():
assert ".ge-dock-tabs-menu-close {" in CSS
assert ".ge-dock-tabs-menu-sep {" in CSS
def test_sw_cache_bumped():
assert re.search(r"CACHE_NAME = 'odysseus-v\d+-pr40-editor-precache'", SW)
+20
View File
@@ -79,6 +79,15 @@ def test_dock_css_present():
assert token in css, token
def test_panel_group_divider_resizes_with_a_fixed_basis():
src = DOCK.read_text(encoding="utf-8")
assert "function wireDivider(divider, groupIndex) {" in src
# The dragged group must keep an exact, non-shrinking basis; a `1 1` basis
# lets the height-constrained dock shrink it back to 0 (dead divider).
assert "target.style.flex = `0 0 ${Math.round(next)}px`;" in src
assert "1 1 ${Math.round(next)}px" not in src
def test_status_bar_and_swatch_markup():
topbar = (ROOT / "static/js/editor/build/topbar.js").read_text(encoding="utf-8")
assert "ge-status-tool" in topbar
@@ -103,3 +112,14 @@ def test_3d_and_plugins_stubs_are_disabled():
def test_dock_module_is_precached():
sw = SW.read_text(encoding="utf-8")
assert "'/static/js/editor/build/dock.js'" in sw
def test_dock_divider_double_click_expands_and_restores():
src = DOCK.read_text(encoding="utf-8")
assert "divider.addEventListener('dblclick'" in src
assert "target.dataset.dockExpanded === '1'" in src
assert "target.dataset.dockPrevSize = String(" in src
assert "target.dataset.dockExpanded = '1';" in src
# a plain click from the double-click must not clear the expand flag
assert "let moved = false;" in src
assert "if (!moved) { moved = true;" in src
+1 -1
View File
@@ -21,7 +21,7 @@ def test_dock_panel_wide_enough_for_full_tab_labels():
panel = css.split(".ge-right-panel {")[1].split("}")[0]
assert "width: 340px" in panel
tab = css.split(".ge-dock-tab {")[1].split("}")[0]
assert "font-size: 9px" in tab
assert "font-size: 10px" in tab
def test_history_and_actions_are_their_own_dock_group():
+1 -1
View File
@@ -18,7 +18,7 @@ def test_dock_recomputes_tab_overflow_after_layout():
def test_dock_overflow_chevron_tracks_scroll_width():
src = DOCK.read_text(encoding="utf-8")
assert "const overflowing = tabsEl.scrollWidth - tabsEl.clientWidth > 1;" in src
assert "moreEl.hidden = !overflowing;" in src
assert "moreEl.hidden = !(overflowing || typeof onCloseGroup === 'function');" in src
def test_dock_hides_partial_tabs_instead_of_clipping_labels():
@@ -0,0 +1,23 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
def test_drop_shadow_dialog_selects_a_direction_and_distance():
# Drop shadows are stored as x/y offsets, but the dialog has to expose a
# direction: convert at the boundary instead of asking for H/V pixels.
assert "function _shadowAngleDistance(params = {}) {" in EDITOR
assert "function _shadowOffsets(values = {}) {" in EDITOR
assert "angle: Math.round((Math.atan2(-y, x) * 180) / Math.PI)," in EDITOR
assert "x: Math.round(Math.cos(radians) * distance)," in EDITOR
assert "y: Math.round(-Math.sin(radians) * distance)," in EDITOR
assert "{ key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance" in EDITOR
assert "{ key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance" in EDITOR
assert "{ key: 'x', label: 'Horizontal'" not in EDITOR
assert "{ key: 'y', label: 'Vertical'" not in EDITOR
# Both the add and the edit dialogs turn the values back into offsets.
assert EDITOR.count("..._shadowOffsets(values)") == 2
assert EDITOR.count("..._shadowOffsets(result)") == 2
# Soft-shadow presets kept their metre; the preset loop migrates them.
assert "{ ...preset.params, ..._shadowAngleDistance(preset.params) }" in EDITOR
+52
View File
@@ -0,0 +1,52 @@
import pathlib
ROOT = pathlib.Path(__file__).resolve().parents[1]
PANEL = (ROOT / "static/js/editor/layer-panel.js").read_text(encoding="utf-8")
SORT = (ROOT / "static/js/dragSort.js").read_text(encoding="utf-8")
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
def test_effect_rows_can_be_dragged_onto_layers_to_copy_them():
# Photoshop-style: drag an effect sub-row onto another layer/group and it
# is copied there (the source keeps its effect).
assert "function cloneEffectForCopy(effect) {" in PANEL
assert "function wireEffectDrag(sub, owner, effect) {" in PANEL
assert "wireEffectDrag(sub, group, effect);" in PANEL
assert "wireEffectDrag(sub, layer, effect);" in PANEL
assert "'.ge-layer-item[data-layer-id], .ge-layer-group-row[data-group-id]'" in PANEL
assert "layer.effects.push(cloneEffectForCopy(effect));" in PANEL
assert "saveState(`Copy ${effectLabel(effect.type)}`);" in PANEL
assert "copied to ${layer.name}" in PANEL
def test_effect_copy_gets_fresh_ids_and_its_own_mask_canvas():
clone = PANEL[PANEL.index("function cloneEffectForCopy(effect) {"):]
clone = clone[:clone.index("\n }")]
assert "id: `effect-${Math.random().toString(36).slice(2, 9)}`," in clone
assert "id: `effect-mask-${Math.random().toString(36).slice(2, 9)}`" in clone
assert "const canvas = document.createElement('canvas');" in clone
assert "canvas.getContext('2d').drawImage(effect.mask.canvas, 0, 0);" in clone
assert "copy.mask.canvas = canvas;" in clone
assert "copy.mask.ctx = canvas.getContext('2d');" in clone
def test_effect_drag_does_not_start_the_layer_reorder():
enable = PANEL[PANEL.index("dragSortModule.enable('ge-layers-list'"):]
enable = enable[:enable.index("onReorder:")]
assert ".ge-adj-sub-item" in enable
# A drop re-renders the list mid-drag, so the snap-back must bail out when
# the placeholder/dragged row were already detached.
assert "if (!placeholder.parentNode || !draggedEl.isConnected) {" in SORT
assert "placeholder.remove();\n return;" in SORT
def test_effect_drag_has_ghost_and_drop_target_styles():
assert ".ge-effect-drag-ghost {" in CSS
ghost = CSS[CSS.index(".ge-effect-drag-ghost {"):]
ghost = ghost[:ghost.index("}")]
assert "position: fixed;" in ghost
assert "pointer-events: none;" in ghost
assert "z-index: 10030;" in ghost
assert ".ge-layer-item.is-effect-drop-target," in CSS
assert ".ge-layer-group-row.is-effect-drop-target {" in CSS
assert ".ge-layer-item.is-effect-dragging {" in CSS
+1 -1
View File
@@ -67,7 +67,7 @@ def test_extra_menus_are_precached_and_mounted():
assert "'/static/js/editor/wire-extra-menus.js'" in SW
assert "import { wireExtraMenus } from './editor/wire-extra-menus.js';" in GALLERY
assert "wireExtraMenus({" in GALLERY
assert "export function wireExtraMenus({ closeOtherTopbarMenus, registerDocClickAway, dock, combineShapes, workspace, uiModule, exportLayeredPsd, syncWindowChecks, togglePanel })" in WIRE
assert "export function wireExtraMenus({ closeOtherTopbarMenus, registerDocClickAway, dock, combineShapes, workspace, saveWorkspace, uiModule, exportLayeredPsd, syncWindowChecks, togglePanel, toggleQuickEdit, newDocument, promptNewDocument, newFromClipboard })" in WIRE
def test_cross_menu_close_knows_new_menus():
+47
View File
@@ -0,0 +1,47 @@
"""The eyedropper shows a floating loupe next to the pointer while sampling.
The options bar already carries a live pixel preview (#ge-eyedropper-loupe).
When the eyedropper is pressed the same preview should also pop up next to the
mouse, so you can see the exact pixel being sampled.
"""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
TOOL = (ROOT / "static/js/editor/tools/eyedropper.js").read_text(encoding="utf-8")
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
def test_eyedropper_passes_the_event_to_the_preview():
assert "syncPreview?.(result?.color || null, result, e);" in TOOL
assert "syncPreview?.(state.color, result, e);" in TOOL
assert "syncPreview?.(null, null, null);" in TOOL
def test_editor_builds_a_floating_cursor_loupe():
assert "el.className = 'ge-eyedropper-cursor-loupe';" in EDITOR
assert "function _drawEyedropperLoupe(canvas, sample)" in EDITOR
assert "_cursorLoupeEl.classList.toggle('is-visible', _cursorLoupeVisible);" in EDITOR
def test_cursor_loupe_shows_on_press_and_hides_on_release():
assert "if (state.tool === 'eyedropper') { _setCursorLoupe(true, e); return _eyedropperTool.pick(e); }" in EDITOR
assert "if (_cursorLoupeVisible) _setCursorLoupe(false);" in EDITOR
def test_cursor_loupe_css_is_a_pointer_tracking_circle():
j = CSS.find(".ge-eyedropper-cursor-loupe {")
assert j != -1
block = CSS[j:j + 500]
assert "position: fixed;" in block
assert "pointer-events: none;" in block
assert "border-radius: 50%;" in block
assert ".ge-eyedropper-cursor-loupe.is-visible {" in CSS
def test_sw_version_is_current():
m = re.search(r"CACHE_NAME = '(odysseus-v\d+-pr40-editor-precache)'", SW)
assert m, "CACHE_NAME not found"
assert re.fullmatch(r"odysseus-v\d+-pr40-editor-precache", m.group(1))
+27
View File
@@ -0,0 +1,27 @@
"""Undoing an "add effect" step must remove the effect from the layer.
Regression: `_restoreState` rebuilt each layer but never restored `layer.effects`
(only groups got theirs back), so a freshly added gradient survived the first
Ctrl+Z and only disappeared on a second undo that restored an older snapshot.
"""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
def test_restore_state_restores_layer_effects():
assert "layer.effects = Array.isArray(s.effects)" in EDITOR
assert "? s.effects.map(effect => _normalizeEffect(effect))" in EDITOR
assert "layer._effectPreview = null;" in EDITOR
def test_restore_state_still_restores_group_effects():
assert "effects: Array.isArray(group.effects) ? group.effects.map(effect => _normalizeEffect(effect)) : []," in EDITOR
def test_sw_cache_name_bumped():
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
+10
View File
@@ -57,6 +57,16 @@ def test_moves_share_pixels_but_keep_independent_offsets_and_changed_pixels():
assert run_node(script) == {"count": 20, "bytes": 400, "first": 0, "last": 19, "shared": True, "changed": True}
def test_moves_share_pixels_with_a_four_byte_comparison():
# The pixel comparison runs over every byte of every layer on each undo
# snapshot; comparing Uint32 words keeps pen/selection commits fast.
source = (ROOT / "static/js/editor/history-budget.js").read_text(encoding="utf-8")
assert "function samePixels(image, other) {" in source
assert "new Uint32Array(image.data.buffer, image.data.byteOffset, length >> 2)" in source
assert "new Uint32Array(other.data.buffer, other.data.byteOffset, length >> 2)" in source
assert "candidates.find(other => samePixels(image, other))" in source
def test_history_budget_counts_saved_selections_and_group_masks():
script = textwrap.dedent(
f"""
+3 -1
View File
@@ -47,7 +47,9 @@ def test_tool_key_selects_its_tool(tool, key):
assert ("\n " + tool + ": { label: '") in TOOLBAR_JS
# … and the keyboard handler focuses the palette by key.
assert "const toolId = toolKeyMap[e.key.toLowerCase()];" in KEYBOARD_JS
assert 'const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`) || toolbar.querySelector(`[data-tool-option="${toolId}"]`);' in KEYBOARD_JS
assert 'const flyoutItem = toolbar.querySelector(`[data-tool-option="${toolId}"]`);' in KEYBOARD_JS
assert "const slotEl = flyoutItem ? flyoutItem.closest('.ge-tool-slot') : null;" in KEYBOARD_JS
assert "toolBtn = toolBtn || toolbar.querySelector(`[data-tool=\"${toolId}\"]`) || flyoutItem;" in KEYBOARD_JS
assert "if (toolBtn) toolBtn.click();" in KEYBOARD_JS
+25
View File
@@ -27,6 +27,15 @@ def test_deleting_the_last_layer_is_allowed():
assert "else { state.activeLayerId = null; state.selectedLayerIds = []; state.activeGroupId = null; }" in panel
def test_enter_confirms_the_transform_even_from_a_transform_field():
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
# A form field would normally swallow Enter; an active transform must still
# accept when Enter is pressed from one of its number inputs.
assert "!(state.transformActive && e.key === 'Enter')) return;" in keyboard
assert "if (e.key === 'Enter' && state.transformActive) {" in keyboard
assert "confirmTransform();" in keyboard
def test_ctrl_zoom_shortcuts_drive_the_toolbar_and_override_the_browser():
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
# Ctrl/Cmd +/-/0 zoom the canvas (they call the toolbar zoom buttons and
@@ -36,3 +45,19 @@ def test_ctrl_zoom_shortcuts_drive_the_toolbar_and_override_the_browser():
assert "document.getElementById('ge-zoom-100')?.click();" in keyboard
assert "e.code === 'NumpadAdd'" in keyboard
assert "e.code === 'NumpadSubtract'" in keyboard
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.
assert "e.altKey && e.code === 'KeyL'" in keyboard
assert "document.querySelector('[data-image-action=\"adj-levels\"]')" in keyboard
def test_ctrl_alt_shift_e_stamps_the_visible_layers():
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
# Photoshop's Stamp Visible reuses the layer footer's "Flatten Copy", which
# merges every visible layer into a new layer on top and keeps the originals.
assert "e.altKey && e.shiftKey && e.code === 'KeyE'" in keyboard
assert "document.getElementById('ge-flatten')" in keyboard
+22
View File
@@ -0,0 +1,22 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PANEL = (ROOT / "static/js/editor/layer-panel.js").read_text(encoding="utf-8")
SORT = (ROOT / "static/js/dragSort.js").read_text(encoding="utf-8")
def test_layer_rows_are_draggable_from_anywhere():
block = PANEL[PANEL.index("dragSortModule.enable('ge-layers-list'"):]
block = block[: block.index("onReorder:")]
# The whole row (grip, thumbnail, name) starts a reorder once the
# pointer moves past the threshold; interactive controls are excluded.
assert "threshold: 4" in block
assert "ignoreSelector:" in block
assert "handleSelector: '.ge-layer-drag'" not in block
def test_drag_sort_waits_for_the_movement_threshold():
assert "threshold: Math.max(0, Number(options.threshold) || 0)," in SORT
assert "pending = { item, startY: e.clientY };" in SORT
assert "Math.abs(e.clientY - pending.startY) < config.threshold" in SORT
assert "pending = null;" in SORT
+69
View File
@@ -0,0 +1,69 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LIQUIFY = ROOT / "static/js/editor/tools/liquify.js"
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
TOOLBAR = (ROOT / "static/js/editor/build/toolbar.js").read_text(encoding="utf-8")
SHORTCUTS = (ROOT / "static/js/editor/tool-shortcuts.js").read_text(encoding="utf-8")
CONTROLS = (ROOT / "static/js/editor/build/controls.js").read_text(encoding="utf-8")
OPTIONS = (ROOT / "static/js/editor/build/options-bar.js").read_text(encoding="utf-8")
STATE = (ROOT / "static/js/editor/state.js").read_text(encoding="utf-8")
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
SRC = LIQUIFY.read_text(encoding="utf-8")
def test_liquify_module_exposes_a_warp_brush():
assert "export function createLiquifyTool({ activeLayer, saveState, composite })" in SRC
assert "const MODES = new Set(['push', 'twirl', 'pucker', 'bloat', 'smooth']);" in SRC
assert "saveState('Liquify')" in SRC
for name in ("begin(event)", "drag(event)", "end()", "cancel()"):
assert name in SRC
# Per-stroke undo: the snapshot is taken before the first dab mutates pixels.
assert SRC.index("saveState('Liquify')") < SRC.index("state.drawing = true")
# Warp maths for every mode.
for token in ("function dab(", "sampleBilinear", "average3x3", "strokeTo"):
assert token in SRC
def test_liquify_state_and_toolbar_entry():
assert "liquifyMode: 'push'," in STATE
assert "liquifySize: 120," in STATE
assert "liquifyStrength: 50," in STATE
assert "liquify: { label: 'Liquify', icon:" in TOOLBAR
assert "['smudge', 'dodge', 'burn', 'liquify']" in TOOLBAR
assert "liquify: 'K'," in SHORTCUTS
def test_liquify_controls_and_options_bar():
assert 'id="ge-liquify-section"' in CONTROLS
for value in ("push", "twirl", "pucker", "bloat", "smooth"):
assert f'<option value="{value}">' in CONTROLS
assert 'id="ge-liquify-mode"' in CONTROLS
assert 'id="ge-liquify-size"' in CONTROLS
assert 'id="ge-liquify-strength"' in CONTROLS
assert "LIQUIFY_TOOLS[toolId]" in OPTIONS
assert "makeSelect('Mode', '#ge-liquify-mode')" in OPTIONS
assert "makeRange('Strength', src.strength)" in OPTIONS
assert ".ge-liquify-hint {" in CSS
def test_liquify_is_wired_into_the_draw_pipeline():
assert "import { createLiquifyTool } from './editor/tools/liquify.js';" in EDITOR
assert "const _liquifyTool = createLiquifyTool({" in EDITOR
assert "'bucket', 'liquify']" in EDITOR # pixel-paint rasterize gate
assert "if (state.tool === 'liquify') return _liquifyTool.begin(e);" in EDITOR
assert "return _liquifyTool.drag(e);" in EDITOR
# Liquify hides the native cursor, so the hover pass must keep the size
# circle visible even though the branch returns before the shared block.
assert "if (overCanvas) _updateBrushCursor(e);" in EDITOR
assert "if (state.tool === 'liquify') return _liquifyTool.end(e);" in EDITOR
# Tool-switch sync: section, color row, circle cursor.
assert "liquifySection.style.display = toolId === 'liquify' ? '' : 'none';" in EDITOR
assert "['eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify']" in EDITOR
assert "'dodge', 'burn', 'liquify'].includes(state.tool)" in EDITOR
assert "basePx = Number(state.liquifySize) || 120;" in EDITOR
def test_pen_action_row_sits_left_with_aligned_icons():
assert "#ge-pen-section .ge-control-row.ge-actions { margin-left: 0; }" in CSS
assert "#ge-pen-commit svg { position: relative; top: 2px; }" in CSS
+273
View File
@@ -0,0 +1,273 @@
"""Marquee options simplify to Free / Fixed ratio, and no duplicate mode row.
The options strip used to render a second copy of the marquee mode buttons
(New / + / - / intersect via `ge-opt-mode` mirrors) that duplicated the real
`ge-wand-mode-btn` buttons relocated into the strip. Those mirrors were
removed, and the "Fixed size" marquee style was dropped (only Free and Fixed
ratio remain).
"""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
CONTROLS = (ROOT / "static/js/editor/build/controls.js").read_text(encoding="utf-8")
OPTIONS = (ROOT / "static/js/editor/build/options-bar.js").read_text(encoding="utf-8")
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
DOC_EDITOR_CSS = (ROOT / "static/css/documents-gallery-editor.css").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
def test_marquee_style_has_no_fixed_size_option():
assert 'id="ge-marquee-constraint"' in CONTROLS
i = CONTROLS.index('id="ge-marquee-constraint"')
block = CONTROLS[i:i + 240]
assert '<option value="free">Free</option>' in block
assert '<option value="ratio">Fixed ratio</option>' in block
assert 'value="size"' not in block
assert "Fixed size" not in CONTROLS
def test_marquee_has_no_duplicate_mode_mirror_row():
assert "marquee: { modes: null, feather: null }," in OPTIONS
assert "if (src.modes) parts.push(makeModes(src.modes));" in OPTIONS
def test_no_selection_tool_renders_a_mode_mirror_row():
# The real wand-mode buttons are relocated into the strip, so a mirror row
# duplicates (and collides with) them for every selection tool.
block = OPTIONS[OPTIONS.index("const SELECTION_TOOLS = {"):]
block = block[:block.index("};")]
assert "modes: '#ge-" not in block
assert "modes: null" in block
def test_sw_cache_name_bumped():
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
def test_options_body_shrinks_to_keep_controls_left_aligned():
# An empty mirror body must not stretch and push the relocated controls
# (and the marquee action row) away from the left edge.
i = CSS.index(".ge-options-body {")
block = CSS[i:i + 420]
assert "flex: 0 1 auto;" in block
assert "flex: 1 1 auto;" not in block
def test_marquee_action_row_is_nudged_up():
assert ".ge-controls--in-options .ge-control-row.ge-actions { position: relative; top: -2px; margin-left: auto; }" in CSS
def test_auto_select_checkbox_is_accent_when_checked():
assert 'class="ge-ctl-check"' in OPTIONS # the auto-select input uses the shared control
i = DOC_EDITOR_CSS.index('body.ge-editor-open .ge-opt input[type="checkbox"]:checked,')
block = DOC_EDITOR_CSS[i:i + 220]
assert "background: var(--accent, var(--red));" in block
assert "background: var(--fg);" not in block
def test_wand_live_button_sits_next_to_the_intersect_mode_and_is_nudged_up():
# Live now sits in the mode row, immediately after the intersect (∩) button.
i = CONTROLS.index('id="ge-wand-section"')
j = CONTROLS.index('id="ge-wand-tol-preview"')
mode_row = CONTROLS[i:j]
assert mode_row.index('data-wand-mode="intersect"') < mode_row.index('id="ge-wand-live"')
# the tolerance row no longer carries the Live button
k = CONTROLS.index('id="ge-wand-tol-preview"')
tol_row = CONTROLS[k:k + 400]
assert 'id="ge-wand-live"' not in tol_row
c = CSS.index(".ge-wand-live-btn {")
live_block = CSS[c:CSS.index("}", c)]
assert "top: 0;" in live_block
def test_wand_slider_label_hugs_its_text():
assert "#ge-wand-section .ge-eraser-row label { flex: 0 0 auto; }" in CSS
def test_wand_action_row_is_nudged_up_like_marquee():
# The wand action row (eye/clear/invert/erase/...) is raised 2px, matching
# the marquee row, by dropping its inline top margin from 4px to 2px.
i = CONTROLS.index('id="ge-wand-section"')
j = CONTROLS.index('id="ge-sam-section"')
wand = CONTROLS[i:j]
assert '<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">' in wand
assert '<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">' not in wand
def test_wand_action_row_matches_marquee():
# Wand and marquee share the same selection actions; the wand row must use
# the same compact buttons as the marquee row (icon-only clear, 14px icons,
# same labels/modes) instead of its own older 12px + text variants.
i = CONTROLS.index('id="ge-wand-section"')
j = CONTROLS.index('id="ge-sam-section"')
wand = CONTROLS[i:j]
assert '<button class="ge-btn ge-btn-sm" id="ge-wand-clear" title="Clear selection" aria-label="Clear selection">' in wand
assert 'data-wand-mode="add" title="Add to selection (Shift)">+</button>' in wand
assert 'data-wand-mode="subtract" title="Subtract from selection (Alt)">−</button>' in wand
for label in ("Invert", "Erase", "Copy Layer", "To Mask", "Quick Mask"):
assert label in wand
assert wand.count('<svg width="14" height="14"') >= 6
assert '<svg width="12" height="12"' not in wand
def test_wand_refine_sliders_are_inline():
# The wand strip carries its post-selection refine sliders (Feather + Edge
# stroke) and the tip directly: the old "More" dropdown was removed.
i = CONTROLS.index('id="ge-wand-section"')
j = CONTROLS.index('id="ge-sam-section"')
wand = CONTROLS[i:j]
assert 'id="ge-wand-more"' not in wand
assert 'ge-wand-more-wrap' not in wand
assert 'id="ge-wand-refine-feather"' in wand
assert 'id="ge-wand-refine-grow"' in wand
assert wand.index('id="ge-wand-refine-feather"') < wand.index('id="ge-wand-refine-grow"')
assert 'class="ge-wand-tip"' not in wand
def test_shared_more_menu_css_lets_the_strip_overflow():
assert ".ge-wand-more-menu {" in CSS
assert ".ge-wand-more-menu[hidden] { display: none; }" in CSS
for menu in ("wand", "brush", "lasso", "sam", "quickselect", "objectselect"):
assert f".ge-options-bar:has(#ge-{menu}-more-menu:not([hidden]))" in CSS
def test_wand_has_no_more_dropdown_wiring():
wire = (ROOT / "static/js/editor/wire-selection-controls.js").read_text(encoding="utf-8")
assert "getElementById('ge-wand-more')" not in wire
assert "getElementById('ge-wand-more-menu')" not in wire
# the other selection tools keep their shared per-tool More menu
assert "document.getElementById(`${prefix}-more-menu`)" in wire
assert "const closeWandMore = () => {" not in wire
def test_lasso_action_row_matches_marquee_and_wand():
# The lasso strip is unified with marquee/wand: same compact action row
# (icon-only clear, 14px icons, same labels) plus the visibility eye.
i = CONTROLS.index('id="ge-lasso-section"')
j = CONTROLS.index('id="ge-wand-section"')
lasso = CONTROLS[i:j]
assert '<button class="ge-btn ge-btn-sm" id="ge-lasso-clear" title="Clear selection" aria-label="Clear selection">' in lasso
assert 'id="ge-lasso-vis"' in lasso
for label in ("Invert", "Erase", "Copy Layer", "To Mask", "Quick Mask"):
assert label in lasso
assert lasso.count('<svg width="14" height="14"') >= 7
assert '<svg width="12" height="12"' not in lasso
assert 'class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;"' in lasso
def test_lasso_secondary_controls_are_in_a_more_dropdown():
i = CONTROLS.index('id="ge-lasso-section"')
j = CONTROLS.index('id="ge-wand-section"')
lasso = CONTROLS[i:j]
assert 'id="ge-lasso-more"' in lasso
assert 'class="ge-lasso-more-wrap"' in lasso
assert '<path d="m6 9 6 6 6-6"/>' in lasso
assert lasso.index('id="ge-lasso-more-menu"') < lasso.index('id="ge-lasso-refine-feather"')
assert lasso.index('id="ge-lasso-mag-contrast') < lasso.index('ge-lasso-tip')
# ids other tests rely on stay put
for keep in ('id="ge-lasso-magnetic"', 'id="ge-lasso-mag-width"', 'id="ge-lasso-mag-contrast"'):
assert keep in lasso
def test_lasso_more_dropdown_css_and_wire():
assert ".ge-lasso-more-menu {" in CSS
assert ".ge-lasso-more-menu[hidden] { display: none; }" in CSS
assert ".ge-options-bar:has(#ge-lasso-more-menu:not([hidden])) { overflow: visible; }" in CSS
wire = (ROOT / "static/js/editor/wire-selection-controls.js").read_text(encoding="utf-8")
assert "const lassoMore = document.getElementById('ge-lasso-more');" in wire
assert "const closeLassoMore = () => {" in wire
assert "document.getElementById('ge-lasso-clear')?.addEventListener('click', wandClear);" in wire
assert "'ge-lasso-vis'" in wire
def test_lasso_clear_clears_the_selection_mask():
# The toolbar clear badges (data-clear-tool="lasso"/"quickselect"/
# "objectselect") route through the same _wandClear path as marquee/wand,
# so they clear the materialized selection.
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
assert ("if (which === 'marquee' || which === 'wand' || which === 'lasso'\n"
" || which === 'quickselect' || which === 'objectselect') {") in editor
assert "#ge-marquee-clear, #ge-wand-clear, #ge-lasso-clear" in editor
def _selection_section(tool):
import re as _re
m = _re.search(rf'id="ge-{tool}-section"[\s\S]*?\n </div>', CONTROLS)
assert m, f"section ge-{tool}-section not found"
return m.group(0)
def test_quickselect_objectselect_sam_match_the_wand_strip():
# The three remaining selection tools were refactored to the wand shape:
# compact mode labels, a caret "More" menu (help moved into it), and the
# unified 7-button action row.
for tool in ("sam", "quickselect", "objectselect"):
sec = _selection_section(tool)
assert 'class="ge-wand-more-wrap"' in sec
assert f'id="ge-{tool}-more"' in sec
assert f'id="ge-{tool}-more-menu"' in sec
assert 'aria-haspopup="true"' in sec
assert 'class="ge-wand-tip"' in sec
# compact mode labels (no verbose "+ Add" / "− Subtract")
assert "+ Add</button>" not in sec
assert "− Subtract</button>" not in sec
assert 'data-wand-mode="add" title="Add to selection">+</button>' in sec
assert 'data-wand-mode="subtract" title="Subtract from selection">−</button>' in sec
# the unified action row
assert '<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">' in sec
assert f'id="ge-{tool}-clear" title="Clear selection" aria-label="Clear selection"' in sec
for action in ("vis", "clear", "invert", "delete", "copy", "mask"):
assert f'id="ge-{tool}-{action}"' in sec
for label in ("Invert", "Erase", "Copy Layer", "To Mask", "Quick Mask"):
assert label in sec
assert sec.count('<svg width="14" height="14"') >= 7
def test_selection_tool_carets_reuse_the_wand_classes():
# CSS: the new menus reuse the wand caret classes and un-clip the strip.
for tool in ("sam", "quickselect", "objectselect"):
assert f".ge-options-bar:has(#ge-{tool}-more-menu:not([hidden])) {{ overflow: visible; }}" in CSS
# the clear ✕ nudge + active-accent rules cover the new buttons too
for tool in ("sam", "quickselect", "objectselect"):
assert f"#ge-{tool}-clear svg" in CSS
assert f"#ge-{tool}-clear.has-selection" in CSS
def test_selection_tool_action_wiring_is_shared():
wire = (ROOT / "static/js/editor/wire-selection-controls.js").read_text(encoding="utf-8")
assert "const wireSelectionCaret = (prefix) => {" in wire
assert "['ge-sam', 'ge-quickselect', 'ge-objectselect'].forEach((prefix) => {" in wire
assert "document.getElementById(`${prefix}-vis`)?.addEventListener('click', toggleSelectionOverlay);" in wire
assert "document.getElementById(`${prefix}-clear`)?.addEventListener('click', wandClear);" in wire
assert "document.getElementById(`${prefix}-delete`)?.addEventListener('click', wandDeleteSelection);" in wire
assert "document.getElementById(`${prefix}-mask`)?.addEventListener('click', wandToMask);" in wire
assert "wireSelectionCaret(prefix);" in wire
# the old one-off SAM bindings were removed (no double toggle / double bind)
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
assert "document.getElementById('ge-sam-vis')?.addEventListener('click', () => {" not in editor
assert "document.getElementById('ge-sam-clear')?.addEventListener('click', () => _wandClear());" not in editor
# the clear indicator lights the new buttons too
assert "#ge-marquee-clear, #ge-wand-clear, #ge-lasso-clear, #ge-sam-clear, #ge-quickselect-clear, #ge-objectselect-clear" in editor
def test_quickselect_brush_size_slider_is_wired():
wire = (ROOT / "static/js/editor/wire-selection-controls.js").read_text(encoding="utf-8")
assert "state.quickSelectBrushSize = Math.max(4, Math.min(200, parseInt(quickSize.value, 10) || 24));" in wire
def test_quickselect_brush_size_uses_the_shared_eraser_row_slider_style():
# Quick Select's brush-size slider kept the bare .ge-ctl-range look (14px,
# transparent) instead of the shared .ge-eraser-row slider the wand
# Tolerance row uses. Its row now uses the same pattern (8px track, preview
# dot, value chip) so every tool's slider reads the same.
q = _selection_section("quickselect")
assert 'class="ge-control-row ge-eraser-row"' in q
assert 'id="ge-quickselect-size-preview"' in q
assert 'id="ge-quickselect-size-label"' in q
assert 'class="ge-quickselect-size" id="ge-quickselect-size"' in q
assert "#ge-quickselect-section .ge-eraser-row label { flex: 0 0 auto; }" in CSS
wire = (ROOT / "static/js/editor/wire-selection-controls.js").read_text(encoding="utf-8")
assert "document.getElementById('ge-quickselect-size-label')" in wire
@@ -19,6 +19,12 @@ def test_topbar_has_file_edit_help_menus():
'data-file-action="import"',
'data-file-action="save"',
'data-file-action="save-project"',
'data-submenu="file-new"',
'data-file-action="new-default"',
'data-file-action="new-square"',
'data-file-action="new-portrait"',
'data-file-action="new-story"',
'data-file-action="new-custom"',
'data-edit-action="undo"',
'data-edit-action="redo"',
'data-edit-action="history"',
@@ -54,10 +60,22 @@ def test_extra_menus_wire_file_edit_help():
"bind('ge-edit-menu-btn', 'ge-edit-menu'",
"bind('ge-help-menu-btn', 'ge-help-menu'",
"uiModule",
"newDocument?.(1920, 1080)",
"promptNewDocument?.()",
]:
assert token in src
def test_new_document_is_wired_to_the_editor():
src = read("static/js/galleryEditor.js")
wire = src[src.index("wireExtraMenus({"):]
wire = wire[:wire.index("});")]
assert "newDocument: (w, h) => {" in wire
assert "openEditor(null, null, { w: Math.round(w), h: Math.round(h) }, 'Untitled');" in wire
assert "promptNewDocument: () => _promptCanvasSize({" in wire
assert "showPresets: true," in wire
def test_topbar_menu_ids_include_file_edit_help():
src = read("static/js/editor/wire-topbar.js")
for token in [
@@ -0,0 +1,48 @@
"""Mobile (<=700px) editor chrome: footer removal, Quick Edit stacking, more-tools chevron.
Requested (390x844 phone):
1. Remove the floating zoom/Setup footer on phones — it collided with the peek
layers sheet and the Quick Edit card. Undo/Redo/History already live in the
layers-sheet header and pinch-zoom replaces the zoom buttons.
2. The Quick Edit card lifts above the bottom sheets / keyboard; keep it above
every other editor asset while lifted (z-index 90).
3. The tool-rail "more tools" affordance should be a right chevron, not an
ellipsis.
"""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
MOBILE_BLOCK = """@media (max-width: 700px) {
.ge-editor-footer {
display: none !important;
}
/* The Quick Edit card lifts over the layers sheet / keyboard; keep it above
every other editor asset (sheet headers, tool rail, popups) while lifted. */
.ge-ai-command {
z-index: 90;
}
}"""
def test_mobile_footer_is_removed():
assert MOBILE_BLOCK in CSS
assert ".ge-editor-footer {\n display: none !important;" in CSS
def test_quick_edit_stacks_above_other_assets():
assert ".ge-ai-command {\n z-index: 90;" in CSS
def test_more_tools_button_is_a_chevron():
assert "toolbarMore.textContent = '\\u203A';" in EDITOR
assert "toolbarMore.textContent = '\\u2026';" not in EDITOR
def test_sw_cache_name_bumped():
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
+1 -1
View File
@@ -15,7 +15,7 @@ def test_menu_row_and_toolbar_contain_sideways_scroll():
# Asserted against the whole stylesheet: several `@media (max-width: 700px)`
# blocks now exist, so "the last one" is not the one that holds these rules.
assert "overscroll-behavior-x: contain;" in CSS
assert ".ge-toolbar," in CSS
assert ".ge-toolbar-scroll," in CSS
assert ".ge-dock-tabs {" in CSS
+10
View File
@@ -30,6 +30,16 @@ def test_mobile_quick_edit_chip_hides_columns():
assert ".ge-ai-command-suggestion-main {" in CSS
def test_quick_edit_collapsed_bar_stays_translucent_over_the_canvas():
# Hovering anywhere over the canvas must not drag the collapsed Quick Edit
# bar fully in front of the image (annoying when zoomed in); only pointing
# at the bar itself, or focusing it, brings it to full strength.
assert ".ge-canvas-area:hover .ge-ai-command-collapsed {" in CSS
assert "opacity: 0.55;" in CSS
assert ".ge-canvas-area .ge-ai-command-collapsed:hover,\n.ge-canvas-area .ge-ai-command-collapsed:focus-within {" in CSS
assert "opacity: 0.94;" in CSS
def test_sw_version_is_current():
m = re.search(r"CACHE_NAME = '(odysseus-v\d+-pr40-editor-precache)'", SW)
assert m, "CACHE_NAME not found"
+1 -1
View File
@@ -10,7 +10,7 @@ SW = (ROOT / "static" / "sw.js").read_text()
def test_toolbar_is_wrapped_for_an_overlay_chevron():
assert "toolbarWrap.className = 'ge-toolbar-wrap';" in JS
assert "toolbarMore.className = 'ge-tool-more';" in JS
assert "toolbarMore.textContent = '\\u2026';" in JS
assert "toolbarMore.textContent = '\\u203A';" in JS
assert "toolbarWrap.appendChild(toolbar);" in JS
assert "toolbarWrap.appendChild(toolbarMore);" in JS
assert "toolbar.scrollBy(" in JS
+32
View File
@@ -0,0 +1,32 @@
import pathlib
ROOT = pathlib.Path(__file__).resolve().parents[1]
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
GALLERY = (ROOT / "static/js/gallery.js").read_text(encoding="utf-8")
def test_new_edit_tab_asks_for_the_canvas_size_like_the_main_menu():
block = EDITOR[EDITOR.index("plus.className = 'gallery-tab gallery-tab-new';"):]
block = block[:block.index("extra.appendChild(plus);")]
assert "plus.title = 'New edit tab';" in block
assert "plus.setAttribute('aria-label', 'New edit tab');" in block
# Same dialog as File > New Image instead of a silent 1024x1024 document.
assert "await _promptCanvasSize({" in block
assert "title: 'New edit'," in block
assert "okLabel: 'Create'," in block
assert "showPresets: true," in block
assert "window.geEditorControl?.invoke('document.create', { width: size.w, height: size.h, name: 'Untitled' });" in block
assert "document.create', {})" not in EDITOR
def test_closing_the_new_tab_keeps_the_other_documents_alive():
assert "window.geEditorCloseActiveDocument = () => {" in EDITOR
assert "if (_openDocuments.length <= 1) return false;" in EDITOR
# The switch must not re-add the closed id or copy its pixels over the
# surviving document.
assert "let _suppressDocSync = false;" in EDITOR
assert "if (_suppressDocSync || !state.editorOpen) return null;" in EDITOR
assert "_suppressDocSync = true;" in EDITOR
assert ".finally(() => { _suppressDocSync = false; });" in EDITOR
# The primary Edit-tab close tries the single-document close first.
assert "if (window.geEditorCloseActiveDocument?.()) return;" in GALLERY
-2
View File
@@ -21,8 +21,6 @@ def test_options_bar_module_shape():
"#ge-clone-opacity",
"#ge-lasso-feather",
"#ge-wand-feather",
"#ge-gradient-type",
"#ge-gradient-opacity",
):
assert selector in src, selector
# Selection modes reuse the existing wand-mode buttons rather than re-implementing.
+128
View File
@@ -0,0 +1,128 @@
"""Overnight editor-polish batch contracts.
Covers the UI/UX fixes applied in one pass: Brightness/Contrast presets,
clearer selection-mode icons, marquee action-row icons, the crop-apply
dismiss button, unified dock/layer icon sizing, the footer zoom glyph
nudge, the histogram repaint hook, the bucket dodge/burn fixes.
"""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
CSS = _read("static/css/13-image-editor.css")
LH = _read("static/js/editor/layer-helpers.js")
OPTIONS = _read("static/js/editor/build/options-bar.js")
CONTROLS = _read("static/js/editor/build/controls.js")
EDITOR = _read("static/js/galleryEditor.js")
PANELS = _read("static/js/editor/panels/panels.js")
STROKE = _read("static/js/editor/stroke-pipeline.js")
BUCKET = _read("static/js/editor/tools/bucket.js")
SW = _read("static/sw.js")
def test_brightness_contrast_has_extra_presets():
m = re.search(r"'brightness-contrast': \[([^\]]*)\]", LH)
assert m, "brightness-contrast preset list missing"
opts = m.group(1)
for name in ("Brighten", "Darken", "Punch", "High Contrast", "Low Contrast", "Faded"):
assert name in opts, name
for case in (
"brightness-contrast:Brighten",
"brightness-contrast:Darken",
"brightness-contrast:Punch",
"brightness-contrast:Low Contrast",
"brightness-contrast:Faded",
):
assert f"case '{case}':" in LH, case
def test_intersect_mode_uses_overlap_icon():
assert "const INTERSECT_ICON =" in OPTIONS
assert "['intersect', INTERSECT_ICON]" in OPTIONS
icon = '<circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/>'
assert icon in OPTIONS
# every wand/selection intersect button in the controls markup uses it
assert CONTROLS.count(icon) >= 6
assert 'data-wand-mode="intersect"' in CONTROLS
def test_marquee_action_row_has_svg_icons():
assert 'class="ge-btn ge-btn-sm" id="ge-marquee-clear"' in CONTROLS
assert 'aria-label="Clear selection"' in CONTROLS
for gid in ("ge-marquee-invert", "ge-marquee-delete", "ge-marquee-copy", "ge-marquee-mask"):
assert f'id="{gid}"' in CONTROLS, gid
def test_crop_apply_has_corner_dismiss():
assert 'class="ge-crop-close"' in EDITOR
# The × clears the committed crop via the tool reset (cancel() is a no-op
# once the gesture has ended).
assert "_cropTool.reset()" in EDITOR
assert ".ge-crop-close {" in CSS
assert "border-radius: 50%;" in CSS[CSS.index(".ge-crop-close {"):CSS.index(".ge-crop-close {") + 320]
def test_crop_dismiss_x_is_nudged_up():
# × glyph up 4px inside the round button; the button itself up 5px.
assert 'class="ge-crop-close-x"' in EDITOR
assert ".ge-crop-close-x { display: block; line-height: 1; transform: translateY(-2px); }" in CSS
idx = CSS.index(".ge-crop-close {")
assert "top: -13px;" in CSS[idx:idx + 120]
def test_gradient_swatch_applies_the_gradient():
# Clicking a preset selects the gradient tool and paints it across the layer.
assert "document.querySelector('[data-tool=\"gradient\"]')?.click();" in PANELS
assert "applyGradient?.();" in PANELS
assert "applyGradient: () => _applyFullGradient()," in EDITOR
assert "function _applyFullGradient()" in EDITOR
assert "_gradientTool.begin(from)" in EDITOR
def test_dock_and_layer_icon_sizes_unified():
idx = CSS.index(".ge-layer-btn svg,")
block = CSS[idx:idx + 360]
assert ".ge-layer-group-toggle svg {" in block
assert "width: 14px;" in block
assert "height: 14px;" in block
def test_adj_row_label_is_right_aligned():
idx = CSS.index(".ge-adj-row > label {")
block = CSS[idx:idx + 160]
assert "text-align: right;" in block
def test_footer_zoom_glyph_nudged_down():
assert ".ge-footer-zoom .ge-zoom-glyph { left: 0; top: 4px; }" in CSS
def test_histogram_picks_a_paintable_layer():
assert "const histogramLayer = ()" in PANELS
assert "drawHistogram(histCanvas, layer)" in PANELS
def test_dodge_and_burn_cap_per_dab_strength():
# Dodge/burn strength is now driven by the Intensity slider (default 25%,
# scaled to 0.5 so even 100% builds gradually) instead of a fixed clamp.
assert STROKE.count("const intensity = Number.isFinite(state.dodgeBurnIntensity)") >= 2
assert STROKE.count("alpha = Math.max(0.004, Math.min(0.6, alpha * intensity * 0.5));") >= 2
assert "operation = 'screen';" in STROKE
assert "operation = 'multiply';" in STROKE
def test_bucket_falls_back_to_a_paintable_layer():
assert "function paintableLayer()" in BUCKET
assert "'Select a pixel layer to fill'" in BUCKET
def test_sw_cache_name_matches():
assert re.search(r"CACHE_NAME = 'odysseus-v\d+-pr40-editor-precache'", SW)
+6 -1
View File
@@ -67,6 +67,11 @@ def test_import_and_save_live_in_the_topbar_row():
assert "ge-win-btn ge-win-close ge-doc-tab-close" not in GALLERY
assert "ge-editor-minimize" not in GALLERY
assert "ge-header-max-btn svg { top: 0 !important; transform: none; }" in STYLE
# The adopted header button must not stack up when the editor is rebuilt.
assert "header.querySelectorAll('.ge-header-max-btn').forEach((stale) => stale.remove());" in GALLERY
assert "document.querySelectorAll('.ge-header-max-btn').forEach((btn) => btn.remove());" in GALLERY
# ...and the maximize glyph is centred by CSS, not an inline nudge.
assert 'stroke-linejoin="round"><polyline points="15 3 21 3 21 9"' in TOPBAR
def test_save_menu_has_a_png_option():
@@ -96,7 +101,7 @@ def test_multi_document_tabs_render_in_the_gallery_tab_bar():
assert "window.geEditorDocTabs = _renderEditorDocTabs" in GALLERY
assert "const _renderEditorDocTabs = () => {" in GALLERY
assert "window.geEditorControl?.invoke('document.switch', { id: doc.id })" in GALLERY
assert "window.geEditorControl?.invoke('document.create', {});" in GALLERY
assert "window.geEditorControl?.invoke('document.create', { width: size.w, height: size.h, name: 'Untitled' });" in GALLERY
assert "gallery-tab gallery-tab-new" in GALLERY
assert ".gallery-editor-tabs-extra { display: contents; }" in STYLE
assert "window.geEditorDocTabs?.();" in GALLERY
+60
View File
@@ -0,0 +1,60 @@
"""Pixel is its own tool, not a brush preset ("brush type").
The 1px hard-square brush used to be exposed as the built-in `pixel-1px`
preset, listed under the flyout's "Brushes" heading. It is now a real tool
(`pixel`) in the brush slot's flyout, next to Brush and Eraser, and the pixel
preset is hidden from the preset list/dropdown.
"""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
TOOLBAR = _read("static/js/editor/build/toolbar.js")
STROKE = _read("static/js/editor/tools/stroke.js")
PIPELINE = _read("static/js/editor/stroke-pipeline.js")
EDITOR = _read("static/js/galleryEditor.js")
SLIDERS = _read("static/js/editor/stroke-tool-sliders.js")
SW = _read("static/sw.js")
def test_pixel_is_a_tool_with_an_icon():
assert "pixel: { label: 'Pixel'," in TOOLBAR
# it sits next to brush + eraser in the flyout slot
assert "['brush', 'eraser', 'pixel']" in TOOLBAR
def test_pixel_is_not_listed_as_a_brush_preset():
# the flyout's Brushes list skips pixel presets...
assert "loadBrushPresets().filter((preset) => !preset.pixel)" in TOOLBAR
# ...and so does the options-strip preset dropdown.
assert "const list = presets.filter(preset => !preset.pixel);" in SLIDERS
assert "renderPresets(presets.some(item => item.id === savedPresetId && !item.pixel) ? savedPresetId : '');" in SLIDERS
def test_pixel_stroke_is_handled_by_the_shared_pipeline():
assert "'pixel'" in STROKE.split("const STROKE_TOOLS")[1].split("];")[0]
assert "if (tool === 'pixel') return 'Pixel stroke';" in STROKE
assert "'pixel'" in PIPELINE.split("const PAINT_TOOLS")[1].split("];")[0]
assert "if ((tool === 'pixel' || (state.brushPixel && (tool === 'brush' || tool === 'eraser'))) && !target.paintingMask) {" in PIPELINE
def test_editor_registers_the_pixel_tool_everywhere():
assert "'brush', 'pixel', 'eraser'" in EDITOR # _pixelPaintTools
assert "const needsBrush = ['brush', 'pixel', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn'].includes(toolId);" in EDITOR
assert "const strokeTool = (toolId === 'brush' || toolId === 'pixel' || toolId === 'dodge' || toolId === 'burn') ? 'brush'" in EDITOR
assert "'brush', 'pixel', 'gradient', 'bucket'" in EDITOR # hasToolControls
assert "'brush', 'pixel', 'eraser', 'inpaint', 'lasso'" in EDITOR # useCircle
# selecting the Pixel tool forces the pixel flag; brush/eraser drop it
assert "if (toolId === 'pixel') state.brushPixel = true;" in EDITOR
assert "else if (toolId === 'brush' || toolId === 'eraser') state.brushPixel = false;" in EDITOR
def test_sw_cache_name_bumped():
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
+171
View File
@@ -0,0 +1,171 @@
"""Planner routing regression suite (HARNESS-owned).
Turns PHOTOSHOP's bench prompt families (vague, restyle, bg-remove/scene,
tone/auto, generative, structural) into assertions on the ROUTE CLASS the
server planner picks, so a future planner change cannot silently re-break
routing. The classes are:
ask_user -> the planner is unsure; it must ask with 2-3 choices and
apply NO operations (never a silent tone default)
deterministic -> a deterministic editor op / recipe (adjustments, filters,
crop, text, rotate, fill, recolour ...)
ai_edit_image -> a generative image-model edit (content add/remove/replace,
restyle, look)
generate_image -> text-to-image for a brand-new image
These use the pure routing helpers (``_plan_special`` / ``_generative_kind`` /
``_fallback_plan``), so no model or network call is made.
"""
import json
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes import editor_agent_routes as eaar
_STATE = {"width": 900, "height": 600}
_PROVIDER = True
@pytest.fixture(autouse=True)
def _reset_planner():
eaar.set_editor_planner(None)
yield
eaar.set_editor_planner(None)
@pytest.fixture()
def client():
app = FastAPI()
app.include_router(eaar.setup_editor_agent_routes())
return TestClient(app)
def _classify(prompt, provider=_PROVIDER):
"""Return (route_class, ops) for a prompt using the deterministic helpers."""
special = eaar._plan_special(prompt, _STATE, provider)
if special:
steps = list(special[0])
else:
fallback = eaar._fallback_plan(prompt)
if fallback.get("ask_user"):
return "ask_user", []
steps = list(fallback.get("steps") or [])
ops = [str(s.get("op") or "") for s in steps]
if "generate_image" in ops:
return "generate_image", ops
if "ai_edit_image" in ops:
return "ai_edit_image", ops
return "deterministic", ops
# (prompt, expected route class)
BENCH_ROUTING = [
# vague -> ask (never a silent tone tweak)
("do the thing", "ask_user"),
("give it some oomph", "ask_user"),
("make it better", "ask_user"),
# whole-image restyle -> model edit
("make it look like an oil painting", "ai_edit_image"),
("turn it into an anime", "ai_edit_image"),
("make it a comic book illustration", "ai_edit_image"),
("make it look like cyberpunk", "ai_edit_image"),
# background / scene replacement -> model edit
("put her on a beach", "ai_edit_image"),
("make the background nicer", "ai_edit_image"),
("make it snowy", "ai_edit_image"),
# generative content add / remove -> model edit
("add a full moon in the sky", "ai_edit_image"),
("remove the distractions", "ai_edit_image"),
("remove the person on the left", "ai_edit_image"),
# brand-new image -> text-to-image
("create a new image of a full moon", "generate_image"),
("generate an image of a sunset", "generate_image"),
("make a logo", "generate_image"),
# tone / auto / look / structural -> deterministic
("make it black and white", "deterministic"),
("make it pop", "deterministic"),
("make it look professional", "deterministic"),
("make only the red car blue", "deterministic"),
("make a 1080x1350 Instagram post from this", "deterministic"),
("add a title TOKYO with a drop shadow", "deterministic"),
("extend the canvas to 16:9", "deterministic"),
("save as PSD with layers", "deterministic"),
("fill the white area with red", "deterministic"),
("rotate the whole image 90 degrees", "deterministic"),
]
@pytest.mark.parametrize("prompt,expected", BENCH_ROUTING)
def test_bench_prompts_route_to_expected_class(prompt, expected):
route_class, ops = _classify(prompt)
assert route_class == expected, f"{prompt!r} -> {route_class} {ops} (want {expected})"
def test_ask_plans_offer_two_to_three_choices():
for prompt in ("do the thing", "give it some oomph", "make it better"):
plan = eaar._fallback_plan(prompt)
ask = plan.get("ask_user")
assert isinstance(ask, dict), prompt
opts = ask.get("options") or []
assert 2 <= len(opts) <= 3, (prompt, opts)
assert plan.get("steps") == []
def test_object_removal_uses_selection_plus_model_edit():
_cls, ops = _classify("remove the person on the left")
assert "ai_edit_image" in ops
assert any(op.startswith("selection.") for op in ops), ops
def test_recolour_never_uses_a_flat_fill_or_a_global_tint():
# Every recolour selects the object by description and either shifts the
# masked hue or asks the image model for the masked region — never a flat
# selection.fill and never a whole-image adjustments.apply.
for prompt in ("make only the red car blue", "make the red car white", "make the flowers bright pink"):
_cls, ops = _classify(prompt)
assert "selection.samText" in ops, (prompt, ops)
assert "selection.fill" not in ops, (prompt, ops)
assert "adjustments.apply" not in ops, (prompt, ops)
def test_chromatic_recolour_is_a_deterministic_masked_hue_shift():
cls, ops = _classify("make only the red car blue")
assert cls == "deterministic", ops
assert ops == ["selection.samText", "adjustments.applyMasked"], ops
def test_new_image_request_from_scratch_vs_edit_of_current_photo():
# A photo edit ("make it a <style>") must NOT be treated as a new image.
assert eaar._generative_kind("make it a comic book illustration") == "restyle"
assert eaar._is_new_image_request("make it a comic book illustration") is False
# A from-scratch generation still is.
assert eaar._is_new_image_request("create an illustration of a cat") is True
assert eaar._generative_kind("create an illustration of a cat") == "add"
def test_plan_route_returns_deterministic_recolour(client):
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
r = client.post("/api/editor/plan", json={"prompt": "make only the red car blue", "state": _STATE})
assert r.status_code == 200
data = r.json()
assert data["planner"] == "deterministic"
assert [s["op"] for s in data["steps"]] == ["selection.samText", "adjustments.applyMasked"]
def test_plan_route_asks_for_a_vague_prompt(client):
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
r = client.post("/api/editor/plan", json={"prompt": "do the thing", "state": _STATE})
assert r.status_code == 200
data = r.json()
assert data.get("ask_user"), data
assert data.get("steps") == []
def test_plan_route_routes_restyle_to_model_edit(client):
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
r = client.post("/api/editor/plan", json={"prompt": "turn it into an anime", "state": _STATE})
assert r.status_code == 200
ops = [s["op"] for s in r.json()["steps"]]
assert "ai_edit_image" in ops, ops
+227
View File
@@ -0,0 +1,227 @@
"""Second editor-polish batch: slider chip editor alignment, action-row nudge,
wand caret styling, crop-close reset, hex copy + favorites."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
SLIDER = (ROOT / "static/js/editor/slider-ux.js").read_text(encoding="utf-8")
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
PANELS = (ROOT / "static/js/editor/panels/panels.js").read_text(encoding="utf-8")
CONTROLS = (ROOT / "static/js/editor/build/controls.js").read_text(encoding="utf-8")
def test_slider_edit_input_is_chip_sized():
assert ".ge-eraser-row input.ge-slider-edit," in CSS
assert ".ge-adj-row input.ge-slider-edit {" in CSS
i = CSS.index(".ge-eraser-row input.ge-slider-edit,")
block = CSS[i:i + 220]
assert "height: 16px;" in block
assert "box-sizing: border-box;" in block
def test_slider_edit_input_is_vertically_centred():
assert "inp.style.top = (crect.top - rrect.top + (crect.height - inp.offsetHeight) / 2) + 'px';" in SLIDER
assert "crect.top - rrect.top - 1" not in SLIDER
def test_action_row_buttons_are_nudged_up():
assert ".ge-controls--in-options .ge-control-row.ge-actions .ge-btn {" in CSS
i = CSS.index(".ge-controls--in-options .ge-control-row.ge-actions .ge-btn {")
block = CSS[i:i + 120]
assert "position: relative;" in block
assert "top: -2px;" in block
def test_wand_more_button_has_no_box():
i = CSS.index(".ge-wand-more-btn {")
block = CSS[i:i + 200]
assert "background: transparent;" in block
assert "border-color: transparent;" in block
def test_wand_caret_and_value_are_nudged_left():
i = CSS.index(".ge-wand-more-btn {")
assert "left: -8px;" in CSS[i:i + 120]
assert "top: -4px;" in CSS[i:i + 120]
assert "#ge-wand-section .ge-slider-value { position: relative; left: -6px; }" in CSS
def test_crop_close_x_is_nudged_up():
assert ".ge-crop-close-x { display: block; line-height: 1; transform: translateY(-2px); }" in CSS
def test_crop_close_clears_the_selection():
assert "pop.querySelector('.ge-crop-close').addEventListener('click', () => _cropTool.reset());" in EDITOR
assert "_cancelCrop('dismiss')" not in EDITOR
def test_crop_close_button_and_presets_are_nudged_up():
idx = CSS.index(".ge-crop-close {")
assert "top: -13px;" in CSS[idx:idx + 120]
assert ".ge-crop-preset-row { display: flex; gap: 4px; position: relative; top: -4px; }" in CSS
def test_escape_removes_the_crop_like_the_x():
# Escape routes through _cancelCrop, which now fully resets (not cancel()).
assert "_cancelCrop('escape')" in EDITOR
i = EDITOR.index("function _cancelCrop(reason = 'cancel') {")
block = EDITOR[i:i + 520]
assert "_cropTool.reset();" in block
def test_hex_row_has_copy_and_favorite_buttons():
assert "el('button', 'ge-hex-copy')" in PANELS
assert "el('button', 'ge-hex-copy ge-hex-fav')" in PANELS
assert "hexRow.append(hexHash, hexInput, hexCopy, hexFav);" in PANELS
assert "navigator.clipboard?.writeText" in PANELS
assert ".ge-hex-copy {" in CSS
assert ".ge-hex-fav.active {" in CSS
def test_color_favorites_are_persisted_and_rendered():
assert "const FAV_KEY = 'ge-color-favorites';" in PANELS
assert "const getFavoriteColors = () =>" in PANELS
assert "const isFavorite = (hex) =>" in PANELS
assert "favRow.innerHTML = favs.length" in PANELS
assert "ge-swatch-fav" in PANELS
def test_ctl_range_thumb_is_nudged_up_one_px():
i = CSS.index(".ge-ctl-range::-webkit-slider-thumb {")
block = CSS[i:i + 200]
assert "margin-top: -5px;" in block
def test_wand_tolerance_value_chip_is_always_tinted():
assert "#ge-wand-section #ge-wand-tol-label.ge-slider-value {" in CSS
i = CSS.index("#ge-wand-section #ge-wand-tol-label.ge-slider-value {")
block = CSS[i:i + 160]
assert "background: color-mix(in srgb, var(--fg) 10%, transparent);" in block
assert "opacity: 1;" in block
def test_selection_action_row_is_pinned_to_the_right_edge():
assert ".ge-options-bar .ge-controls.ge-controls--in-options {" in CSS
i = CSS.index(".ge-options-bar .ge-controls.ge-controls--in-options {")
assert "flex: 1 1 auto;" in CSS[i:i + 260]
assert ".ge-controls--in-options .ge-control-row.ge-actions { position: relative; top: -2px; margin-left: auto; }" in CSS
j = CSS.index(".ge-controls--in-options > div:not([style*=\"display:none\"])")
assert "flex: 1 1 auto;" in CSS[j:j + 500]
def test_crop_can_expand_beyond_the_canvas():
# normalizeCropRect must not clamp back inside the image anymore.
GEO = (ROOT / "static/js/editor/document-geometry.js").read_text(encoding="utf-8")
i = GEO.index("function normalizeCropRect(")
block = GEO[i:i + 900]
assert "editorState.imgWidth - 1" not in block
assert "Math.max(-MAX, Math.min(MAX, Math.round(rect.x)))" in block
assert "const MAX = 20000;" in block
def test_crop_tool_allows_dragging_past_the_edge_and_syncs_a_frame():
CROP = (ROOT / "static/js/editor/tools/crop.js").read_text(encoding="utf-8")
assert "createCropTool({ composite, showCropApply, snapFrame, syncCropFrame })" in CROP
assert "x: Math.max(-MAX, Math.min(MAX, end.x))," in CROP
assert "syncCropFrame?.();" in CROP
def test_crop_frame_outline_is_wired_and_styled():
assert "function _syncCropFrame()" in EDITOR
assert "el.className = 'ge-crop-frame';" in EDITOR
assert "syncCropFrame: () => _syncCropFrame()," in EDITOR
assert ".ge-crop-frame {" in CSS
i = CSS.index(".ge-crop-frame {")
block = CSS[i:i + 260]
assert "position: absolute;" in block
assert "pointer-events: none;" in block
def test_crop_can_start_outside_the_canvas_without_panning():
# The crop tool must be able to begin a rect in the empty area around the
# 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
# 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
def test_crop_apply_has_a_standard_size_dropdown():
# The crop Apply box (W × H pixel inputs) carries a dropdown of standard
# crop sizes that set the rect to a fixed pixel size (or the original).
assert 'class="ge-crop-size-preset"' in EDITOR
i = EDITOR.index('class="ge-crop-size-preset"')
block = EDITOR[i:i + 900]
for v in ('custom', '1080x1080', '1080x1350', '1080x1920', '1200x630',
'1280x720', '1920x1080', '2048x1536', '3000x2400', '4000x3000', 'original'):
assert f'value="{v}"' in block
assert '<option value="custom">Custom</option>' in block
assert '<option value="original">Original</option>' in block
# change handler applies the chosen pixel size / original image size
assert "sizeSel?.addEventListener('change'" in EDITOR
assert "if (v === 'original') { w = state.imgWidth; h = state.imgHeight; }" in EDITOR
assert "const m = /^(\\d+)x(\\d+)$/.exec(v);" in EDITOR
# manual W/H edits reset the dropdown to Custom
assert "if (sizeSel) sizeSel.value = 'custom';" in EDITOR
# CSS styles the select like the number inputs and leaves a gap to its right
assert ".ge-crop-apply select.ge-crop-size-preset {" in CSS
ci = CSS.index(".ge-crop-apply select.ge-crop-size-preset {")
assert "margin-right: 8px;" in CSS[ci:ci + 260]
def test_clear_x_is_nudged_down_and_lit_when_a_selection_is_active():
# The Clear ✕ in the marquee/wand action rows: the glyph sits 1px lower,
# and the button turns accent while a selection is active.
i = CSS.index("#ge-marquee-clear svg,")
block = CSS[i:i + 900]
assert "#ge-wand-clear svg," in block
assert "#ge-objectselect-clear svg { position: relative; top: 1px; }" in block
assert "#ge-marquee-clear.has-selection," in block
assert "#ge-objectselect-clear.has-selection { color: var(--accent, var(--red));" in block
assert "querySelectorAll('#ge-marquee-clear, #ge-wand-clear, #ge-lasso-clear, #ge-sam-clear, #ge-quickselect-clear, #ge-objectselect-clear').forEach((btn) => {" in EDITOR
assert "btn.classList.toggle('has-selection', !!state.wandMask);" in EDITOR
def test_transform_cancel_and_apply_are_right_aligned():
# The Transform popup's Cancel + Apply share the right edge of the body.
i = CSS.index(".ge-transform-popup-body #ge-transform-cancel-btn {")
assert "margin-left: auto;" in CSS[i:i + 120]
def test_clone_source_clear_matches_the_wand_clear_button():
# The Clone "clear sampled source" ✕ now uses the same SVG cross and
# button classes as the wand/marquee Clear ✕, and lights up (accent)
# while a source is sampled.
assert 'class="ge-btn ge-btn-sm" id="ge-clone-source-clear"' in CONTROLS
assert '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>' in CONTROLS
assert "ge-icon-btn\" id=\"ge-clone-source-clear\"" not in CONTROLS
assert "#ge-clone-source-clear svg { position: relative; top: -1px; }" in CSS
assert "#ge-clone-source-clear.has-selection { color: var(--accent, var(--red));" in CSS
assert "clear.classList.toggle('has-selection', hasSource);" in EDITOR
def test_clone_sample_mode_lives_in_the_clone_section():
# The Sample select was moved out of the brush "More" menu (the custom
# stroke group) into the dedicated Clone section, sharing the standard
# control-select style.
assert 'id="ge-clone-sample-mode" class="ge-ctl-select"' in CONTROLS
assert '<select id="ge-clone-sample-mode">' not in CONTROLS
section_at = CONTROLS.index('id="ge-clone-section"')
select_at = CONTROLS.index('id="ge-clone-sample-mode"')
assert select_at > section_at
def test_ai_canvas_ascii_wave_uses_the_theme_accent():
# The flowing ASCII "wave" skeleton painted over the canvas while an image
# is generated used a hardcoded green; it must follow the theme accent.
assert ".ge-canvas-area .ge-ai-ascii-fireflies {" in CSS
block = CSS[CSS.index(".ge-canvas-area .ge-ai-ascii-fireflies {"):]
block = block[:block.index("}")]
assert "color: var(--accent, var(--red));" in block
assert "#b8e6c1" not in block
assert "rgba(184, 230, 193, 0.20)" not in CSS
assert "color-mix(in srgb, var(--accent, var(--red)) 20%, transparent)" in CSS
+33 -4
View File
@@ -7,20 +7,27 @@ ROUTES = (ROOT / "routes" / "gallery" / "gallery_routes.py").read_text()
SW = (ROOT / "static" / "sw.js").read_text()
def test_client_image_mode_generates():
assert "if (mode === 'image')" in JS
def test_client_gen_mode_generates():
assert "if (mode === 'gen')" in JS
assert "_runQuickControlOp('ai.generateImage'" in JS
assert "Generating an image…" in JS
assert "Generated an image." in JS
assert "Could not generate an image:" in JS
def test_client_image_mode_runs_after_harmonize():
def test_client_gen_mode_runs_after_harmonize():
harm = JS.index("_isHarmonizeIntent(prompt))")
img = JS.index("if (mode === 'image')")
img = JS.index("if (mode === 'gen')")
assert harm < img
def test_editing_status_is_distinct_from_generating_status():
# Image mode makes a brand-new picture; every other mode edits the canvas.
assert "_setAiCommandStatus('Editing the image…', 'running');" in JS
assert "_setAiCommandStatus('Generating an image…', 'running');" in JS
assert "_setAiCommandStatus('Generating…', 'running');" not in JS
def test_server_generate_route_exists():
assert '@router.post("/api/image/generate")' in ROUTES
assert "do_generate_image" in ROUTES
@@ -30,3 +37,25 @@ def test_server_generate_route_exists():
def test_sw_cache_bumped():
m = re.search(r"CACHE_NAME = '(odysseus-v\d+-pr40-editor-precache)'", SW)
assert m and re.fullmatch(r"odysseus-v\d+-pr40-editor-precache", m.group(1))
def test_generate_route_forwards_the_picked_model():
"""The model picker's choice must reach do_generate_image.
Settings defaults may otherwise point at the local Qwen server and the
user's chosen OpenRouter model is never used.
"""
route = ROUTES.split('@router.post("/api/image/generate")', 1)[1][:2600]
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
def test_cloud_image_model_is_resolved_before_the_local_fallback():
"""A gateway model must not be hijacked onto the local image endpoint."""
ai = (ROOT / "src" / "ai_interaction.py").read_text()
block = ai.split("async def _resolve_image_model(model_name: str):", 1)[1][:900]
plain = block.index("_resolve_model(model_name, owner=owner)")
typed = block.index('model_type="image"')
assert plain < typed
+65 -7
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("const _bgRemove = ", start)
end = EDITOR.index("if (_looksBackgroundRemoval(p)) {", start)
transform_block = EDITOR[start:end]
assert "_saveState('Rotate" not in transform_block
assert "_saveState('Flip" not in transform_block
@@ -163,7 +163,7 @@ def test_brush_preset_picker_is_visible_in_the_options_bar():
def test_pixel_brush_shows_a_square_cursor():
# The pixel brush draws hard squares, so the cursor preview is a square
# snapped to whole pixels rather than a feathered circle.
assert "const pixelBrush = !!state.brushPixel && (state.tool === 'brush' || state.tool === 'eraser');" in EDITOR
assert "const pixelBrush = state.tool === 'pixel' || (!!state.brushPixel && (state.tool === 'brush' || state.tool === 'eraser'));" in EDITOR
assert "state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);" in EDITOR
assert ".ge-brush-cursor.ge-brush-cursor--pixel" in STYLE
assert "border-radius: 0;" in STYLE
@@ -207,9 +207,13 @@ def test_quick_edit_is_local_first_and_only_uses_ai_when_needed():
assert "const planned = await _runLocalPlan(prompt);" in wrapper
assert "if (tryLocal && !_looksGenerative(prompt)) {" in wrapper
assert wrapper.index("_runLocalPlan(prompt)") < wrapper.index("agent.run(_enrichLookPrompt(prompt)")
# Routing mode selector: auto / quick / agent / image, with a helper.
# Routing mode selector: auto / gen / edit, with a helper.
assert "function _getQuickEditMode() {" in EDITOR
assert "id=\"ge-ai-command-mode\"" in EDITOR
assert "<option value=\"auto\">Auto</option>" in EDITOR
assert "<option value=\"gen\">Img Gen</option>" in EDITOR
assert "<option value=\"edit\">Img Edit</option>" in EDITOR
assert EDITOR.index('<option value="auto">Auto</option>') < EDITOR.index('<option value="gen">Img Gen</option>')
assert "const mode = _getQuickEditMode();" in wrapper
# Auto/Image/Tab fall through to the IMAGE MODEL, not a filter planner.
assert "const _runImageModelEdit = async (prompt, { signal = null } = {}) => {" in EDITOR
@@ -232,7 +236,11 @@ def test_quick_edit_is_local_first_and_only_uses_ai_when_needed():
assert "'Nothing to trim — the image already fills the canvas.'" in wrapper
assert "invoke('document.crop', { x: b.x, y: b.y, width: b.w, height: b.h })" in wrapper
# Deterministic ops are skipped in agent/image/Tab mode.
assert "const tryLocal = !forceAi && !forceAgent && mode !== 'image';" in wrapper
# Deterministic ops run in Auto only; Gen/Edit/Tab skip them.
assert "const tryLocal = !forceAi && mode === 'auto';" in wrapper
# Gen = text-to-image on a new layer; Edit = the image edit model only.
assert "if (mode === 'gen') {" in wrapper
assert "if (mode === 'edit' && !forceAi) {" in wrapper
assert "if (tryLocal && /\\b(?:crop|trim)\\b/i.test(cropText) && !hasRatio && !/\\bselection\\b|\\bselected\\b/i.test(cropText)) {" in wrapper
@@ -306,7 +314,7 @@ def test_ai_work_glows_the_canvas():
assert "height: Math.max(1, area.clientHeight)," in EDITOR
assert "area.style.setProperty('--ge-ai-busy-w'" in EDITOR
assert ".ge-canvas-area.ge-canvas-ai-busy::after" in STYLE
assert "background: rgba(6, 14, 9, 0.38);" in STYLE
assert "background: color-mix(in srgb, var(--bg, #111) 62%, transparent);" in STYLE
assert "left: var(--ge-ai-busy-x, 0px);" in STYLE
assert "width: var(--ge-ai-busy-w, 100%);" in STYLE
assert "@keyframes ge-ai-canvas-sheen" in STYLE
@@ -490,9 +498,19 @@ def test_remove_background_intent_matches_common_phrasings():
# The bg-removal fast path must catch "removal" (not just "remove"),
# "bg remov", "remove bg", "no background", "cutout", "isolate the
# subject" etc. — otherwise these fall through to the AI agent.
assert "const _bgRemove = " in EDITOR
assert "const _looksBackgroundRemoval = " in EDITOR
assert "remov\\w*|erase|delete|drop|get\\s*rid\\s*of|cut\\s*out|cutout|knock\\s*out|isolate|strip|clear|transparent|no|without|rm" in EDITOR
assert "if (_bgRemove) {" in EDITOR
assert "if (_looksBackgroundRemoval(p)) {" in EDITOR
def test_remove_background_never_falls_back_to_the_generative_model():
# A rembg outage must report the failure, not send "remove background" to
# the image model (which repainted a blank canvas instead).
guard = EDITOR[EDITOR.index("if (_looksBackgroundRemoval(prompt)) {"):]
guard = guard[:guard.index("// Explicit \"harmonize / blend the layers\"")]
assert "await _executeQuickEditLocal(prompt);" in guard
assert "Could not remove the background:" in guard
assert "_runImageModelEdit" not in guard
def test_remove_background_mask_is_a_layer_mask():
@@ -698,3 +716,43 @@ def test_generation_503_is_handled_gracefully():
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
def test_sending_a_prompt_returns_focus_to_the_document():
# After Enter the prompt box used to keep focus, so pressing V (Move) or B
# (Brush) typed into an empty prompt instead of switching tools.
start = EDITOR.index("const enqueueQuickEditJob = (prompt, forceAi = false) => {")
body = EDITOR[start:start + 1400]
assert "input.blur();\n pumpQuickEdit();" in body
assert "input.focus();\n pumpQuickEdit();" not in body
def test_edit_scope_control_chooses_all_layers_or_the_selected_layer():
# "Edit" used to always send the flattened document; the scope control next
# to the routing dropdown lets the selected layer be sent on its own.
assert 'id="ge-ai-command-scope"' in EDITOR
assert '<option value="all">All layers</option>' in EDITOR
assert '<option value="layer">This layer</option>' in EDITOR
assert "localStorage.getItem('ge-ai-command-edit-scope')" in EDITOR
assert "localStorage.setItem('ge-ai-command-edit-scope', scopeSelect.value);" in EDITOR
assert "scopeSelect.hidden = modeSelect.value === 'gen';" in EDITOR
assert "const _aiEditSourceCanvas = () => {" in EDITOR
assert "const flat = _aiEditSourceCanvas();" in EDITOR
assert "if (scope !== 'layer') return flatten();" in EDITOR
assert "out.getContext('2d').drawImage(layer.canvas, off.x, off.y);" in EDITOR
assert ".ge-ai-command .ge-ai-command-scope" in STYLE
def test_cancel_aborts_the_in_flight_image_request():
# Cancel used to drop the row but leave the model call running, so the
# result still landed on the canvas. The job must abort the actual fetch.
assert "let _aiEditAbort = null;" in EDITOR
assert "_aiEditAbort = _ctl;" in EDITOR
assert "if (_aiEditAbort === _ctl) _aiEditAbort = null;" in EDITOR
cancel = EDITOR[EDITOR.index("const cancelJob = (job) => {"):]
cancel = cancel[:cancel.index("_setAiCommandStatus('', '');")]
assert "try { _aiEditAbort?.abort(); } catch (_) {}" in cancel
css = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
block = css[css.index(".ge-ai-job-cancel {"):]
block = block[:block.index("}")]
assert "top: -2px;" in block
+3 -3
View File
@@ -24,7 +24,7 @@ def test_gallery_attaches_affordance_to_both_rails():
src = GALLERY.read_text(encoding="utf-8")
assert "import { attachScrollAffordance } from './editor/scroll-affordance.js';" in src
assert "attachScrollAffordance(container.querySelector('.ge-topbar-left'))" in src
assert "attachScrollAffordance(container.querySelector('.ge-toolbar'))" in src
assert "attachScrollAffordance(container.querySelector('.ge-toolbar-scroll'))" in src
def test_mobile_css_fades_overflowing_rail_edges():
@@ -34,7 +34,7 @@ def test_mobile_css_fades_overflowing_rail_edges():
before = css[:idx]
assert before.rfind("@media (max-width: 700px)") > 0
assert ".ge-topbar-left.ge-scroll-fade," in css
assert ".ge-toolbar.ge-scroll-fade {" in css
assert ".ge-toolbar-scroll.ge-scroll-fade {" in css
assert "mask-image: linear-gradient(to right, transparent 0, #000 42px" in css
assert ".ge-topbar-left.ge-scroll-fade.is-at-left.is-at-right," in css
assert ".ge-toolbar.ge-scroll-fade.is-at-left.is-at-right" in css
assert ".ge-toolbar-scroll.ge-scroll-fade.is-at-left.is-at-right" in css
+1 -1
View File
@@ -30,4 +30,4 @@ def test_scroll_fade_only_applies_to_marked_rails():
# scoped to the two mobile rails, not every toolbar.
css = _css()
assert ".ge-topbar-left.ge-scroll-fade," in css
assert ".ge-toolbar.ge-scroll-fade {" in css
assert ".ge-toolbar-scroll.ge-scroll-fade {" in css
+57
View File
@@ -0,0 +1,57 @@
"""Selection marching-ants must keep a constant on-screen size regardless of zoom.
The selection outline canvas is rendered at SCREEN resolution (image size ×
zoom) and the painter strokes a flat 1px outline in screen pixels, so there is
no bitmap resampling: the outline keeps the same thickness AND opacity at every
zoom level. The crop overlay got the same zoom compensation.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MASK = (ROOT / "static/js/editor/selection-mask.js").read_text(encoding="utf-8")
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
def test_selection_boundary_helper_returns_flags():
assert "export function selectionBoundary(mask, threshold = 128) {" in MASK
assert "flags" in MASK
# Rows outside the selection are skipped instead of scanning the whole
# canvas, which keeps closing a pen path fast on large documents.
assert "const firsts = new Int32Array(height).fill(-1);" in MASK
assert "const lasts = new Int32Array(height).fill(-1);" in MASK
assert "for (let x = first; x <= last; x += 1) {" in MASK
def test_overlay_renders_at_screen_resolution():
# The overlay bitmap must match its CSS box (image size × zoom) so the
# strokes are never up-scaled (which is what made opacity drift).
assert "const dispW = Math.max(1, Math.round(state.imgWidth * state.zoom));" in EDITOR
assert "overlay.style.width = `${dispW}px`;" in EDITOR
def test_painter_draws_flat_screen_pixels():
assert "export function paintSelectionBoundary(ctx, pixels, width, height, phase = 0, zoom = 1, flags = null) {" in MASK
assert "ctx.lineWidth = 1;" in MASK
assert "const x0 = x * scale;" in MASK
assert "new Path2D()" in MASK
def test_overlay_passes_zoom_and_flags():
assert "selectionBoundary as _selectionBoundary," in EDITOR
assert (
"_paintSelectionBoundary(ctx, cache.pixels, cache.width, cache.height, "
"_selectionAnimationPhase, state.zoom, cache.flags);" in EDITOR
)
assert "..._selectionBoundary(mask)," in EDITOR
def test_crop_overlay_is_zoom_compensated():
assert "state.mainCtx.lineWidth = 1 / state.zoom;" in EDITOR
assert "state.mainCtx.setLineDash([4 / state.zoom, 4 / state.zoom]);" in EDITOR
def test_sw_cache_name_bumped():
import re
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
+38
View File
@@ -0,0 +1,38 @@
"""Quick Select / Object Select must honour the New/Add/Subtract mode toggle.
The wand's segmented control (`.ge-wand-mode-btn`) writes `state.wandMode`;
every selection tool has to read it back. Quick Select used to hard-code
`'add'` (so every dab added, regardless of the toggle) and Object Select
hard-coded `'replace'`, so picking Subtract did nothing in either tool.
"""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
QUICK = (ROOT / "static/js/editor/tools/quick-select.js").read_text(encoding="utf-8")
OBJECT = (ROOT / "static/js/editor/tools/object-select.js").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
def test_quick_select_reads_the_wand_mode_toggle():
assert "import { state } from '../state.js';" in QUICK
assert "const modeFor = (event) => selectionModeForEvent(event, state.wandMode || 'replace');" in QUICK
# the draft mode is captured on begin and re-used for the whole stroke
assert "let strokeMode = 'replace';" in QUICK
assert "strokeMode = modeFor(event);" in QUICK
assert "sample(event, strokeMode);" in QUICK
# after the first replace, later dabs accumulate instead of wiping progress
assert "sample(event, strokeMode === 'replace' ? 'add' : selectionModeForEvent(event, strokeMode));" in QUICK
# the old hard-coded add default is gone
assert "selectionModeForEvent(event, 'add')" not in QUICK
def test_object_select_reads_the_wand_mode_toggle():
assert "import { state } from '../state.js';" in OBJECT
assert "mode = selectionModeForEvent(event, state.wandMode || 'replace');" in OBJECT
assert "mode = selectionModeForEvent(event, 'replace');" not in OBJECT
def test_sw_cache_name_bumped():
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
+17 -8
View File
@@ -51,11 +51,18 @@ def test_toolbar_declares_new_selection_tools():
def test_eyedropper_icon_is_a_pipette_not_the_brush():
# The eyedropper and the brush must read as different tools at a glance,
# so the eyedropper uses a pipette (pointed tip + bulb), not a brush head.
# The eyedropper must read as an actual dropper at a glance, so it uses the
# pipette glyph (bulb + barrel + pointed tip), not a droplet or a brush head.
assert "m2 22 1-1h3l9-9" in TOOLBAR
assert "m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3" in TOOLBAR
assert "M9.06 11.9l8.07-8.06a2.85" in TOOLBAR
assert "M3 21v-3l9-9" in TOOLBAR
assert "3.4-3.4a2.1 2.1 0 1 1 3 3" in TOOLBAR
def test_eyedropper_tool_uses_a_live_pipette_cursor():
# Picking the Eyedropper swaps the canvas cursor for the pipette (tip at the
# hotspot) instead of leaving the default crosshair.
assert "const eyedropperCursorSvg = `data:image/svg+xml;utf8,${encodeURIComponent(" in GALLERY
assert "else if (state.tool === 'eyedropper') cursor = `url(\"${eyedropperCursorSvg}\") 2 22, crosshair`;" in GALLERY
def test_all_toolbar_tool_icons_are_uniform_svgs():
@@ -115,13 +122,15 @@ def test_editor_wires_tools_and_color_range():
assert "if (state.tool === 'objectselect') return _objectSelectTool.end(e);" in GALLERY
assert "async function _colorRangeSelection()" in GALLERY
assert "colorRange: _colorRangeSelection," in GALLERY
assert "data-tool=\"quickselect\"" in GALLERY
assert "data-tool=\"objectselect\"" in GALLERY
assert "const toolBtn = (id) => state.container.querySelector(" in GALLERY
assert '.ge-tool-btn[data-tool="${id}"], .ge-tool-btn[data-tool-option="${id}"]' in GALLERY
assert "const quickSelectBtn = toolBtn('quickselect');" in GALLERY
assert "const objectSelectBtn = toolBtn('objectselect');" in GALLERY
def test_options_bar_and_select_menu_entries():
assert "quickselect: { modes: '#ge-quickselect-section', feather: null }" in OPTIONS_BAR
assert "objectselect: { modes: '#ge-objectselect-section', feather: null }" in OPTIONS_BAR
assert "quickselect: { modes: null, feather: null }" in OPTIONS_BAR
assert "objectselect: { modes: null, feather: null }" in OPTIONS_BAR
assert 'data-selection-action="colorRange"' in TOPBAR
assert "action === 'colorRange'" in WIRE_MENUS
assert "colorRange," in WIRE_MENUS
+25
View File
@@ -0,0 +1,25 @@
"""A slot's main keyboard key must keep the sub-tool you last picked.
The gradient / paint-bucket slot (and the brush / eraser / pixel slot) share one
toolbar button. Picking Paint Bucket from the flyout re-renders the slot face as
bucket, so pressing G must re-activate the face (keeping Paint Bucket) rather
than falling through to the hidden gradient entry and snapping back.
"""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
KEYS = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
def test_slot_key_reactivates_the_current_member():
assert "const flyoutItem = toolbar.querySelector(`[data-tool-option=\"${toolId}\"]`);" in KEYS
assert "const slotEl = flyoutItem ? flyoutItem.closest('.ge-tool-slot') : null;" in KEYS
assert "const firstMember = slotEl.querySelector('.ge-tool-flyout-item[data-tool-option]');" in KEYS
assert "if (toolId === defaultTool) toolBtn = slotEl.querySelector('.ge-tool-slot-face');" in KEYS
assert "toolBtn = toolBtn || toolbar.querySelector(`[data-tool=\"${toolId}\"]`) || flyoutItem;" in KEYS
def test_sw_cache_name_bumped():
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
+1 -1
View File
@@ -21,7 +21,7 @@ def test_hex_code_field_is_wired():
js = _read("static/js/editor/panels/panels.js")
assert "const hexRow = el('div', 'ge-hex-row');" in js
assert "const hexInput = el('input', 'ge-hex-input');" in js
assert "hexRow.append(hexHash, hexInput);" in js
assert "hexRow.append(hexHash, hexInput, hexCopy, hexFav);" in js
assert "swatchBody.appendChild(hexRow);" in js
assert "const _setHex = (hex) => {" in js
assert "hexInput.addEventListener('input'," in js
+60 -2
View File
@@ -10,7 +10,8 @@ def _read(rel):
def test_self_hosted_font_faces_are_declared():
css = _read("static/css/00-tokens.css")
for family in ["Inter", "Roboto", "Playfair Display", "Lobster", "Bebas Neue", "Roboto Mono"]:
for family in ["Inter", "Roboto", "Playfair Display", "Lobster", "Bebas Neue", "Roboto Mono",
"Space Grotesk", "Fraunces", "Syne", "Bungee", "Instrument Serif"]:
assert f"font-family: '{family}'" in css, family
for fname in [
"Inter-Regular.woff2",
@@ -19,6 +20,11 @@ def test_self_hosted_font_faces_are_declared():
"Lobster-Regular.woff2",
"BebasNeue-Regular.woff2",
"RobotoMono-Regular.woff2",
"SpaceGrotesk-Regular.woff2",
"Fraunces-Regular.woff2",
"Syne-Regular.woff2",
"Bungee-Regular.woff2",
"InstrumentSerif-Regular.woff2",
]:
assert f"/static/fonts/{fname}" in css, fname
@@ -33,6 +39,14 @@ def test_font_files_exist_on_disk():
"BebasNeue-Regular.woff2",
"RobotoMono-Regular.woff2",
"RobotoMono-Bold.woff2",
"SpaceGrotesk-Regular.woff2",
"SpaceGrotesk-Bold.woff2",
"Fraunces-Regular.woff2",
"Fraunces-Bold.woff2",
"Syne-Regular.woff2",
"Syne-Bold.woff2",
"Bungee-Regular.woff2",
"InstrumentSerif-Regular.woff2",
]:
assert (REPO / "static" / "fonts" / fname).is_file(), fname
@@ -49,7 +63,8 @@ def test_text_layer_exposes_font_helpers():
def test_options_bar_font_select_lists_webfonts():
js = _read("static/js/editor/build/controls.js")
for family in ["Inter", "Roboto", "Playfair Display", "Lobster", "Bebas Neue", "Roboto Mono"]:
for family in ["Inter", "Roboto", "Playfair Display", "Lobster", "Bebas Neue", "Roboto Mono",
"Space Grotesk", "Fraunces", "Syne", "Bungee", "Instrument Serif"]:
assert family in js, family
@@ -64,3 +79,46 @@ def test_sw_version_is_current():
sw = _read("static/sw.js")
m = re.search(r"CACHE_NAME = '([^']+)'", sw)
assert m and re.fullmatch(r"odysseus-v\d+-pr40-editor-precache", m.group(1)), m
def test_text_box_width_survives_the_position_loop():
js = _read("static/js/editor/text-edit-overlay.js")
# The per-frame position() used to rewrite the width unconditionally, which
# stomped the native resize handle before the ResizeObserver saw it.
assert "const targetW = Math.max(80, text.frameWidth * scaleX);" in js
assert "if (!resizing && Math.abs(targetW - appliedWidth) > 0.5) {" in js
assert "input.style.minWidth = '40px';" in js
assert "let appliedWidth = 0;" in js
assert "appliedWidth = 0;" in js
def test_text_box_has_a_visible_corner_resize_handle():
js = _read("static/js/editor/text-edit-overlay.js")
css = _read("static/css/13-image-editor.css")
# The native textarea grip is invisible and tiny in this theme, so the
# editor exposes its own 14px grab square that drives the frame width.
assert "handle.className = 'ge-direct-text-handle';" in js
assert "handle.addEventListener('pointerdown'" in js
assert "handle.addEventListener('pointermove'" in js
assert "input.style.width = `${w}px`;" in js
assert "onInput?.(layer, input.value, input, w / Math.max(0.01, dragStart.scale));" in js
assert "handle.innerHTML = '<svg width=\"12\" height=\"12\"" in js
assert ".ge-direct-text-handle {" in css
assert "cursor: nwse-resize;" in css
assert "width: 16px;" in css
handle_block = css[css.index(".ge-direct-text-handle {"):]
handle_block = handle_block[:handle_block.index("}")]
assert "position: fixed;" in handle_block
assert "z-index: 10021;" in handle_block
assert "background: transparent;" in handle_block
assert "opacity: 0.55;" in handle_block
# The editing surface is see-through so the artwork stays visible while typing.
editor_block = css[css.index(".ge-direct-text-editor {"):]
editor_block = editor_block[:editor_block.index("}")]
assert "background: transparent;" in editor_block
# `body.ge-editor-open textarea` is more specific, so the transparent
# surface is restated for the on-canvas text field.
override = css[css.index("body.ge-editor-open textarea.ge-direct-text-editor {"):]
override = override[:override.index("}")]
assert "background: transparent;" in override
assert "border: 1px dashed var(--red);" in override
+6 -6
View File
@@ -1,7 +1,7 @@
"""Marker test: the mobile tool-rail overflow control shows an ellipsis.
"""Marker test: the mobile tool-rail overflow control shows a '>' chevron.
EVAL flagged the '<' '>' chevron; it should match the dock overflow control
(an ellipsis '...').
Superseded 2026-10-14: the maintainer asked for a '>' chevron instead of the
ellipsis, so it clearly reads as "more tools" rather than a menu.
"""
from pathlib import Path
@@ -12,9 +12,9 @@ EDITOR = (ROOT / "static/js/galleryEditor.js").read_text()
SW = (ROOT / "static/sw.js").read_text()
def test_tool_rail_more_is_an_ellipsis():
assert "toolbarMore.textContent = '\\u2026';" in EDITOR
assert "toolbarMore.textContent = '\\u203A';" not in EDITOR
def test_tool_rail_more_is_a_chevron():
assert "toolbarMore.textContent = '\\u203A';" in EDITOR
assert "toolbarMore.textContent = '\\u2026';" not in EDITOR
def test_sw_version_is_current():
+112
View File
@@ -0,0 +1,112 @@
"""Toolbar batch: inline wand refinements, custom crop ratio, dodge/burn
intensity, gradient strip de-duplication, the tall text strip and the pen
overlay that no longer re-composites the whole document."""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
CONTROLS = (ROOT / "static/js/editor/build/controls.js").read_text(encoding="utf-8")
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
STATE = (ROOT / "static/js/editor/state.js").read_text(encoding="utf-8")
PIPE = (ROOT / "static/js/editor/stroke-pipeline.js").read_text(encoding="utf-8")
SLIDERS = (ROOT / "static/js/editor/stroke-tool-sliders.js").read_text(encoding="utf-8")
PEN = (ROOT / "static/js/editor/tools/pen-selection.js").read_text(encoding="utf-8")
OB = (ROOT / "static/js/editor/build/options-bar.js").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
def test_custom_crop_ratio_field():
assert 'id="ge-crop-custom-ratio"' in CONTROLS
assert 'class="ge-crop-custom"' in CONTROLS
assert ".ge-crop-custom input {" in CSS
# The typed ratio is applied through the same preset path as the buttons,
# and a centred rect is created when none is drawn yet.
assert "customRatioInput.addEventListener('change', applyCustomRatio);" in EDITOR
assert "if (!(rect && rect.w > 5 && rect.h > 5)) {" in EDITOR
def test_dodge_and_burn_intensity_slider():
assert 'id="ge-dodgeburn-intensity"' in CONTROLS
assert "dodgeBurnIntensity: 25," in STATE
assert "state.dodgeBurnIntensity = parseInt(e.target.value, 10);" in SLIDERS
assert PIPE.count("state.dodgeBurnIntensity") >= 2
assert PIPE.count("alpha * intensity") >= 2
# The strip mirrors Intensity so it is adjustable without the More menu.
assert "intensity: '#ge-dodgeburn-intensity'" in OB
assert "makeRange('Intensity', src.intensity)" in OB
# The row is a real strip row next to Size, shown for Dodge/Burn only.
assert "if (dodgeBurnRow) dodgeBurnRow.style.display = (toolId === 'dodge' || toolId === 'burn') ? '' : 'none';" in EDITOR
menu = CONTROLS[CONTROLS.index('id="ge-brush-more-menu"'):]
assert 'id="ge-dodgeburn-intensity-row"' not in menu.split('ge-brush-pressure-row')[0]
# Its label is abbreviated (Int) and its column shrinks to the word so the
# text sits flush against its own slider.
assert '<label title="Intensity">Int <span id="ge-dodgeburn-intensity-label">25%</span></label>' in CONTROLS
assert "#ge-dodgeburn-intensity-row label { flex: 0 0 auto; min-width: 20px; }" in CSS
# The dot and its label sit 10px further right than the row's edge.
assert "#ge-dodgeburn-intensity-row > .ge-eraser-preview { margin-left: 10px; }" in CSS
def test_every_clear_cross_is_the_same_square_target():
start = CSS.index("#ge-marquee-clear,\n#ge-wand-clear,")
block = CSS[start:start + 700]
assert " width: 26px;\n height: 26px;" in block
assert " min-width: 26px;\n min-height: 26px;" in block
assert " display: inline-flex;\n align-items: center;\n justify-content: center;" in block
for sel in ("#ge-wand-clear", "#ge-marquee-clear", "#ge-lasso-clear", "#ge-clone-source-clear"):
assert sel in block
def test_clone_clear_icon_moves_back_when_a_source_is_selected():
assert "#ge-clone-source-clear.has-selection svg { top: 1px; }" in CSS
def test_gradient_strip_is_not_duplicated():
# The real #ge-gradient-section renders inside the strip; the mirror must
# not add a second Type/Opacity pair.
assert "ge-gradient-type" not in OB
assert "ge-gradient-opacity" not in OB
assert 'id="ge-gradient-section"' in CONTROLS
def test_text_strip_can_wrap_tall():
assert "ge-options-tall" in CSS
assert "classList.toggle('ge-options-tall', toolId === 'text')" in EDITOR
assert CSS.count("flex-wrap: wrap !important;") >= 2
def test_pen_tool_paints_its_overlay_without_recompositing():
assert "state.penOverlay" in PEN
assert "state.penOverlayCtx" in PEN
assert "const repaint = () => { if (typeof redraw === 'function') redraw(); else composite(); };" in PEN
assert PEN.count("repaint();") >= 4
# begin()/drag() must not re-composite the document
begin = PEN[PEN.index("begin(event)"):PEN.index("drag(event)")]
assert "composite()" not in begin
assert "redraw: () => _penTool.draw()," in EDITOR
assert ".ge-pen-overlay {" in CSS
assert "state.penOverlay = null;" in EDITOR
# The stylesheet hides the overlay by default, so revealing it must set an
# explicit display rather than clearing the inline value.
assert "overlay.style.display = 'block';" in PEN
# Closing the path must show the marching ants. It refreshes just the
# selection overlay (cheap) and falls back to a full composite.
assert "if (typeof refreshSelection === 'function') refreshSelection();" in PEN
assert "else composite();" in PEN
assert "refreshSelection: () => _refreshSelectionOverlay()," in EDITOR
# A pen close only changes the selection, so it must not re-read every
# layer's pixels for the undo snapshot.
assert "saveState('Pen selection', { selectionOnly: true });" in PEN
assert "const reuse = options.selectionOnly ? state.undoStack.at(-1) : null;" in EDITOR
# The preview shows handles only for the anchor being shaped.
assert "const shaped = dragging ? points.length - 1 : -1;" in PEN
def test_brush_size_readout_hugs_its_slider():
# The px value IS the whole label on the size row; letting the shared 36px
# label column force it open leaves a dead gap before its own slider.
assert ".ge-brush-size-row label { min-width: 0; }" in CSS
def test_sw_cache_name_bumped():
assert re.search(r"CACHE_NAME = 'odysseus-v\d+-pr40-editor-precache'", SW)
+54
View File
@@ -0,0 +1,54 @@
"""Toolbar flyout affordances: the "more tools" corner triangle must survive
face re-renders, and brush-preset rows in the brush flyout show a tip glyph.
The triangle hint used to be injected into the slot FACE element, so any
`setInner(face, id)` re-render (e.g. after picking a flyout member) wiped it and
the slot looked like a single tool. It now lives on the slot wrapper, and the
brush-preset rows carry a soft/hard/pixel disc reflecting the preset tip.
"""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
TOOLBAR = (ROOT / "static/js/editor/build/toolbar.js").read_text(encoding="utf-8")
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
def test_flyout_triangle_is_on_the_slot_not_the_face():
assert "slotEl.insertAdjacentHTML('beforeend', '<span class=\"ge-tool-flyout-tri\" aria-hidden=\"true\"></span>');" in TOOLBAR
assert "const tri = slotEl.querySelector('.ge-tool-flyout-tri');" in TOOLBAR
# the hint must not be re-injected into the face (which setInner replaces)
assert "face.insertAdjacentHTML('beforeend', '<span class=\"ge-tool-flyout-tri\"" not in TOOLBAR
def test_flyout_triangle_css_is_visible():
# The element is a generous transparent hit area; the visible mark is
# painted by ::after so it stays easy to click despite being small.
i = CSS.index(".ge-tool-flyout-tri {")
block = CSS[i:i + 460]
assert "width: 18px;" in block
assert "height: 18px;" in block
j = CSS.index(".ge-tool-flyout-tri::after {")
mark = CSS[j:j + 420]
assert "border-left: 7px solid transparent;" in mark
assert "border-bottom: 7px solid var(--fg-muted, currentColor);" in mark
assert "opacity: 0.7;" in mark
def test_brush_preset_rows_have_a_tip_icon():
assert "const soft = typeof preset.softness === 'number' ? preset.softness : 100;" in TOOLBAR
assert "const presetIcon = preset.pixel" in TOOLBAR
assert '<span class="ge-tool-icon">${presetIcon}</span><span class="ge-tool-flyout-label">${preset.name}</span>' in TOOLBAR
def test_flyout_is_clamped_inside_the_viewport():
assert "const maxLeft = Math.max(4, window.innerWidth - fw - 4);" in TOOLBAR
assert "const maxTop = Math.max(4, window.innerHeight - fh - 4);" in TOOLBAR
assert "if (viewLeft > maxLeft) flyout.style.left = (maxLeft - cbX) + 'px';" in TOOLBAR
assert "if (viewTop > maxTop) flyout.style.top = (maxTop - cbY) + 'px';" in TOOLBAR
def test_sw_cache_name_bumped():
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
+1 -1
View File
@@ -58,7 +58,7 @@ def test_mobile_topbar_pins_the_right_actions():
def test_desktop_tool_rail_keeps_long_tool_names_readable():
toolbar_start = STYLE.index(".ge-toolbar {")
toolbar_end = STYLE.index(".ge-toolbar::-webkit-scrollbar", toolbar_start)
toolbar_end = STYLE.index(".ge-toolbar-scroll::-webkit-scrollbar", toolbar_start)
toolbar = STYLE[toolbar_start:toolbar_end]
assert "width: 50px;" in toolbar
label_rule = re.search(r"(?m)^\.ge-tool-label\s*\{([^}]*)\}", STYLE[toolbar_end:])
+56
View File
@@ -0,0 +1,56 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_entering_transform_from_a_shortcut_swaps_the_options_strip():
# A shortcut must behave like picking the Transform tool: the previous
# tool's options (e.g. Text) are hidden so they don't overlap the
# transform controls.
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
assert "const _enterTransform = () => {" in editor
assert "toolbar.querySelector('.ge-tool-flyout-item[data-tool-option=\"transform\"]')" in editor
assert "startTransform: _enterTransform," in editor
def test_transform_entry_shows_a_one_time_hint():
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
assert "const _maybeShowTransformHint = () => {" in editor
assert "sessionStorage.getItem('ge-transform-hint')" in editor
assert "sessionStorage.setItem('ge-transform-hint', '1');" in editor
assert "Free Transform \u2014 Enter applies, Esc cancels. Shortcut: Ctrl+Alt+T" in editor
assert "_startTransform();\n _maybeShowTransformHint();" in editor
def test_shortcuts_popover_lists_ctrl_alt_t_for_transform():
popups = (ROOT / "static/js/editor/build/popups.js").read_text(encoding="utf-8")
assert "<kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>T</kbd> Free Transform</div>" in popups
def test_transform_hint_is_pinned_to_one_stable_spot():
# The hint used to jump between the top-right corner and the full-width
# bottom banner depending on what else was open.
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
assert "{ toastClass: 'toast-transform-hint' }" in editor
ui = (ROOT / "static/js/ui.js").read_text(encoding="utf-8")
assert "if (el.classList.contains('toast-transform-hint')) {" in ui
assert "'toast-message-sent', 'toast-transform-hint');" in ui
css = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
block = css[css.index("body.ge-editor-open .toast.toast-transform-hint {"):]
block = block[:block.index("}")]
assert "bottom: 84px;" in block
assert "left: 0;" in block and "right: 0;" in block
assert "margin-left: auto;" in block and "margin-right: auto;" in block
def test_transform_preserve_label_stays_on_one_line():
css = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
block = css[css.index("#ge-transform-section .ge-transform-preserve-field {"):]
block = block[:block.index("}")]
assert "flex: 0 0 auto;" in block
assert "white-space: nowrap;" in block
assert "#ge-transform-section .ge-transform-preserve-field span { display: none; }" in css
popup = (ROOT / "static/js/editor/build/transform-popup.js").read_text(encoding="utf-8")
assert 'title="Preserve source pixels (applies the transform non-destructively)"' in popup
assert 'aria-label="Preserve source pixels"' in popup
@@ -12,7 +12,7 @@ SNAP_MODULE = (ROOT / "static/js/editor/snap.js").as_uri()
def run_node(body: str):
script = textwrap.dedent(
f"""
import {{transformFrameGeometry, hitTestTransformHandle, pointInTransformFrame, resizeTransformFrame}} from {json.dumps(MODULE)};
import {{transformFrameGeometry, hitTestTransformHandle, pointInTransformFrame, resizeTransformFrame, skewTransformFrame, distortTransformFrame}} from {json.dumps(MODULE)};
{body}
"""
)
@@ -220,3 +220,121 @@ def test_shift_alt_corner_resize_is_centered_and_aspect_locked():
"flipH": False,
"flipV": False,
}
def test_skew_drag_shears_the_dragged_edge_only():
result = run_node(
"""
const frame={centerX:0,centerY:0,width:200,height:100,rotation:0};
const right=skewTransformFrame(frame,'r',{x:0,y:0},{x:0,y:40});
const top=skewTransformFrame(frame,'t',{x:0,y:0},{x:50,y:0});
const corner=skewTransformFrame(frame,'br',{x:0,y:0},{x:30,y:20});
const none=skewTransformFrame(frame,'r',{x:0,y:0},{x:0,y:0});
console.log(JSON.stringify({right,top,corner,none}));
"""
)
# Right edge dragged down -> skew Y only; top edge dragged right ->
# skew X only; corner -> both; no drag -> unchanged.
assert result["right"]["skewX"] == 0
assert abs(result["right"]["skewY"] - 21.8) < 0.5
assert abs(result["top"]["skewX"] - (-45.0)) < 0.5
assert result["top"]["skewY"] == 0
assert abs(result["corner"]["skewX"] - 30.96) < 0.5
assert abs(result["corner"]["skewY"] - 11.31) < 0.5
assert result["none"] == {"skewX": 0, "skewY": 0}
def test_skew_drag_is_relative_to_the_start_and_clamped():
result = run_node(
"""
const frame={centerX:0,centerY:0,width:100,height:100,rotation:0};
const grown=skewTransformFrame({...frame,height:100},'b',{x:0,y:0},{x:10,y:0},{startSkewX:20,startSkewY:0});
const huge=skewTransformFrame({...frame,height:1},'b',{x:0,y:0},{x:9000,y:0});
console.log(JSON.stringify({grown,huge}));
"""
)
assert result["grown"]["skewX"] > 20 # start offset + drag delta
assert result["huge"]["skewX"] == 85 # clamped below the singular 90°
def test_distort_drag_moves_only_the_dragged_corner():
result = run_node(
"""
const frame={centerX:0,centerY:0,width:100,height:100,rotation:0};
const corner=distortTransformFrame(frame,'br',{x:0,y:0},{x:10,y:20});
console.log(JSON.stringify({corner}));
"""
)
assert result["corner"] == {
"tl": {"x": 0, "y": 0},
"tr": {"x": 0, "y": 0},
"br": {"x": 0.1, "y": 0.2},
"bl": {"x": 0, "y": 0},
}
def test_distort_drag_moves_only_the_dragged_edge_corners():
result = run_node(
"""
const frame={centerX:0,centerY:0,width:100,height:100,rotation:0};
const edge=distortTransformFrame(frame,'t',{x:0,y:0},{x:5,y:-10});
console.log(JSON.stringify({edge}));
"""
)
assert result["edge"] == {
"tl": {"x": 0.05, "y": -0.1},
"tr": {"x": 0.05, "y": -0.1},
"br": {"x": 0, "y": 0},
"bl": {"x": 0, "y": 0},
}
def test_distort_ignores_rotation_and_move_handles():
result = run_node(
"""
const frame={centerX:0,centerY:0,width:100,height:100,rotation:0};
console.log(JSON.stringify({
rot: distortTransformFrame(frame,'rot',{x:0,y:0},{x:30,y:0}),
move: distortTransformFrame(frame,'move',{x:0,y:0},{x:30,y:0}),
}));
"""
)
assert result["rot"] is None
assert result["move"] is None
def test_distort_is_clamped_and_relative_to_the_start():
result = run_node(
"""
const frame={centerX:0,centerY:0,width:100,height:100,rotation:0};
const huge=distortTransformFrame(frame,'br',{x:0,y:0},{x:900,y:0});
const grown=distortTransformFrame(frame,'br',{x:0,y:0},{x:10,y:0},{startDistort:{tl:{x:0,y:0},tr:{x:0,y:0},br:{x:0.5,y:0},bl:{x:0,y:0}}});
console.log(JSON.stringify({huge: huge.br.x, grown: grown.br.x}));
"""
)
assert result["huge"] == 4 # clamped to the ±4 frame-normal limit
assert abs(result["grown"] - 0.6) < 1e-9 # start offset + drag delta
def test_geometry_places_handles_on_the_distorted_quad():
result = run_node(
"""
const geometry=transformFrameGeometry({centerX:0,centerY:0,width:100,height:100,rotation:0,distort:{tl:{x:0,y:0},tr:{x:0,y:0},br:{x:0.1,y:0.2},bl:{x:0,y:0}}},{zoom:1});
console.log(JSON.stringify({
corners: geometry.corners,
handles: Object.fromEntries(geometry.resizeHandles.map(h=>[h.id,[h.x,h.y]])),
}));
"""
)
assert result["corners"] == {
"tl": {"x": -50, "y": -50},
"tr": {"x": 50, "y": -50},
"br": {"x": 60, "y": 70},
"bl": {"x": -50, "y": 50},
}
assert result["handles"]["br"] == [60, 70]
assert result["handles"]["t"] == [0, -50]
assert result["handles"]["r"] == [55, 10]
assert result["handles"]["b"] == [5, 60]
assert result["handles"]["l"] == [-50, 0]
+88
View File
@@ -11,6 +11,9 @@ STATE = read("static/js/editor/state.js")
POPUP = read("static/js/editor/build/transform-popup.js")
SESSION = read("static/js/editor/tools/transform-session.js")
MULTI = read("static/js/editor/multi-transform.js")
GEO = read("static/js/editor/transform-frame-geometry.js")
HANDLES = read("static/js/editor/tools/transform-handles.js")
DRAG = read("static/js/editor/tools/transform-drag.js")
def test_state_has_skew_fields():
@@ -42,3 +45,88 @@ def test_multitransform_geometry_carries_skew():
assert "skewPadY" in MULTI
assert "skewX," in MULTI
assert "skewY," in MULTI
def test_geometry_applies_local_shear():
assert "export function skewTransformFrame(frame, handle, startPoint, currentPoint, options = {}) {" in GEO
assert "const shearX = Math.tan(skewX * Math.PI / 180);" in GEO
assert "const shearY = Math.tan(skewY * Math.PI / 180);" in GEO
assert "if (ySign) skewX = clamp(startSkewX" in GEO
assert "if (xSign) skewY = clamp(startSkewY" in GEO
def test_ctrl_drag_skews_the_grabbed_handle():
assert "import { resizeTransformFrame, skewTransformFrame, distortTransformFrame } from '../transform-frame-geometry.js';" in DRAG
assert "state.transformStartSkewX = state.transformPendingSkewX || 0;" in DRAG
assert "state.transformStartSkewY = state.transformPendingSkewY || 0;" in DRAG
assert "if (e.ctrlKey || e.metaKey) {" in DRAG
assert "skewTransformFrame({" in DRAG
assert "state.transformPendingSkewX = skewed.skewX;" in DRAG
assert "state.transformPendingSkewY = skewed.skewY;" in DRAG
def test_handles_thread_skew_and_grow_the_overlay_past_the_image():
# transformFrame() must feed skew into the shared geometry...
assert "skewX: state.transformPendingSkewX || 0," in HANDLES
assert "skewY: state.transformPendingSkewY || 0," in HANDLES
# ...and the overlay must be sized to the frame, not just the image.
assert "export function syncOverlay(margin, extents = null) {" in HANDLES
assert "const cover = syncOverlay(margin, frameExtents(geometry));" in HANDLES
assert "function frameExtents(geometry) {" in HANDLES
assert "ctx.translate(-cover.left, -cover.top);" in HANDLES
assert "const MAX_BITMAP = 8192;" in HANDLES
def test_transform_controls_live_in_the_header_strip():
css = read("static/css/13-image-editor.css")
controls = read("static/js/editor/build/controls.js")
assert 'class="ge-transform-section" id="ge-transform-section" style="display:none;"' in controls
assert "const pop = document.getElementById('ge-transform-section');" in SESSION
assert "pop.innerHTML = `<div class=\"ge-transform-popup\">${transformPopupHTML()}</div>`;" in SESSION
assert "state.container.appendChild(pop)" not in SESSION
assert "pop.className = 'ge-transform-popup'" not in SESSION
assert "state.transformPopup.style.display = 'none';" in SESSION
assert "#ge-transform-section .ge-transform-popup," in css
assert "display: contents;" in css
# The head's `display: flex !important` (for the floating popup) must be
# out-ranked so the wrapper flattens into the strip's single row.
assert "#ge-transform-section .ge-transform-popup-head { display: contents !important; }" in css
# The floating popup spaced its buttons off the fields; that stray top
# margin must be neutralised or the strip row grows taller than the bar.
assert "#ge-transform-section button { margin-top: 0 !important; }" in css
def test_ctrl_drag_distorts_only_the_dragged_point():
# New state slot + reset + target round-trip.
assert "transformPendingDistort: null," in STATE
assert "distort: state.transformPendingDistort," in SESSION
assert "state.transformPendingDistort = target.distort || null;" in SESSION
# The session renders the warped quad.
assert "if (geometry.distort && geometry.quad) {" in SESSION
assert "perspectiveWarpCanvas(source, normalized, canvas.width, canvas.height)" in SESSION
# Geometry exposes the pure distort helper + places handles on the quad.
assert "export function distortTransformFrame(frame, handle, startPoint, currentPoint, options = {}) {" in GEO
assert "const corners = ['tl', 'tr', 'br', 'bl'];" in GEO
assert "tl: ['tl'], tr: ['tr'], br: ['br'], bl: ['bl']," in GEO
assert "t: ['tl', 'tr'], b: ['bl', 'br'], l: ['tl', 'bl'], r: ['tr', 'br']," in GEO
assert "}[handle];" in GEO
assert "const LIMIT = 4;" in GEO
# Handles thread the quad through to the skeleton.
assert "distort: state.transformPendingDistort || null," in HANDLES
# Multi-transform sizes the layer to the quad AABB.
assert "const distort = target.distort || null;" in MULTI
# Drag: Ctrl grabs a corner/edge -> distort (only that point); skew stays for rot.
assert "import { resizeTransformFrame, skewTransformFrame, distortTransformFrame } from '../transform-frame-geometry.js';" in DRAG
assert "state.transformStartDistort = state.transformPendingDistort || null;" in DRAG
assert "const distorted = distortTransformFrame({" in DRAG
assert "state.transformPendingDistort = distorted;" in DRAG
def test_distort_bakes_into_the_raster_instead_of_the_placed_source():
# A free-distort quad is a non-affine warp; placed-layer data is affine
# only, so the session must fall back to the raster branch for it.
assert "const usePlaced = snapshot.placed" in SESSION
assert "&& (snapshot.wasPlaced || state.transformPreserveSource !== false)" in SESSION
assert "&& !plannedGeometry.distort;" in SESSION
assert "if (usePlaced) {" in SESSION
assert "if (snapshot.kind === 'raster' || plannedGeometry.distort) {" in SESSION
+36 -2
View File
@@ -18,11 +18,12 @@ STYLE = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
WINDOW_ACTIONS = {
"quick-edit",
"options", "layers", "properties", "character", "adjustments",
"swatches", "gradients", "channels", "paths", "patterns", "histogram",
"info", "navigator", "history", "actions",
"workspace-essentials", "workspace-photography", "workspace-painting",
"workspace-graphic", "shortcuts",
"workspace-graphic", "workspace-minimal", "workspace-custom", "shortcuts",
}
@@ -48,8 +49,12 @@ def test_window_menu_is_a_real_toggle_kept_open():
assert "function _syncWindowMenuChecks(menu)" in GALLERY
assert "panelVisibility.isVisible(action)" in GALLERY
assert "state.historyPanelEl" in GALLERY
assert "ge-shortcuts-popover" in GALLERY
assert "_shortcutsPopover?.isOpen?.()" in GALLERY
assert "togglePanel: (id) => panelVisibility.toggle(id)," in GALLERY
# Quick Edit has no dock panel: the Window row mirrors its collapse control.
assert "else if (action === 'quick-edit') toggleQuickEdit?.();" in WIRE
assert "wrap.dispatchEvent(new CustomEvent(collapsed ? 'ge-ai-command-open' : 'ge-ai-command-close'));" in GALLERY
assert "on = !!wrap && !wrap.classList.contains('ge-ai-command-collapsed');" in GALLERY
def test_history_row_stays_toggleable():
@@ -74,3 +79,32 @@ def test_window_dot_uses_shared_active_glyph_css():
def test_sw_cache_name_bumped_for_window_dots():
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
def test_shortcuts_row_toggles_off():
# The shortcuts popover dismisses itself on document mousedown; a click on
# a topbar dropdown row (the Window menu "Keyboard Shortcuts" row) must not
# count as click-away, or the row's own click would immediately reopen it
# and the row could only ever turn it on.
POPOVER = (ROOT / "static/js/editor/shortcuts-popover.js").read_text(encoding="utf-8")
assert "e.target.closest('.ge-topbar .dropdown')" in POPOVER
def test_workspace_choice_closes_the_window_menu():
assert "const closeWindowMenu = () => {" in WIRE
assert "menu.hidden = true;" in WIRE
# both the preset rows and the Save Current row close the menu
assert "closeWindowMenu();" in WIRE
def test_save_current_workspace_option():
assert 'data-window-action="workspace-save"' in TOPBAR
assert "<span>Save Current</span>" in TOPBAR
assert 'data-window-action="workspace-custom"' in TOPBAR
assert "saveWorkspace?.();" in WIRE
assert "saveWorkspace: _saveCurrentWorkspace," in GALLERY
assert "const _saveCurrentWorkspace = () => {" in GALLERY
assert "localStorage.setItem('ge-workspace-custom'" in GALLERY
# applyWorkspace resolves a user-saved slot from localStorage
WS = (ROOT / "static/js/editor/workspaces.js").read_text(encoding="utf-8")
assert "localStorage.getItem(`ge-workspace-${name}`)" in WS
+17 -6
View File
@@ -1,7 +1,7 @@
"""Window > Workspace submenu: a nested flyout with 4 window setups.
"""Window > Workspace submenu: a nested flyout with the window setups.
The Window dropdown used to list the 4 workspace presets as flat rows. They are
now a `.ge-menu-parent` flyout ("Workspace") whose 4 children set up the editor
The Window dropdown used to list the workspace presets as flat rows. They are
now a `.ge-menu-parent` flyout ("Workspace") whose children set up the editor
windows (dock panels + tab + right-panel width). The submenu open/close toggle
is the shared `wireSubmenuToggles` helper (also used by Image/Filter/Selection).
"""
@@ -23,8 +23,8 @@ SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
def test_window_menu_has_a_workspace_flyout_parent():
assert 'class="dropdown-item-compact ge-menu-parent" data-submenu="window-workspace"' in TOPBAR
assert '<div class="ge-submenu" data-submenu-panel="window-workspace">' in TOPBAR
# the 4 variants still carry their window-action ids
for name in ("essentials", "photography", "painting", "graphic"):
# the variants still carry their window-action ids
for name in ("essentials", "photography", "painting", "graphic", "minimal"):
assert f'data-window-action="workspace-{name}"' in TOPBAR
@@ -39,13 +39,24 @@ def test_submenu_toggle_helper_is_shared():
def test_workspaces_set_up_the_windows():
# every preset declares the panels it shows
assert WORKSPACES.count("panels:") == 4
assert WORKSPACES.count("panels:") == 5
assert "setPanelVisible" in WORKSPACES
assert "PANEL_IDS" in WORKSPACES
# the "minimal" preset is just layers + history
assert "minimal: Object.freeze({ label: 'Minimal'" in WORKSPACES
assert "panels: Object.freeze(['layers', 'history'])" in WORKSPACES
# composer hands the panel-visibility setter into applyWorkspace
assert "setPanelVisible: (id, visible) => panelVisibility.setVisible(id, visible)" in GALLERY
def test_active_workspace_is_remembered_and_restored_on_open():
# the chosen workspace is restored from localStorage when the editor opens
assert "localStorage.getItem('ge-active-workspace')" in GALLERY
restore = GALLERY[GALLERY.index("Remember the workspace/window layout"):]
assert "_applyWorkspace(savedWorkspace" in restore
assert "rightPanel: window.innerWidth > 700 ? rightPanel : null" in restore
def test_workspace_flyout_releases_the_menu_clip():
assert ".ge-window-menu:has(.ge-menu-parent.open)" in CSS
@@ -11,6 +11,7 @@ from src.clean_agent_preview import (
from src.turn_contract import (
editor_request_instructions, requested_capabilities, selected_tools_for_request,
preserve_bound_editor_selected_tools, resolve_turn_contract,
targets_bound_editor_request,
)
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.tool_policy import ToolPolicy
@@ -72,6 +73,16 @@ def test_direct_edits_share_the_capability_router(prompt):
assert not active_editor_suggestion_request(doc, prompt)
@pytest.mark.parametrize('prompt', [
'ga mer ut pa att vi tanker pa framtid for bjorn och att vi faktist trivs sa bra har',
'Go more into how we are thinking about the future.',
])
def test_implicit_revision_followups_route_to_active_document_only(prompt):
assert requested_capabilities(prompt, active_document=True) == {'documents'}
assert requested_capabilities(prompt, active_document=False) == frozenset()
assert targets_bound_editor_request(prompt)
def test_source_review_still_requires_tool_completion_after_research():
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] in {'web_search', 'suggest_document'}]
assert required_active_editor_tool_choice(active_editor_target=True, suggestion_target=True,
+515
View File
@@ -0,0 +1,515 @@
"""User-supplied IMAP/SMTP hosts must pass the outbound host guard.
Regression: POST /api/email/accounts/test opened raw ``imaplib`` / ``smtplib``
connections to whatever host:port the caller supplied, and the account save
routes persisted arbitrary hosts for the background pollers to contact. Both
are reachable by any authenticated user (``require_user`` / ``require_owner``,
no admin gate), so a non-admin could port-scan loopback, the LAN and the cloud
metadata range through the server and read the first 200 chars of the banner
back out of the error message. THREAT_MODEL.md lists email as admin-only.
Policy (see routes/email_routes.py:_check_mail_host): link-local (metadata),
multicast, reserved and unspecified targets are always rejected; loopback and
private ranges are rejected for non-admin owners or when
EMAIL_BLOCK_PRIVATE_IPS=true, and stay allowed for admins (local-first: a LAN
Dovecot is a normal setup). Unresolved names are rejected before persistence;
live connections also validate and pin their own resolved addresses.
"""
import socket
import unittest.mock as mock
import pytest
from src.url_safety import check_outbound_host, connect_outbound_tcp, OutboundAddressBlocked
# ── helper policy table ──────────────────────────────────────────────────────
@pytest.mark.parametrize("host", [
"169.254.169.254",
"[::ffff:169.254.169.254]",
"::ffff:169.254.169.254",
"fe80::1",
"0.0.0.0",
"224.0.0.1",
"100.100.100.200",
"[100.100.100.200]",
])
def test_always_blocked_addresses(host):
ok, reason = check_outbound_host(host)
assert ok is False, (host, reason)
ok, reason = check_outbound_host(host, block_private=False)
assert ok is False
@pytest.mark.parametrize("host", [
"127.0.0.1",
"::1",
"[::1]",
"10.0.0.5",
"192.168.1.10",
"100.64.1.1",
"localhost",
"LOCALHOST.",
"mail.localhost",
"ip6-localhost",
])
def test_private_and_loopback_gated_by_block_private(host):
ok, _ = check_outbound_host(host, block_private=True)
assert ok is False, host
ok, _ = check_outbound_host(host, block_private=False)
assert ok is True, host
@pytest.mark.parametrize("host", ["host:993", "http://mail.example.com", "user@mail.example.com", "mail.example.com/", "a b", "", " "])
def test_non_bare_hosts_rejected(host):
ok, _ = check_outbound_host(host, resolver=lambda h: ["93.184.216.34"])
assert ok is False, host
def test_nonstring_rejected():
assert check_outbound_host(None)[0] is False # type: ignore[arg-type]
assert check_outbound_host(123)[0] is False # type: ignore[arg-type]
def test_resolved_name_is_classified():
ok, reason = check_outbound_host("metadata.google.internal", resolver=lambda h: ["169.254.169.254"])
assert ok is False and "link-local" in reason
ok, _ = check_outbound_host("mail.corp", block_private=True, resolver=lambda h: ["10.1.2.3"])
assert ok is False
ok, _ = check_outbound_host("mail.corp", block_private=False, resolver=lambda h: ["10.1.2.3"])
assert ok is True
ok, _ = check_outbound_host("imap.example.org", block_private=True, resolver=lambda h: ["93.184.216.34"])
assert ok is True
def test_unresolved_policy():
def _nx(h):
raise OSError("Name or service not known")
assert check_outbound_host("nope.invalid", resolver=_nx)[0] is False
assert check_outbound_host("nope.invalid", resolver=lambda h: [])[0] is False
# ── route wiring ─────────────────────────────────────────────────────────────
def _route(router, path, method):
for r in router.routes:
if r.path == path and method in getattr(r, "methods", set()):
return r.endpoint
raise AssertionError(f"route not found: {method} {path}")
def _make_orm_db():
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
Factory = sessionmaker(bind=engine)
return Factory
def _set_admin(monkeypatch, is_admin: bool):
monkeypatch.setattr("src.tool_security.owner_is_admin_or_single_user", lambda owner: is_admin)
monkeypatch.delenv("EMAIL_BLOCK_PRIVATE_IPS", raising=False)
monkeypatch.delenv("EMAIL_ALLOW_PRIVATE_IPS", raising=False)
class _Req:
def __init__(self, body):
self._body = body
async def json(self):
return self._body
@pytest.mark.asyncio
async def test_test_connection_rejects_loopback_for_non_admin_before_any_socket(monkeypatch):
import routes.email_routes as email_routes
_set_admin(monkeypatch, False)
router = email_routes.setup_email_routes()
test_conn = _route(router, "/api/email/accounts/test", "POST")
body = {
"imap_host": "127.0.0.1", "imap_port": 11434, "imap_user": "x", "imap_password": "x",
"imap_starttls": False,
"smtp_host": "169.254.169.254", "smtp_port": 80, "smtp_security": "none",
}
with mock.patch("socket.socket") as create_socket:
result = await test_conn(req=_Req(body), owner="regular-user")
assert result["ok"] is False
assert ("Rejected IMAP host" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
assert ("Rejected SMTP host" in result["smtp"]["error"] or "server address is not allowed" in result["smtp"]["error"])
create_socket.assert_not_called()
@pytest.mark.asyncio
async def test_test_connection_metadata_ip_rejected_even_for_admin(monkeypatch):
import routes.email_routes as email_routes
_set_admin(monkeypatch, True)
router = email_routes.setup_email_routes()
test_conn = _route(router, "/api/email/accounts/test", "POST")
body = {"imap_host": "169.254.169.254", "imap_port": 80, "imap_user": "x", "imap_password": "x", "imap_starttls": True}
with mock.patch("socket.socket") as create_socket:
result = await test_conn(req=_Req(body), owner="admin")
assert result["ok"] is False
assert ("link-local" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
create_socket.assert_not_called()
@pytest.mark.asyncio
async def test_test_connection_admin_may_use_loopback(monkeypatch):
"""Local-first: an admin's Dovecot on localhost keeps working."""
import routes.email_routes as email_routes
_set_admin(monkeypatch, True)
router = email_routes.setup_email_routes()
test_conn = _route(router, "/api/email/accounts/test", "POST")
body = {"imap_host": "127.0.0.1", "imap_port": 31143, "imap_user": "x", "imap_password": "x", "imap_starttls": False}
with mock.patch("routes.email_routes._open_imap_connection", return_value=mock.MagicMock()) as imap_open:
result = await test_conn(req=_Req(body), owner="admin")
assert result["imap"]["ok"] is True
imap_open.assert_called_once()
@pytest.mark.asyncio
async def test_create_account_rejects_metadata_host_before_db(monkeypatch):
import routes.email_routes as email_routes
_set_admin(monkeypatch, True)
router = email_routes.setup_email_routes()
create = _route(router, "/api/email/accounts", "POST")
def _no_db():
raise AssertionError("DB must not be touched for a rejected host")
with mock.patch("core.database.SessionLocal", _no_db):
result = await create(
{"name": "Backup", "imap_host": "169.254.169.254", "imap_user": "u", "imap_password": "p"},
owner="admin",
)
assert result["ok"] is False
assert "Rejected IMAP host" in result["error"]
@pytest.mark.asyncio
async def test_create_account_rejects_private_host_for_non_admin(monkeypatch):
import routes.email_routes as email_routes
_set_admin(monkeypatch, False)
router = email_routes.setup_email_routes()
create = _route(router, "/api/email/accounts", "POST")
with mock.patch("core.database.SessionLocal", _make_orm_db()):
result = await create(
{"name": "Scan", "imap_host": "93.184.216.34", "smtp_host": "10.0.0.5", "imap_user": "u", "imap_password": "p"},
owner="regular-user",
)
assert result["ok"] is False
assert "Rejected SMTP host" in result["error"]
@pytest.mark.asyncio
async def test_create_account_admin_private_host_allowed_unless_locked_down(monkeypatch):
import routes.email_routes as email_routes
_set_admin(monkeypatch, True)
router = email_routes.setup_email_routes()
create = _route(router, "/api/email/accounts", "POST")
payload = {"name": "LAN", "imap_host": "192.168.1.10", "imap_user": "u", "imap_password": "p"}
with mock.patch("core.database.SessionLocal", _make_orm_db()):
result = await create(dict(payload), owner="admin")
assert result["ok"] is True
monkeypatch.setenv("EMAIL_BLOCK_PRIVATE_IPS", "true")
with mock.patch("core.database.SessionLocal", _make_orm_db()):
result = await create(dict(payload), owner="admin")
assert result["ok"] is False
assert "Rejected IMAP host" in result["error"]
@pytest.mark.asyncio
async def test_update_account_rejects_bad_host_and_keeps_row(monkeypatch):
import routes.email_routes as email_routes
from core.database import EmailAccount
_set_admin(monkeypatch, False)
Factory = _make_orm_db()
db = Factory()
db.add(EmailAccount(id="acct-1", owner="regular-user", name="Mine", imap_host="imap.gmail.com", imap_port=993))
db.commit()
db.close()
router = email_routes.setup_email_routes()
update = _route(router, "/api/email/accounts/{account_id}", "PUT")
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._assert_owns_account", lambda account_id, owner: None):
result = await update("acct-1", {"imap_host": "127.0.0.1", "imap_port": 8000}, owner="regular-user")
assert result["ok"] is False
assert "Rejected IMAP host" in result["error"]
db = Factory()
try:
assert db.get(EmailAccount, "acct-1").imap_host == "imap.gmail.com"
finally:
db.close()
@pytest.mark.asyncio
async def test_update_config_rejects_bad_host(monkeypatch):
import routes.email_routes as email_routes
_set_admin(monkeypatch, False)
router = email_routes.setup_email_routes()
update_cfg = _route(router, "/api/email/config", "PUT")
def _no_db():
raise AssertionError("DB must not be touched for a rejected host")
with mock.patch("core.database.SessionLocal", _no_db), \
mock.patch("routes.email_routes._load_settings", _no_db):
result = await update_cfg({"smtp_host": "10.0.0.1", "smtp_port": 6379}, account_id=None, owner="regular-user")
assert result["ok"] is False
assert "Rejected SMTP host" in result["error"]
# ── adversarial runtime & connection-time tests ─────────────────────────────
def test_connect_outbound_tcp_blocks_dns_rebinding():
with mock.patch("socket.getaddrinfo", return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 993))]):
with pytest.raises(OutboundAddressBlocked) as exc_info:
connect_outbound_tcp("rebind.example.org", 993, block_private=False)
assert "link-local" in str(exc_info.value)
def test_connect_outbound_tcp_multiple_ips_fails_closed():
mixed_addrs = [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 993)),
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.1", 993)),
]
with mock.patch("socket.getaddrinfo", return_value=mixed_addrs):
with pytest.raises(OutboundAddressBlocked) as exc_info:
connect_outbound_tcp("dual.example.org", 993, block_private=True)
assert "private/shared/loopback" in str(exc_info.value)
@pytest.mark.asyncio
async def test_test_connection_endpoint_dns_rebinding_blocked(monkeypatch):
"""If DNS changes between save-check and connection, connect-time validation stops it."""
import routes.email_routes as email_routes
_set_admin(monkeypatch, False)
router = email_routes.setup_email_routes()
test_conn = _route(router, "/api/email/accounts/test", "POST")
body = {
"imap_host": "rebind.example.org", "imap_port": 993, "imap_user": "u", "imap_password": "p",
"imap_starttls": False,
}
rebinding_addrs = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 993))]
with mock.patch("socket.getaddrinfo", return_value=rebinding_addrs), \
mock.patch("socket.socket") as create_socket:
result = await test_conn(req=_Req(body), owner="regular-user")
assert result["ok"] is False
assert ("Rejected IMAP host" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
create_socket.assert_not_called()
@pytest.mark.asyncio
async def test_runtime_imap_connect_blocks_preexisting_db_row(monkeypatch):
"""A database row with private host (preexisting, restored, or DB-injected) fails closed at runtime."""
import routes.email_helpers as email_helpers
from core.database import EmailAccount
_set_admin(monkeypatch, False)
Factory = _make_orm_db()
db = Factory()
db.add(EmailAccount(id="acct-malicious", owner="regular-user", name="Evil", imap_host="127.0.0.1", imap_port=993, imap_user="u", imap_password="p"))
db.commit()
db.close()
with mock.patch("core.database.SessionLocal", Factory):
with pytest.raises(OutboundAddressBlocked) as exc_info:
email_helpers._imap_connect("acct-malicious", owner="regular-user")
assert "private/shared/loopback" in str(exc_info.value)
def test_runtime_send_smtp_message_blocks_metadata_host(monkeypatch):
"""_send_smtp_message fails closed on cloud metadata destination."""
import routes.email_helpers as email_helpers
_set_admin(monkeypatch, True)
cfg = {
"smtp_host": "169.254.169.254",
"smtp_port": 25,
"owner": "admin",
}
with pytest.raises(OutboundAddressBlocked) as exc_info:
email_helpers._send_smtp_message(cfg, "from@example.com", ["to@example.com"], b"data")
assert "link-local" in str(exc_info.value)
def test_ipv6_loopback_allowed_for_admin_blocked_for_non_admin():
mock_sock = mock.MagicMock()
with mock.patch("socket.socket", return_value=mock_sock):
sock = connect_outbound_tcp("::1", 993, block_private=False)
assert sock is mock_sock
mock_sock.connect.assert_called_with(("::1", 993, 0, 0))
with pytest.raises(OutboundAddressBlocked) as exc_info:
connect_outbound_tcp("::1", 993, block_private=True)
assert "private/shared/loopback" in str(exc_info.value)
def test_email_block_private_ips_env_overrides_admin(monkeypatch):
import routes.email_helpers as email_helpers
_set_admin(monkeypatch, True)
monkeypatch.setenv("EMAIL_BLOCK_PRIVATE_IPS", "true")
cfg = {
"smtp_host": "127.0.0.1",
"smtp_port": 25,
"owner": "admin",
}
with pytest.raises(OutboundAddressBlocked) as exc_info:
email_helpers._send_smtp_message(cfg, "from@example.com", ["to@example.com"], b"data")
assert "private/shared/loopback" in str(exc_info.value)
def test_tls_sni_preserved_during_safe_connection():
import routes.email_helpers as email_helpers
mock_sock = mock.MagicMock()
mock_ssl_sock = mock.MagicMock()
mock_ctx = mock.MagicMock()
mock_ctx.wrap_socket.return_value = mock_ssl_sock
with mock.patch("routes.email_helpers.connect_outbound_tcp", return_value=mock_sock):
imap = email_helpers._PolicyIMAP4_SSL.__new__(email_helpers._PolicyIMAP4_SSL)
imap._block_private = False
imap.host = "imap.secure.example.com"
imap.port = 993
imap.ssl_context = mock_ctx
res = imap._create_socket(10)
mock_ctx.wrap_socket.assert_called_with(mock_sock, server_hostname="imap.secure.example.com")
assert res is mock_ssl_sock
mock_smtp_sock = mock.MagicMock()
mock_ctx.wrap_socket.return_value = mock_smtp_sock
with mock.patch("routes.email_helpers.connect_outbound_tcp", return_value=mock_sock):
smtp = email_helpers._PolicySMTP_SSL.__new__(email_helpers._PolicySMTP_SSL)
smtp._block_private = False
smtp._host = "smtp.secure.example.com"
smtp.context = mock_ctx
smtp.source_address = None
res = smtp._get_socket("smtp.secure.example.com", 465, 10)
mock_ctx.wrap_socket.assert_called_with(mock_sock, server_hostname="smtp.secure.example.com")
assert res is mock_smtp_sock
@pytest.mark.asyncio
@pytest.mark.parametrize("route_name", ["create", "update", "config"])
@pytest.mark.parametrize("field", ["imap_host", "smtp_host"])
@pytest.mark.parametrize("is_admin", [False, True])
@pytest.mark.parametrize("answer", ["error", "empty", "garbage", "none"])
async def test_save_rejects_unresolved_hosts_before_persistence(monkeypatch, route_name, field, is_admin, answer):
import routes.email_routes as routes
import src.url_safety as safety
_set_admin(monkeypatch, is_admin)
def resolve(host):
if answer == "error":
raise RuntimeError("resolver-internal-detail")
return {"empty": [], "garbage": ["not-an-ip"], "none": None}[answer]
def no_persistence(*args, **kwargs):
raise AssertionError("Rejected mail host reached persistence")
monkeypatch.setattr(safety, "_default_resolver", resolve)
monkeypatch.setattr("core.database.SessionLocal", no_persistence)
monkeypatch.setattr(routes, "_load_settings", no_persistence)
monkeypatch.setattr(routes, "_save_settings", no_persistence)
monkeypatch.setattr(routes, "_assert_owns_account", lambda *a: None)
router = routes.setup_email_routes()
payload = {"name": "Pending DNS", field: "pending.example.invalid"}
owner = "admin" if is_admin else "regular-user"
if route_name == "create":
result = await _route(router, "/api/email/accounts", "POST")(payload, owner=owner)
elif route_name == "update":
result = await _route(router, "/api/email/accounts/{account_id}", "PUT")("acct", payload, owner=owner)
else:
result = await _route(router, "/api/email/config", "PUT")(payload, account_id=None, owner=owner)
assert result["ok"] is False
assert "does not resolve" in result["error"]
assert "resolver-internal-detail" not in result["error"]
assert "not-an-ip" not in result["error"]
def test_connect_pins_the_first_resolution_without_rebinding_lookup():
public = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 993))]
metadata = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 993))]
resolver = mock.Mock(side_effect=[public, metadata])
with mock.patch("socket.socket") as create_socket:
sock = connect_outbound_tcp("rebind.example.org", 993, resolver=resolver, block_private=True)
resolver.assert_called_once_with("rebind.example.org", 993, 0, socket.SOCK_STREAM)
sock.connect.assert_called_once_with(("93.184.216.34", 993))
def test_resolver_type_error_propagates_without_alternate_signature_retry():
resolver = mock.Mock(side_effect=TypeError("resolver-internal-bug"))
with mock.patch("socket.socket") as create_socket:
with pytest.raises(TypeError, match="resolver-internal-bug"):
connect_outbound_tcp("mail.example.org", 993, resolver=resolver)
resolver.assert_called_once_with("mail.example.org", 993, 0, socket.SOCK_STREAM)
create_socket.assert_not_called()
@pytest.mark.parametrize("port", [None, 0, -1, 65536, True, 25.5, "not-a-port"])
def test_invalid_tcp_port_is_rejected_before_resolution(port):
resolver = mock.Mock()
with pytest.raises(ValueError):
connect_outbound_tcp("mail.example.org", port, resolver=resolver)
resolver.assert_not_called()
def test_empty_resolver_iterator_fails_closed():
assert check_outbound_host("empty.example.org", resolver=lambda host: iter(()))[0] is False
def test_shared_space_metadata_endpoint_is_identified_correctly():
ok, reason = check_outbound_host("100.100.100.200", block_private=False)
assert ok is False
assert "metadata endpoint blocked" in reason
assert "link-local" not in reason
def test_importing_mail_helpers_does_not_replace_stdlib_clients():
import subprocess
import sys
subprocess.run([sys.executable, "-c", """
import imaplib, smtplib
original = (imaplib.IMAP4, imaplib.IMAP4_SSL, smtplib.SMTP, smtplib.SMTP_SSL)
import routes.email.email_helpers
assert original == (imaplib.IMAP4, imaplib.IMAP4_SSL, smtplib.SMTP, smtplib.SMTP_SSL)
"""], check=True, capture_output=True, text=True)
@pytest.mark.asyncio
@pytest.mark.parametrize("route_name", ["create", "update", "config"])
async def test_save_host_resolution_runs_off_the_event_loop(monkeypatch, route_name):
import threading
import routes.email_routes as routes
event_loop_thread = threading.get_ident()
def guard(*args):
assert threading.get_ident() != event_loop_thread
return "Host rejected"
monkeypatch.setattr(routes, "_check_mail_host", guard)
monkeypatch.setattr(routes, "_assert_owns_account", lambda *args: None)
router = routes.setup_email_routes()
payload = {"name": "Pending DNS", "imap_host": "pending.example.invalid"}
if route_name == "create":
result = await _route(router, "/api/email/accounts", "POST")(payload, owner="admin")
elif route_name == "update":
result = await _route(router, "/api/email/accounts/{account_id}", "PUT")("acct", payload, owner="admin")
else:
result = await _route(router, "/api/email/config", "PUT")(payload, account_id=None, owner="admin")
assert result == {"ok": False, "error": "Host rejected"}
+901 -6
View File
@@ -41,18 +41,18 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache():
assert "_libCacheWriteBack()" in src
def test_email_bulk_export_attachments_is_ui_only_selected_context():
def test_email_bulk_exports_messages_and_attachments_from_selected_context():
frontend = email_library_source()
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
export_src = frontend[
frontend.index("async function _exportSelectedAttachments()"):
frontend.index("\n}\n\n// URL-suffix helper", frontend.index("async function _exportSelectedAttachments()")) + 3
]
export_src = _function_source("_exportSelectedBundle")
payload_src = _function_source("_selectedEmailExportPayload")
assert "Export Emails" in frontend
assert "Email file (.eml)" in frontend
assert "_exportSelectedEmailPdf" in frontend
assert "Export Attachments" in frontend
assert "All in view" in frontend
assert "/api/email/attachments-download-bulk" in export_src
assert "${isEmailExport ? 'messages' : 'attachments'}-download-bulk" in export_src
assert "method: 'POST'" in export_src
assert "res.blob()" in export_src
assert "account_id: String(em.account_id || state._libAccountId || '')" in payload_src
@@ -63,6 +63,96 @@ def test_email_bulk_export_attachments_is_ui_only_selected_context():
assert "_fixture_email_read(uid, folder, owner)" in backend
assert "_is_likely_signature_image_attachment(att)" in backend
assert '@router.post("/messages-download-bulk")' in backend
assert '"(UID RFC822)"' in backend
assert "msg.as_bytes(policy=email_policy.SMTP)" in backend
assert 'media_type="message/rfc822"' in backend
assert '"X-Exported-Emails"' in backend
def test_email_print_pdf_export_reads_without_marking_read_and_sanitizes_body():
"""Print / save PDF export must use the browser print dialog (SAN-157).
The removed html2pdf library must not be loaded or referenced. Instead
the export uses a sandboxed iframe and window.print(), matching the
established pattern in document.js.
"""
frontend = email_library_source()
print_src = _function_source("_printSelectedEmails")
menu_src = js_function_source("_showBulkActionsMenu", frontend)
assert "mark_seen=false&full=true" in print_src
assert "_safeRenderEmailBody(data)" in print_src
assert "new DOMParser()" in print_src
assert "up to 20 emails" in print_src
assert "Print / save PDF" in menu_src
# SAN-157: must NOT reference the removed html2pdf library.
assert "html2pdf" not in print_src
assert "html2pdf" not in frontend
# Must use the browser print pattern with a sandboxed iframe.
assert "contentWindow.print()" in print_src
assert "sandbox" in print_src
assert "allow-same-origin" in print_src
assert "allow-modals" in print_src
assert "afterprint" in print_src
assert "email-browser-print-frame" in print_src
def test_email_eml_export_bounds_total_size():
"""The /messages-download-bulk endpoint must enforce a total size cap
to prevent unbounded memory growth when exporting many large messages."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert "max_total_bytes" in backend
assert "total_bytes" in backend
assert "Export too large" in backend
assert "status_code=413" in backend
def test_email_eml_export_validates_selection_count():
"""The /messages-download-bulk endpoint must reject oversized selections."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert 'len(messages) > 250' in backend
assert '"Too many emails selected"' in backend
def test_email_eml_export_rejects_non_numeric_remote_uids():
"""Non-numeric UIDs must not be passed to IMAP UID FETCH."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
# After fixture lookup fails, non-numeric UIDs are skipped.
assert 're.fullmatch(r"\\d+", uid)' in backend
def test_email_eml_export_handles_partial_batch_failures():
"""When an IMAP fetch batch returns partial results, exported/skipped
accounting must remain correct."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
# The skipped counter increments for UIDs not found in the batch response.
assert "skipped += len(batch) - len(found)" in backend
# Whole-connection failures count remaining unprocessed rows as skipped.
assert "skipped += remaining_rows" in backend
assert '"X-Exported-Emails"' in backend
assert '"X-Skipped-Emails"' in backend
def test_email_eml_export_enforces_account_ownership():
"""The /messages-download-bulk endpoint must assert account ownership."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert "_assert_owns_account(account_id, owner)" in backend
def test_email_eml_export_deduplicates_selection():
"""Duplicate items in selection must be skipped to avoid duplicate files."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert "seen_items" in backend
def test_fixture_tag_filter_supports_receipt_tag_flow():
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
@@ -171,3 +261,808 @@ def test_email_compose_can_attach_gallery_images():
assert "GalleryImage" in backend
assert "_gallery_image_path(img.filename)" in backend
assert "_stage_compose_file" in backend
class _MockImapConn:
def __init__(self, messages_by_uid: dict[str, bytes] | None = None, fetch_exc: Exception | None = None, fetch_cb=None):
self.messages = messages_by_uid or {}
self.fetch_exc = fetch_exc
self.fetch_cb = fetch_cb
self.fetch_calls: list = []
def select(self, folder, readonly=True):
return "OK", [b"1"]
def uid(self, command, *args):
if command == "FETCH":
self.fetch_calls.append(args)
if self.fetch_exc is not None:
raise self.fetch_exc
if self.fetch_cb:
return self.fetch_cb(args)
uid_set_b = args[0]
uid_set = uid_set_b.decode() if isinstance(uid_set_b, bytes) else str(uid_set_b)
uids = [u.strip() for u in uid_set.split(",") if u.strip()]
fetch_cmd = str(args[1]) if len(args) > 1 else ""
res = []
if "RFC822.SIZE" in fetch_cmd:
for u in uids:
if u in self.messages:
meta = f"1 (UID {u} RFC822.SIZE {len(self.messages[u])})".encode()
res.append(meta)
return "OK", res
for u in uids:
if u in self.messages:
meta = f"1 (UID {u} RFC822 {{{len(self.messages[u])}}}".encode()
res.append((meta, self.messages[u]))
res.append(b")")
return "OK", res
return "NO", []
def _create_test_app(monkeypatch, mailbox: _MockImapConn | None = None, assert_owns_fn=None):
from contextlib import contextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.email import email_routes as routes
conn = mailbox or _MockImapConn()
@contextmanager
def mock_imap(account_id=None, owner=""):
yield conn
monkeypatch.setattr(routes, "_start_poller", lambda: None)
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
monkeypatch.setattr(routes, "_imap", mock_imap)
if assert_owns_fn:
monkeypatch.setattr(routes, "_assert_owns_account", assert_owns_fn)
app = FastAPI()
app.include_router(routes.setup_email_routes())
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
return TestClient(app)
def test_messages_download_bulk_validates_empty_and_oversized_payload(monkeypatch):
client = _create_test_app(monkeypatch)
res_empty = client.post("/api/email/messages-download-bulk", json={"messages": []})
assert res_empty.status_code == 400
assert "No emails selected" in res_empty.text
oversized = [{"uid": str(i)} for i in range(251)]
res_over = client.post("/api/email/messages-download-bulk", json={"messages": oversized})
assert res_over.status_code == 400
assert "Too many emails selected" in res_over.text
def test_messages_download_bulk_single_message_eml_export(monkeypatch):
raw_rfc822 = b"From: sender@example.com\r\nTo: test-user\r\nSubject: Report\r\n\r\nReport content"
mailbox = _MockImapConn({"101": raw_rfc822})
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101", "subject": "Quarterly Report", "date": "2026-10-20"}]
})
assert res.status_code == 200
assert res.headers["content-type"].startswith("message/rfc822")
assert res.headers["x-exported-emails"] == "1"
assert res.headers["x-skipped-emails"] == "0"
assert "email.eml" in res.headers["content-disposition"]
assert res.content == raw_rfc822
def test_messages_download_bulk_multi_message_zip_export(monkeypatch):
import io
import zipfile
msg1 = b"From: a@example.com\r\nSubject: First\r\n\r\nOne"
msg2 = b"From: b@example.com\r\nSubject: Second\r\n\r\nTwo"
mailbox = _MockImapConn({"101": msg1, "102": msg2})
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [
{"uid": "101", "subject": "First Msg", "date": "2026-10-18"},
{"uid": "102", "subject": "Second Msg", "date": "2026-10-19"},
]
})
assert res.status_code == 200
assert res.headers["content-type"] == "application/zip"
assert res.headers["x-exported-emails"] == "2"
assert res.headers["x-skipped-emails"] == "0"
zf = zipfile.ZipFile(io.BytesIO(res.content))
namelist = zf.namelist()
assert len(namelist) == 2
assert any("101.eml" in name for name in namelist)
assert any("102.eml" in name for name in namelist)
def test_messages_download_bulk_partial_failure_and_duplicate_dedup(monkeypatch):
import io
import zipfile
msg1 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nExists body"
# UID 101 exists, UID 999 does not exist in mailbox
mailbox = _MockImapConn({"101": msg1})
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [
{"uid": "101", "subject": "Exists"},
{"uid": "999", "subject": "Missing"},
{"uid": "101", "subject": "Duplicate"}, # duplicate must be skipped
]
})
assert res.status_code == 200
assert res.headers["content-type"] == "application/zip"
assert res.headers["x-exported-emails"] == "1"
assert res.headers["x-skipped-emails"] == "2"
zf = zipfile.ZipFile(io.BytesIO(res.content))
assert len(zf.namelist()) == 1
def test_messages_download_bulk_enforces_account_ownership_guard(monkeypatch):
from fastapi import HTTPException
def mock_assert_owns(account_id, owner):
if account_id == "other-tenant-acct":
raise HTTPException(status_code=404, detail="Account not found")
client = _create_test_app(monkeypatch, assert_owns_fn=mock_assert_owns)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101", "account_id": "other-tenant-acct"}]
})
assert res.status_code == 404
assert "Account not found" in res.text
def test_messages_download_bulk_mid_batch_failure_accounting(monkeypatch):
"""When an exception occurs mid-batch after partial progress, already-exported
records must not be double-counted as skipped."""
class MidBatchFailImap:
def select(self, folder, readonly=True):
return "OK", [b"1"]
def uid(self, command, *args):
fetch_cmd = str(args[1]) if len(args) > 1 else ""
if "RFC822.SIZE" in fetch_cmd:
return "OK", [f"{i} (UID {i} RFC822.SIZE 100)".encode() for i in range(1, 11)]
records = []
for i in range(1, 5):
records.append((f"{i} (UID {i} RFC822 {{{10}}}".encode(), f"Subject: Msg {i}\r\n\r\nBody {i}".encode()))
class BadRecord:
def __bytes__(self):
raise RuntimeError("Corrupt memory buffer")
records.append((b"5 (UID 5 RFC822 {10}", BadRecord()))
return "OK", records
from contextlib import contextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.email import email_routes as routes
@contextmanager
def mock_imap(account_id=None, owner=""):
yield MidBatchFailImap()
monkeypatch.setattr(routes, "_start_poller", lambda: None)
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
monkeypatch.setattr(routes, "_imap", mock_imap)
app = FastAPI()
app.include_router(routes.setup_email_routes())
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
client = TestClient(app)
rows = [{"uid": str(i)} for i in range(1, 11)]
res = client.post("/api/email/messages-download-bulk", json={"messages": rows})
assert res.status_code == 200
exp = int(res.headers.get("x-exported-emails"))
skp = int(res.headers.get("x-skipped-emails"))
assert exp == 4
assert skp == 6
assert exp + skp == 10
def test_messages_download_bulk_size_cap_413_boundary(monkeypatch):
"""Cumulative export size exceeding max_total_bytes returns HTTP 413."""
class LargeMessageImap:
def select(self, folder, readonly=True):
return "OK", [b"1"]
def uid(self, command, *args):
raw_uids = args[0] if isinstance(args[0], str) else args[0].decode()
if "(UID RFC822.SIZE)" in str(args):
records = []
for u in raw_uids.split(","):
records.append(f"{u} (UID {u} RFC822.SIZE 10485760)".encode())
return "OK", records
records = []
big_chunk = b"X" * (10 * 1024 * 1024)
for u in raw_uids.split(","):
records.append((f"{u} (UID {u} RFC822".encode(), big_chunk))
return "OK", records
from contextlib import contextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.email import email_routes as routes
@contextmanager
def mock_imap(account_id=None, owner=""):
yield LargeMessageImap()
monkeypatch.setattr(routes, "_start_poller", lambda: None)
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
monkeypatch.setattr(routes, "_imap", mock_imap)
app = FastAPI()
app.include_router(routes.setup_email_routes())
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
client = TestClient(app)
# 52 messages of 10 MB = 520 MB > 512 MiB limit
payload_52 = [{"uid": str(i + 1), "subject": f"Big {i}"} for i in range(52)]
res = client.post("/api/email/messages-download-bulk", json={"messages": payload_52})
assert res.status_code == 413
assert res.json()["detail"] == "Export too large"
def test_messages_download_bulk_per_message_limit_413(monkeypatch):
"""A single oversized message (>64 MiB) triggers HTTP 413."""
class HugeSingleMsgImap:
def select(self, folder, readonly=True):
return "OK", [b"1"]
def uid(self, command, *args):
return "OK", [b"1 (UID 101 RFC822.SIZE 73400320)"]
from contextlib import contextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.email import email_routes as routes
@contextmanager
def mock_imap(account_id=None, owner=""):
yield HugeSingleMsgImap()
monkeypatch.setattr(routes, "_start_poller", lambda: None)
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
monkeypatch.setattr(routes, "_imap", mock_imap)
app = FastAPI()
app.include_router(routes.setup_email_routes())
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
client = TestClient(app)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101", "subject": "Huge Email"}]
})
assert res.status_code == 413
assert res.json()["detail"] == "Export too large"
def test_messages_download_bulk_account_ownership_memoization(monkeypatch):
"""Account ownership validation is memoized per distinct account_id to eliminate N+1 DB queries."""
owns_calls = []
def counting_assert_owns(account_id, owner):
owns_calls.append(account_id)
raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
msgs = {str(i): raw_rfc822 for i in range(1, 26)}
mailbox = _MockImapConn(msgs)
client = _create_test_app(monkeypatch, mailbox=mailbox, assert_owns_fn=counting_assert_owns)
# 25 messages all referencing the same account_id
payload = [{"uid": str(i), "account_id": "acc-shared"} for i in range(1, 26)]
res = client.post("/api/email/messages-download-bulk", json={"messages": payload})
assert res.status_code == 200
assert len(owns_calls) == 1
def test_messages_download_bulk_corrupt_zip_fails_closed(monkeypatch):
"""If ZIP construction fails (e.g. disk error), fail closed with 500 instead of returning corrupt archive."""
import zipfile
raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
mailbox = _MockImapConn({"101": raw_rfc822, "102": raw_rfc822})
orig_writestr = zipfile.ZipFile.writestr
def failing_writestr(self, zinfo_or_arcname, data=None, *args, **kwargs):
raise OSError("Disk write failed: No space left on device")
monkeypatch.setattr(zipfile.ZipFile, "writestr", failing_writestr)
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101"}, {"uid": "102"}]
})
assert res.status_code == 500
assert "Failed to create export archive" in res.json()["detail"]
def test_messages_download_bulk_preflight_no_fails_safe_no_body_fetch(monkeypatch):
"""When preflight returns NO, size safety is unknown: fail safely (502) and do not fetch bodies."""
mailbox = _MockImapConn({"101": b"dummy", "102": b"dummy"})
mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", [])
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]})
assert res.status_code == 502
assert "Unable to verify email size safety" in res.json()["detail"]
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
def test_messages_download_bulk_preflight_empty_missing_mail(monkeypatch):
"""When preflight returns empty data, all requested messages are missing on server: returns 404 with no body fetch."""
mailbox = _MockImapConn() # no messages
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]})
assert res.status_code == 404
assert "No selected emails could be exported" in res.json()["detail"]
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
def test_messages_download_bulk_preflight_malformed_fails_safe_no_body_fetch(monkeypatch):
"""When preflight returns records without RFC822.SIZE, fail safely (502) and do not fetch bodies."""
mailbox = _MockImapConn({"101": b"dummy"})
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 FLAGS (\\Seen))"]) if "RFC822.SIZE" in str(args) else ("OK", [])
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
assert res.status_code == 502
assert "Invalid email size metadata from server" in res.json()["detail"]
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
def test_messages_download_bulk_preflight_inconsistent_fails_safe_no_body_fetch(monkeypatch):
"""When preflight returns negative or corrupted size values, fail safely (502) and do not fetch bodies."""
mailbox = _MockImapConn({"101": b"dummy"})
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 RFC822.SIZE -50)"]) if "RFC822.SIZE" in str(args) else ("OK", [])
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
assert res.status_code == 502
assert "Invalid email size metadata from server" in res.json()["detail"]
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
def test_messages_download_bulk_preflight_partial_missing_omitted_from_body_fetch(monkeypatch):
"""When some requested UIDs do not exist on the server, omit them from body fetch and export verified items."""
msg1 = b"From: a@example.com\r\nSubject: Only One\r\n\r\nBody"
mailbox = _MockImapConn({"101": msg1}) # 102 does not exist on server
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101"}, {"uid": "102"}]
})
assert res.status_code == 200
assert res.headers["x-exported-emails"] == "1"
assert res.headers["x-skipped-emails"] == "1"
# Verify body fetch was only requested for 101, NOT 102
body_fetches = [call for call in mailbox.fetch_calls if "(UID RFC822)" in str(call)]
assert len(body_fetches) == 1
requested_uids = body_fetches[0][0].decode() if isinstance(body_fetches[0][0], bytes) else str(body_fetches[0][0])
assert requested_uids == "101"
assert "102" not in requested_uids
def test_messages_download_bulk_duplicate_imap_records_rejected(monkeypatch):
"""Duplicate IMAP FETCH response records for the same UID must be ignored.
They must not produce duplicate archive entries or corrupt accounting."""
import io
import zipfile
msg1 = b"From: a@example.com\r\nSubject: Unique\r\n\r\nBody 1"
mailbox = _MockImapConn({"101": msg1})
def custom_fetch(args):
fetch_cmd = str(args[1]) if len(args) > 1 else ""
if "RFC822.SIZE" in fetch_cmd:
return "OK", [b"1 (UID 101 RFC822.SIZE 100)", b"2 (UID 102 RFC822.SIZE 100)"]
# In body fetch, server sends duplicate records for 101, and nothing for 102
records = [
(b"1 (UID 101 RFC822 {50}", msg1),
b")",
(b"1 (UID 101 RFC822 {50}", msg1), # DUPLICATE RECORD
b")",
]
return "OK", records
mailbox.fetch_cb = custom_fetch
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101"}, {"uid": "102"}]
})
assert res.status_code == 200
assert res.headers["x-exported-emails"] == "1"
assert res.headers["x-skipped-emails"] == "1"
zf = zipfile.ZipFile(io.BytesIO(res.content))
assert len(zf.namelist()) == 1
assert "101.eml" in zf.namelist()[0]
def test_messages_download_bulk_single_message_preflight_failure_no_body_fetch(monkeypatch):
"""In single message mode, bad preflight or missing mail fails safely without body fetch."""
mailbox = _MockImapConn({"101": b"dummy"})
# Case A: preflight NO -> 502
mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", [])
client = _create_test_app(monkeypatch, mailbox=mailbox)
res_no = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
assert res_no.status_code == 502
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
# Case B: missing mail -> 404
mailbox.fetch_calls.clear()
mailbox.fetch_cb = lambda args: ("OK", []) if "RFC822.SIZE" in str(args) else ("OK", [])
res_miss = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
assert res_miss.status_code == 404
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
# Case C: malformed metadata -> 502
mailbox.fetch_calls.clear()
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 NO_SIZE)"]) if "RFC822.SIZE" in str(args) else ("OK", [])
res_mal = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
assert res_mal.status_code == 502
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
def test_messages_download_bulk_spooled_stream_lifecycle():
"""Verify that _iter_spooled closes the underlying SpooledTemporaryFile on completion and GeneratorExit."""
import tempfile
from routes.email.email_routes import setup_email_routes
# Test normal completion
f = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b")
f.write(b"Hello world payload")
f.seek(0)
# Re-create generator helper pattern
def _iter(file_obj, chunk_size=64):
try:
while True:
chunk = file_obj.read(chunk_size)
if not chunk:
break
yield chunk
finally:
file_obj.close()
gen = _iter(f)
chunks = list(gen)
assert b"".join(chunks) == b"Hello world payload"
assert f.closed
# Test early disconnect / cancellation (GeneratorExit)
f2 = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b")
f2.write(b"Chunk1Chunk2Chunk3")
f2.seek(0)
gen2 = _iter(f2, chunk_size=6)
next(gen2)
assert not f2.closed
gen2.close() # Simulates client disconnect / generator cancellation
assert f2.closed
def test_uid_and_size_from_fetch_meta_case_insensitivity_and_validation():
"""Unit test: _uid_from_fetch_meta and _size_from_fetch_meta case insensitivity and boundary validation."""
from routes.email.email_routes import _uid_from_fetch_meta, _size_from_fetch_meta
# 1. Standard uppercase UID and RFC822.SIZE
meta_upper = b"1 (UID 101 RFC822.SIZE 1234)"
assert _uid_from_fetch_meta(meta_upper) == "101"
assert _size_from_fetch_meta(meta_upper) == 1234
# 2. Lowercase uid and rfc822.size
meta_lower = b"1 (uid 202 rfc822.size 5678)"
assert _uid_from_fetch_meta(meta_lower) == "202"
assert _size_from_fetch_meta(meta_lower) == 5678
# 3. Mixed-case combinations
combos = [
(b"1 (Uid 303 Rfc822.Size 1000)", "303", 1000),
(b"1 (uID 404 RFC822.size 2000)", "404", 2000),
(b"1 (UID 505 rfc822.size 3000)", "505", 3000),
(b"1 (uid 606 RFC822.SIZE 4000)", "606", 4000),
(b"1 (uId 707 Rfc822.SIZE 5000)", "707", 5000),
(b"1 (FLAGS (\\Seen) uid 808 rfc822.Size 6000)", "808", 6000),
]
for meta_bytes, exp_uid, exp_sz in combos:
assert _uid_from_fetch_meta(meta_bytes) == exp_uid
assert _size_from_fetch_meta(meta_bytes) == exp_sz
# 4. Malformed metadata cases
assert _uid_from_fetch_meta(b"1 (rfc822.size 1234)") == ""
assert _size_from_fetch_meta(b"1 (uid 101 flags (\\seen))") is None
assert _uid_from_fetch_meta(b"1 (uid abc rfc822.size 1234)") == ""
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size invalid)") is None
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size -50)") is None
# 5. Boundary integrity (must not match substrings like UUID, GUID, NOT_RFC822.SIZE)
assert _uid_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") == ""
assert _size_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") is None
assert _uid_from_fetch_meta(b"1 (uid101 rfc822.size1234)") == ""
assert _size_from_fetch_meta(b"1 (uid101 rfc822.size1234)") is None
assert _uid_from_fetch_meta(b"") == ""
assert _size_from_fetch_meta(b"") is None
def test_messages_download_bulk_case_insensitive_imap_atoms(monkeypatch):
"""End-to-end: Bulk and single-message export with lowercase and mixed-case IMAP atoms."""
import io
import zipfile
msg101 = b"From: a@example.com\r\nSubject: Item 101\r\n\r\nBody 1"
msg102 = b"From: b@example.com\r\nSubject: Item 102\r\n\r\nBody 2"
mailbox = _MockImapConn({"101": msg101, "102": msg102})
def custom_fetch(args):
fetch_cmd = str(args[1]) if len(args) > 1 else ""
if "RFC822.SIZE" in fetch_cmd or "rfc822.size" in fetch_cmd.lower():
# Server returns lowercase/mixed-case atoms
return "OK", [
b"1 (uid 101 rfc822.size " + str(len(msg101)).encode() + b")",
b"2 (Uid 102 Rfc822.Size " + str(len(msg102)).encode() + b")",
]
# In body fetch, server returns mixed-case atoms
return "OK", [
(b"1 (uid 101 rfc822 {" + str(len(msg101)).encode(), msg101),
b")",
(b"2 (Uid 102 RFC822 {" + str(len(msg102)).encode(), msg102),
b")",
]
mailbox.fetch_cb = custom_fetch
client = _create_test_app(monkeypatch, mailbox=mailbox)
# 1. Bulk download (2 items) with lowercase / mixed-case IMAP responses
res_bulk = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101"}, {"uid": "102"}]
})
assert res_bulk.status_code == 200
assert res_bulk.headers["x-exported-emails"] == "2"
assert res_bulk.headers["x-skipped-emails"] == "0"
zf = zipfile.ZipFile(io.BytesIO(res_bulk.content))
assert len(zf.namelist()) == 2
# 2. Single message download fast path with lowercase IMAP response
mailbox.fetch_calls.clear()
res_single = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101"}]
})
assert res_single.status_code == 200
assert res_single.headers["content-type"].startswith("message/rfc822")
assert res_single.content == msg101
# 3. Lowercase malformed preflight fails safely with 502 and NO body fetch
mailbox.fetch_calls.clear()
mailbox.fetch_cb = lambda args: (
("OK", [b"1 (uid 101 rfc822.size NaN)"])
if "RFC822.SIZE" in str(args) or "rfc822.size" in str(args).lower()
else ("OK", [])
)
res_malformed = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101"}]
})
assert res_malformed.status_code == 502
assert "Invalid email size metadata from server" in res_malformed.json()["detail"]
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
def test_messages_download_bulk_multi_group_preflight_failure_propagates_502(monkeypatch):
"""A preflight safety failure in any group produces HTTP 502 for the entire request,
even when another group has already exported messages into the temporary ZIP.
Confirms temporary-file cleanup, zero body FETCH for the failed group, and both group orderings."""
import tempfile
msg101 = b"From: a@example.com\r\nSubject: Good\r\n\r\nBody 1"
spooled_instances: list[tempfile.SpooledTemporaryFile] = []
orig_spooled = tempfile.SpooledTemporaryFile
def tracking_spooled(*args, **kwargs):
inst = orig_spooled(*args, **kwargs)
spooled_instances.append(inst)
return inst
monkeypatch.setattr(tempfile, "SpooledTemporaryFile", tracking_spooled)
class MultiMailboxConn:
def __init__(self):
self.fetch_calls: list[tuple[str, tuple]] = []
self.current_folder = "INBOX"
def select(self, folder, readonly=True):
self.current_folder = str(folder)
return "OK", [b"1"]
def uid(self, command, *args):
self.fetch_calls.append((self.current_folder, args))
fetch_cmd = str(args[1]) if len(args) > 1 else ""
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
if "RFC822.SIZE" in fetch_cmd:
if "folder-fail" in self.current_folder:
return "NO", [] # Preflight failure for folder-fail
return "OK", [f"1 (UID {uid_set} RFC822.SIZE 100)".encode()]
# Body fetch
if "folder-fail" in self.current_folder:
return "NO", []
return "OK", [(f"1 (UID {uid_set} RFC822 {{100}}".encode(), msg101), b")"]
# 1. Successful group followed by failed preflight
conn1 = MultiMailboxConn()
client1 = _create_test_app(monkeypatch, mailbox=conn1)
spooled_instances.clear()
res1 = client1.post("/api/email/messages-download-bulk", json={
"messages": [
{"uid": "101", "folder": "folder-good"},
{"uid": "202", "folder": "folder-fail"},
]
})
assert res1.status_code == 502
assert "Unable to verify email size safety" in res1.json()["detail"]
# No RFC822 body FETCH for the failed group
failed_body_fetches1 = [
call for folder, call in conn1.fetch_calls
if "folder-fail" in folder and "(UID RFC822)" in str(call)
]
assert len(failed_body_fetches1) == 0
# Temporary file cleanup confirmed
assert len(spooled_instances) == 1
assert spooled_instances[0].closed
# 2. Failed preflight followed by successful group
conn2 = MultiMailboxConn()
client2 = _create_test_app(monkeypatch, mailbox=conn2)
spooled_instances.clear()
res2 = client2.post("/api/email/messages-download-bulk", json={
"messages": [
{"uid": "202", "folder": "folder-fail"},
{"uid": "101", "folder": "folder-good"},
]
})
assert res2.status_code == 502
assert "Unable to verify email size safety" in res2.json()["detail"]
# No RFC822 body FETCH for the failed group
failed_body_fetches2 = [
call for folder, call in conn2.fetch_calls
if "folder-fail" in folder and "(UID RFC822)" in str(call)
]
assert len(failed_body_fetches2) == 0
# Temporary file cleanup confirmed
assert len(spooled_instances) == 1
assert spooled_instances[0].closed
def test_messages_download_bulk_multi_group_both_successful(monkeypatch):
"""When both groups succeed preflight and body fetch, return 200 with all messages in ZIP."""
import io
import zipfile
msg101 = b"From: a@example.com\r\nSubject: Item 1\r\n\r\nBody 1"
msg202 = b"From: b@example.com\r\nSubject: Item 2\r\n\r\nBody 2"
class MultiMailboxConn:
def __init__(self):
self.current_folder = "INBOX"
def select(self, folder, readonly=True):
self.current_folder = str(folder)
return "OK", [b"1"]
def uid(self, command, *args):
fetch_cmd = str(args[1]) if len(args) > 1 else ""
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
msg = msg101 if "101" in uid_set else msg202
if "RFC822.SIZE" in fetch_cmd:
return "OK", [f"1 (UID {uid_set} RFC822.SIZE {len(msg)})".encode()]
return "OK", [(f"1 (UID {uid_set} RFC822 {{{len(msg)}}}".encode(), msg), b")"]
conn = MultiMailboxConn()
client = _create_test_app(monkeypatch, mailbox=conn)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [
{"uid": "101", "folder": "folder-1"},
{"uid": "202", "folder": "folder-2"},
]
})
assert res.status_code == 200
assert res.headers["x-exported-emails"] == "2"
assert res.headers["x-skipped-emails"] == "0"
zf = zipfile.ZipFile(io.BytesIO(res.content))
assert len(zf.namelist()) == 2
def test_messages_download_bulk_multi_group_genuinely_missing_message(monkeypatch):
"""Genuinely missing messages (unreturned by preflight) do not cause 502;
they are skipped while verified messages in any group are successfully exported."""
import io
import zipfile
msg101 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nBody"
class MissingMailboxConn:
def __init__(self):
self.fetch_calls: list = []
self.current_folder = "INBOX"
def select(self, folder, readonly=True):
self.current_folder = str(folder)
return "OK", [b"1"]
def uid(self, command, *args):
self.fetch_calls.append((self.current_folder, args))
fetch_cmd = str(args[1]) if len(args) > 1 else ""
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
if "RFC822.SIZE" in fetch_cmd:
# 101 exists on server; 999 is genuinely missing (unreported in size metadata)
res = []
if "101" in uid_set:
res.append(f"1 (UID 101 RFC822.SIZE {len(msg101)})".encode())
return "OK", res
# Body fetch only returns 101
return "OK", [(f"1 (UID 101 RFC822 {{{len(msg101)}}}".encode(), msg101), b")"]
conn = MissingMailboxConn()
client = _create_test_app(monkeypatch, mailbox=conn)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [
{"uid": "101", "folder": "folder-1"},
{"uid": "999", "folder": "folder-1"},
]
})
assert res.status_code == 200
assert res.headers["x-exported-emails"] == "1"
assert res.headers["x-skipped-emails"] == "1"
zf = zipfile.ZipFile(io.BytesIO(res.content))
assert len(zf.namelist()) == 1
# Verify 999 was never requested in body fetch
body_fetches = [
call for folder, call in conn.fetch_calls
if "(UID RFC822)" in str(call)
]
assert len(body_fetches) == 1
assert "999" not in str(body_fetches[0])
def test_email_print_browser_behaviors():
"""Real Chromium DOM: placeholder stripping, button removal, and partial failure."""
import shutil
import subprocess
if not shutil.which('node'):
import pytest
pytest.skip('Node is required')
probe = subprocess.run(['node', '-e', "require.resolve('playwright')"], cwd=_REPO, capture_output=True)
if probe.returncode:
import pytest
pytest.skip('Playwright is required')
result = subprocess.run(
['node', str(_REPO / 'tests/test_email_library_print_browser.cjs')],
cwd=_REPO,
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "ALL_BROWSER_TESTS_PASSED" in result.stdout
+438
View File
@@ -0,0 +1,438 @@
/**
* Browser print regression tests for Email Library print and PDF export.
*
* Requirements:
* - Node.js (v20+)
* - Playwright (`npm ci` or `npm install -D @playwright/test`)
* - Chromium browser binary:
* - Standard Playwright install: `npx playwright install chromium`
* - Or custom executable via env vars:
* - `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome`
* - `CHROME_PATH=/path/to/chrome`
* - Or custom browsers cache dir via:
* - `PLAYWRIGHT_BROWSERS_PATH=/path/to/cache`
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
let chromium;
try {
({ chromium } = require('playwright'));
} catch (err) {
if (err && err.code === 'MODULE_NOT_FOUND') {
console.error(
'Error: Playwright module not found.\n' +
'Please install dependencies by running:\n' +
' npm ci\n' +
'or ensure NODE_PATH contains the playwright package.'
);
process.exit(1);
}
throw err;
}
const ROOT = path.resolve(__dirname, '..');
// Discover available Chromium executable using standard Playwright resolution,
// custom environment variables, standard cache paths, or system binaries.
function findChromiumPath() {
// 1. Explicit environment variable overrides
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH && fs.existsSync(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH)) {
return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
}
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
return process.env.CHROME_PATH;
}
// 2. Playwright's configured/default executable path
try {
const defaultPath = chromium.executablePath();
if (defaultPath && fs.existsSync(defaultPath)) {
return defaultPath;
}
} catch (e) {
// chromium.executablePath() may throw if no default browser is installed
}
// 3. Inspect standard Playwright browser cache directories
const cacheDirs = [];
if (process.env.PLAYWRIGHT_BROWSERS_PATH) {
cacheDirs.push(process.env.PLAYWRIGHT_BROWSERS_PATH);
}
if (process.platform === 'win32') {
if (process.env.LOCALAPPDATA) {
cacheDirs.push(path.join(process.env.LOCALAPPDATA, 'ms-playwright'));
}
cacheDirs.push(path.join(os.homedir(), 'AppData', 'Local', 'ms-playwright'));
} else if (process.platform === 'darwin') {
cacheDirs.push(path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright'));
} else {
cacheDirs.push(path.join(os.homedir(), '.cache', 'ms-playwright'));
}
for (const cacheDir of cacheDirs) {
if (!fs.existsSync(cacheDir)) continue;
try {
const entries = fs.readdirSync(cacheDir).sort().reverse();
for (const entry of entries) {
const entryPath = path.join(cacheDir, entry);
const candidates = [
path.join(entryPath, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),
path.join(entryPath, 'chrome-linux64', 'chrome'),
path.join(entryPath, 'chrome-linux', 'chrome'),
path.join(entryPath, 'chrome-win64', 'chrome.exe'),
path.join(entryPath, 'chrome-win', 'chrome.exe'),
path.join(entryPath, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
} catch (e) {}
}
// 4. System browser fallbacks
const systemCandidates = process.platform === 'win32' ? [
path.join(process.env['PROGRAMFILES'] || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
] : [
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
];
for (const sc of systemCandidates) {
if (fs.existsSync(sc)) return sc;
}
return undefined;
}
async function run() {
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
if (url.pathname.startsWith('/api/email/read/')) {
const uid = url.pathname.replace('/api/email/read/', '');
if (uid === 'fail_uid' || uid === 'net_fail') {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Mailbox disconnected' }));
return;
}
if (uid === 'crash_network') {
req.destroy();
return;
}
const markSeen = url.searchParams.get('mark_seen');
if (markSeen !== 'false') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'mark_seen must be false' }));
return;
}
let body_html = '';
if (uid === 'img_test') {
body_html = [
'<div>',
'<p>Email with diverse images</p>',
'<!-- Inline image with alt text -->',
'<img src="cid:logo123" alt="Company Brand Logo">',
'<!-- Remote image with title -->',
'<img src="https://example.com/tracker.png" title="Tracking Pixel">',
'<!-- Image without alt or title -->',
'<img src="https://example.com/no-alt.jpg">',
'<!-- Interactive control that shouldn\'t be in print -->',
'<button type="button" class="btn-action">Interactive Action</button>',
'<!-- XSS payload -->',
'<script>window.__pwned = true;</script>',
'</div>'
].join('\n');
} else if (uid === 'long_msg') {
body_html = `<div><p>${'A'.repeat(5000)}</p></div>`;
} else {
body_html = `<div><p>Standard message body for ${uid}</p></div>`;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
uid: uid,
subject: `Subject ${uid}`,
from_name: 'Sender',
from_address: 'sender@example.com',
to: 'user@example.com',
date: '2026-10-20',
date_display: 'Oct 20, 2026',
body: `Plain text for ${uid}`,
body_html: body_html,
}));
return;
}
let filePath = path.join(ROOT, url.pathname);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath);
const mime = ext === '.js' ? 'application/javascript' : ext === '.html' ? 'text/html' : ext === '.css' ? 'text/css' : 'text/plain';
res.writeHead(200, { 'Content-Type': mime });
res.end(fs.readFileSync(filePath));
return;
}
res.writeHead(404);
res.end('Not found');
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const port = server.address().port;
const origin = `http://127.0.0.1:${port}`;
const launchOpts = { headless: true };
const chromePath = findChromiumPath();
if (chromePath) launchOpts.executablePath = chromePath;
let browser;
try {
browser = await chromium.launch(launchOpts);
} catch (launchErr) {
throw new Error(
`Failed to launch Chromium browser: ${launchErr.message}\n` +
'Missing or unexecutable Chromium binary.\n' +
'To install Playwright Chromium, run:\n' +
' npx playwright install chromium\n' +
'Or set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a valid Chromium executable.'
);
}
const context = await browser.newContext();
const page = await context.newPage();
const results = {};
try {
await page.goto(`${origin}/static/index.html`);
await page.evaluate(() => {
if (!document.getElementById('email-lib-bulk-actions')) {
const d = document.createElement('div'); d.id = 'email-lib-bulk-actions'; document.body.appendChild(d);
}
if (!document.getElementById('email-lib-selected-count')) {
const d = document.createElement('div'); d.id = 'email-lib-selected-count'; document.body.appendChild(d);
}
});
// TEST 1: Full Print Image Sanitization and Replacement
const printImageResult = await page.evaluate(async (base) => {
window.API_BASE = base;
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
stateMod.state._selectedUids = new Set(['img_test']);
stateMod.state._libEmails = [{ uid: 'img_test', subject: 'Image Test', folder: 'INBOX' }];
let iframePrintInvoked = false;
let printDocHtml = '';
let afterPrintTriggered = false;
let hasPlaceholderElement = false;
const observer = new MutationObserver(() => {
const frame = document.getElementById('email-browser-print-frame');
if (frame && frame.contentWindow && !frame._patched) {
frame._patched = true;
frame.contentWindow.print = function() {
iframePrintInvoked = true;
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
const body = frame.contentDocument ? frame.contentDocument.body : null;
hasPlaceholderElement = !!body?.querySelector('.email-inline-image-placeholder');
frame.contentWindow.dispatchEvent(new Event('afterprint'));
afterPrintTriggered = true;
};
}
});
observer.observe(document.body, { childList: true });
await mod._exportSelectedEmailPdf();
observer.disconnect();
const frameAfter = document.getElementById('email-browser-print-frame');
return {
iframePrintInvoked,
afterPrintTriggered,
frameRemovedAfterPrint: frameAfter === null,
hasLogoNotice: printDocHtml.includes('[Image: Company Brand Logo]'),
hasTrackerNotice: printDocHtml.includes('[Image: Tracking Pixel]'),
hasNoAltNotice: printDocHtml.includes('[Image omitted]'),
hasPlaceholderElement,
hasRemoteImageBlockedText: printDocHtml.includes('Remote image blocked'),
hasDownloadButton: printDocHtml.includes('email-inline-image-download-btn'),
hasInteractiveActionBtn: printDocHtml.includes('btn-action'),
hasXSS: printDocHtml.includes('<script>') || printDocHtml.includes('window.__pwned'),
};
}, origin);
results.printImageSanitization = printImageResult;
// TEST 2: Partial Failure - 1 failure out of 3 emails
const partialFailureResult = await page.evaluate(async (base) => {
window.API_BASE = base;
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
const messages = [
{ uid: 'msg1', subject: 'Email One', folder: 'INBOX' },
{ uid: 'fail_uid', subject: 'Failing Email', folder: 'INBOX' },
{ uid: 'msg2', subject: 'Email Two', folder: 'INBOX' },
];
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
stateMod.state._libEmails = messages;
let printInvoked = false;
let printDocHtml = '';
const observer = new MutationObserver(() => {
const frame = document.getElementById('email-browser-print-frame');
if (frame && frame.contentWindow && !frame._patched) {
frame._patched = true;
frame.contentWindow.print = function() {
printInvoked = true;
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
frame.contentWindow.dispatchEvent(new Event('afterprint'));
};
}
});
observer.observe(document.body, { childList: true });
await mod._exportSelectedEmailPdf();
observer.disconnect();
const toast = document.getElementById('toast')?.textContent || '';
return {
printInvoked,
toast,
hasMsg1: printDocHtml.includes('Subject msg1'),
hasMsg2: printDocHtml.includes('Subject msg2'),
hasOmittedNotice: printDocHtml.includes('1 selected email was unavailable and omitted from this print'),
};
}, origin);
results.partialFailureTest = partialFailureResult;
// TEST 3: All emails fail
const allFailedResult = await page.evaluate(async (base) => {
window.API_BASE = base;
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
const messages = [
{ uid: 'fail_uid', subject: 'Fail 1', folder: 'INBOX' },
{ uid: 'net_fail', subject: 'Fail 2', folder: 'INBOX' },
];
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
stateMod.state._libEmails = messages;
let printInvoked = false;
const observer = new MutationObserver(() => {
const frame = document.getElementById('email-browser-print-frame');
if (frame && frame.contentWindow && !frame._patched) {
frame._patched = true;
frame.contentWindow.print = function() {
printInvoked = true;
};
}
});
observer.observe(document.body, { childList: true });
let thrownError = null;
try {
await mod._exportSelectedEmailPdf();
} catch (err) {
thrownError = err.message || String(err);
}
observer.disconnect();
const frame = document.getElementById('email-browser-print-frame');
const toast = document.getElementById('toast')?.textContent || '';
return {
printInvoked,
frameOpened: frame !== null,
thrownError,
toast,
};
}, origin);
results.allFailedTest = allFailedResult;
// TEST 4: Idempotent Iframe Cleanup & Window Afterprint
const cleanupResult = await page.evaluate(async (base) => {
window.API_BASE = base;
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
stateMod.state._selectedUids = new Set(['msg1']);
stateMod.state._libEmails = [{ uid: 'msg1', subject: 'Cleanup Test', folder: 'INBOX' }];
const observer = new MutationObserver(() => {
const frame = document.getElementById('email-browser-print-frame');
if (frame && frame.contentWindow && !frame._patched) {
frame._patched = true;
frame.contentWindow.print = function() {
window.dispatchEvent(new Event('afterprint'));
};
}
});
observer.observe(document.body, { childList: true });
await mod._exportSelectedEmailPdf();
observer.disconnect();
const frameAfter = document.getElementById('email-browser-print-frame');
return {
frameRemovedViaWindowAfterprint: frameAfter === null,
};
}, origin);
results.cleanupTest = cleanupResult;
} finally {
await browser.close();
server.close();
}
// Assertions
const t1 = results.printImageSanitization;
if (!t1.iframePrintInvoked) throw new Error('Assertion failed: print not invoked');
if (!t1.frameRemovedAfterPrint) throw new Error('Assertion failed: frame not removed');
if (!t1.hasLogoNotice) throw new Error('Assertion failed: [Image: Company Brand Logo] missing');
if (!t1.hasTrackerNotice) throw new Error('Assertion failed: [Image: Tracking Pixel] missing');
if (!t1.hasNoAltNotice) throw new Error('Assertion failed: [Image omitted] missing');
if (t1.hasPlaceholderElement) throw new Error('Assertion failed: .email-inline-image-placeholder found in print doc');
if (t1.hasRemoteImageBlockedText) throw new Error('Assertion failed: Remote image blocked leaked into print doc');
if (t1.hasDownloadButton) throw new Error('Assertion failed: download button leaked into print doc');
if (t1.hasInteractiveActionBtn) throw new Error('Assertion failed: interactive button leaked into print doc');
if (t1.hasXSS) throw new Error('Assertion failed: script tag found');
const t2 = results.partialFailureTest;
if (!t2.printInvoked) throw new Error('Assertion failed: partial print was not invoked');
if (!t2.hasMsg1 || !t2.hasMsg2) throw new Error('Assertion failed: successful messages missing from print');
if (!t2.hasOmittedNotice) throw new Error('Assertion failed: omitted notice banner missing');
if (!t2.toast.includes('Prepared 2 emails for printing; 1 unavailable')) {
throw new Error('Assertion failed: toast text mismatch: ' + t2.toast);
}
const t3 = results.allFailedTest;
if (t3.printInvoked) throw new Error('Assertion failed: print should NOT be invoked when all fail');
if (t3.frameOpened) throw new Error('Assertion failed: print frame should NOT remain in DOM');
const t4 = results.cleanupTest;
if (!t4.frameRemovedViaWindowAfterprint) throw new Error('Assertion failed: top window afterprint did not clean up frame');
console.log('ALL_BROWSER_TESTS_PASSED');
}
run().catch(err => {
console.error('Browser test failed:', err);
process.exit(1);
});
@@ -0,0 +1,79 @@
"""Email list stale-while-revalidate (SWR) regression tests.
A plain list view must never block ~2-5 s on IMAP while we already hold a
recent snapshot or a durable index: it should return the snapshot immediately
(with ``sync.stale``) and refresh in the background. Blocking IMAP only remains
for a genuine cold start (no snapshot AND no indexed rows).
"""
import asyncio
import pathlib
from routes.email import email_routes as er
SRC = pathlib.Path(er.__file__).read_text(encoding="utf-8")
def _list_endpoint():
router = er.setup_email_routes()
for route in router.routes:
if getattr(route, "name", "") == "list_emails":
return route.endpoint
raise AssertionError("list_emails route not found")
def _call(endpoint, **over):
kwargs = dict(
folder="INBOX", limit=50, offset=0, filter="all", from_addr=None,
account_id=None, has_attachments=0, date_from=None, date_to=None,
cached_only=0, cache_bust=None, refresh=0, owner="pewds",
)
kwargs.update(over)
return asyncio.run(endpoint(**kwargs))
def test_list_returns_index_snapshot_with_a_stale_marker_without_blocking_on_imap(monkeypatch):
endpoint = _list_endpoint()
monkeypatch.setattr(
er, "_email_index_list",
lambda owner, account_id, folder, filter_, limit, offset, has_att: (
[{"uid": "11", "subject": "Indexed", "is_read": False}], 1, "2026-10-11T00:00:00Z",
),
)
# The synchronous path must never open IMAP; if it did the request would
# raise instead of returning the index snapshot immediately.
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(
AssertionError("SWR must not open IMAP when an index snapshot exists")))
result = _call(endpoint, cache_bust="123")
assert result["total"] == 1
assert [e["uid"] for e in result["emails"]] == ["11"]
assert result["sync"]["stale"] is True
assert result["sync"]["source"] in ("index", "cache_stale")
def test_cold_start_with_no_snapshot_and_no_index_is_not_marked_stale(monkeypatch):
# With neither a snapshot nor an index, SWR must NOT fire: the request
# falls through to the real (blocking) path, so the response is not a
# stale-marked snapshot.
endpoint = _list_endpoint()
monkeypatch.setattr(er, "_email_index_list", lambda *a, **k: ([], 0, None))
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(
RuntimeError("offline test")))
try:
result = _call(endpoint)
except Exception:
return # the cold path legitimately attempted real IMAP
assert isinstance(result, dict)
assert not (result.get("sync") or {}).get("stale")
def test_swr_contract_is_wired_into_the_list_path():
assert "_LIST_STALE" in SRC
assert "def _list_stale_put" in SRC
assert "def _schedule_list_refresh" in SRC
assert 'sync_meta["source"] = "cache_stale"' in SRC
assert 'sync_meta["stale"] = True' in SRC
# The manual-refresh contract the UI relies on must stay intact.
assert "manual_refresh = bool(refresh)" in SRC
assert "_invalidate_list_cache(account_id, folder)" in SRC
assert "cached_rows = {} if refresh else _email_index_rows" in SRC
@@ -79,3 +79,68 @@ async def test_inherited_authority_still_cannot_widen_to_ungranted_bash(tmp_path
assert pending.process_operation is None
_, result = await _dispatch(child, "bash", "printf approved-run", exact)
assert result.get("blocked") is True
@pytest.fixture
def agent_workspace():
import os
from src.constants import AGENT_WORKSPACE_DIR
os.makedirs(AGENT_WORKSPACE_DIR, exist_ok=True)
return os.path.realpath(AGENT_WORKSPACE_DIR)
def test_approval_without_a_workspace_seals_a_launch_in_the_agent_workspace(agent_workspace):
# Agent mode with no folder picked is the default. bash already runs in the
# agent workspace there (agent_cwd), so that is where its approval seals.
authority = _authority("", "get_workspace")
pending, _ = _approve(authority, "bash", "pwd")
bound = pending.process_operation
assert bound is not None
assert bound.launch is not None
assert bound.launch.scope.root.path == agent_workspace
async def test_approved_bash_without_a_workspace_runs_in_the_agent_workspace(agent_workspace):
authority = _authority("", "get_workspace")
_, exact = _approve(authority, "bash", "pwd")
description, result = await _dispatch(authority, "bash", "pwd", exact)
assert result.get("failure_kind") != "resource_identity_denied", result
assert result.get("exit_code") == 0, result
containment = result.get("containment") or {}
assert containment.get("workspace", agent_workspace) == agent_workspace, containment
async def test_inherited_authority_without_a_workspace_still_cannot_widen(agent_workspace):
from dataclasses import replace
child = replace(_authority("", "get_workspace"), inherited=True)
pending, exact = _approve(child, "bash", "pwd")
assert pending.process_operation is None
_, result = await _dispatch(child, "bash", "pwd", exact)
assert result.get("blocked") is True
async def test_approved_write_without_a_workspace_lands_in_the_agent_workspace(agent_workspace):
# write_file had the same gap: with no workspace the approval sealed no
# root, so "approve" ended in "no sealed resource identity".
import os
target = "approved-write-6635.txt"
content = f"{target}\nhello"
authority = _authority("", "get_workspace")
pending, exact = _approve(authority, "write_file", content)
assert pending.resource_operation is not None
_, result = await _dispatch(authority, "write_file", content, exact)
assert result.get("failure_kind") != "resource_identity_denied", result
written = os.path.join(agent_workspace, target)
try:
with open(written) as fh:
assert fh.read().strip() == "hello"
finally:
if os.path.exists(written):
os.remove(written)
+155
View File
@@ -76,3 +76,158 @@ def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
assert lookup(Query(), endpoint.base_url, 'alice', 'text-model') is None
assert lookup(Query(), endpoint.base_url, 'alice', 'openai/gpt-image-1') is None
assert lookup(Query(), 'https://unregistered.test/v1', 'alice', 'openai/gpt-5-image') is None
def _form_fields(content, content_type):
"""Parse simple multipart form fields (text parts only) from a request body."""
boundary = content_type.split('boundary=')[1].strip().strip('"')
fields = {}
for part in content.split(('--' + boundary).encode()):
if b'name="' not in part:
continue
head, _, body = part.partition(b'\r\n\r\n')
import re as _re
match = _re.search(rb'name="([^"]+)"', head)
if not match:
continue
fields[match.group(1).decode()] = body.rstrip(b'\r\n-').decode('latin1')
return fields
def _self_hosted_inpaint(monkeypatch, image, mask, body_extra, respond, include_mask=True):
base_url = 'http://100.69.120.65:8188/v1'
endpoint = SimpleNamespace(base_url=base_url, api_key='local', owner='alice')
monkeypatch.setattr(gallery, 'SessionLocal', lambda: SimpleNamespace(close=lambda: None))
monkeypatch.setattr(gallery, 'require_privilege', lambda *a: 'alice')
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *a: endpoint)
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *a, **k: (True, ''))
_real_client = httpx.AsyncClient
monkeypatch.setattr(httpx, 'AsyncClient',
lambda **kw: _real_client(transport=httpx.MockTransport(respond), **kw))
class Request:
async def json(self):
payload = {'_endpoint': base_url, 'image': png(image), 'prompt': 'do a thing'}
if include_mask:
payload['mask'] = png(mask)
payload.update(body_extra)
return payload
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
return asyncio.run(route.endpoint(Request()))
def test_self_hosted_inpaint_caps_oversize_and_restores_dimensions(monkeypatch):
seen = {}
def respond(request):
assert str(request.url) == 'http://100.69.120.65:8188/v1/images/edits'
seen.update(_form_fields(request.content, request.headers['content-type']))
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (768, 576), 'blue'))}]})
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (1600, 1200), 'red'),
Image.new('L', (1600, 1200), 255),
{'steps': 8}, respond)
# Working size is capped to a model-friendly size (<=768 long side, /16).
assert seen['size'] == '768x576'
# A requested step budget passes through up to the cap (default 8).
assert seen['steps'] == '8'
# The returned image keeps the original dimensions.
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (1600, 1200)
def test_self_hosted_inpaint_clamps_steps_to_the_cap(monkeypatch):
seen = {}
def respond(request):
seen.update(_form_fields(request.content, request.headers['content-type']))
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (640, 480), 'blue'))}]})
_self_hosted_inpaint(monkeypatch, Image.new('RGB', (640, 480), 'red'),
Image.new('L', (640, 480), 255), {'steps': 20}, respond)
# Over-cap requests are clamped to the configured maximum (default 8).
assert seen['steps'] == '8'
def test_self_hosted_inpaint_leaves_small_images_unchanged(monkeypatch):
seen = {}
def respond(request):
seen.update(_form_fields(request.content, request.headers['content-type']))
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (640, 480), 'green'))}]})
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (640, 480), 'red'),
Image.new('L', (640, 480), 255), {}, respond)
# Under the cap: dimensions pass through untouched; with no requested count
# sent the moderate default (4) is used.
assert seen['size'] == '640x480'
assert seen['steps'] == '4'
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (640, 480)
def test_self_hosted_inpaint_without_mask_uses_a_full_white_mask(monkeypatch):
"""A whole-image instruction edit may omit the mask; it must not raise
KeyError -> 400 (the health-audit regression)."""
seen = {}
mask_bytes = {}
def respond(request):
seen.update(_form_fields(request.content, request.headers['content-type']))
# The provider must receive a (full-white) mask, not a missing one.
import re as _re
parts = request.content.split(b'--')
for part in parts:
if b'name="mask"' in part:
head, _, body = part.partition(b'\r\n\r\n')
mask_bytes['png'] = body.rstrip(b'\r\n-')
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (32, 32), 'blue'))}]})
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (32, 32), 'red'),
None, {}, respond, include_mask=False)
assert 'image' in out, out
assert seen.get('size') == '32x32'
# The synthesized mask is image-sized and all-white (whole image editable).
mask_img = Image.open(io.BytesIO(mask_bytes['png'])).convert('L')
assert mask_img.size == (32, 32)
assert mask_img.getextrema() == (255, 255)
def test_openai_inpaint_without_mask_is_a_clean_400(monkeypatch):
"""OpenAI edits need a mask; a missing one must be a clean 400, not a
KeyError traceback."""
from fastapi import HTTPException
endpoint = SimpleNamespace(base_url='https://api.openai.com/v1', api_key='sk-x', owner='alice')
monkeypatch.setattr(gallery, 'SessionLocal', lambda: SimpleNamespace(close=lambda: None))
monkeypatch.setattr(gallery, 'require_privilege', lambda *a: 'alice')
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *a: endpoint)
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *a, **k: (True, ''))
class Request:
async def json(self):
return {'_endpoint': 'https://api.openai.com/v1',
'image': png(Image.new('RGB', (32, 32), 'red')), 'prompt': 'x'}
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
try:
asyncio.run(route.endpoint(Request()))
raise AssertionError('expected a 400 for a missing mask')
except HTTPException as exc:
assert exc.status_code == 400
assert 'mask' in str(exc.detail).lower()
def test_self_hosted_inpaint_forwards_request_id(monkeypatch):
"""A client-supplied request_id is forwarded to the provider so the client
can poll GET /api/image/edit-progress for step progress."""
seen = {}
def respond(request):
seen.update(_form_fields(request.content, request.headers.get('content-type', '')))
return httpx.Response(200, json={'model': 'qwen-image-edit-2511',
'data': [{'b64_json': png(Image.new('RGBA', (32, 32), 'red'))}]})
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (32, 32), 'red'),
Image.new('L', (32, 32), 255), {'request_id': 'job-777'}, respond)
assert 'image' in out, out
assert seen.get('request_id') == 'job-777'
+120
View File
@@ -0,0 +1,120 @@
"""Tests for the image-op progress contract (HARNESS-owned).
The 7011 side accepts an optional client-supplied ``request_id`` on heavy image
ops and exposes ``GET /api/image/edit-progress`` which proxies the provider's
``/v1/images/progress/{request_id}`` route. When the provider has no such route
the endpoint returns ``{"available": false}`` so the client keeps an
indeterminate bar.
"""
import asyncio
from types import SimpleNamespace
import httpx
import routes.gallery_routes as gallery
def _route(path):
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path)
class _Req:
def __init__(self, query):
self.query_params = query
def _fake_db():
class Db:
def close(self):
pass
def query(self, *args, **kwargs):
return self
def filter(self, *args, **kwargs):
return self
def first(self):
return None
return Db()
def test_edit_progress_requires_request_id(monkeypatch):
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
route = _route("/api/image/edit-progress")
try:
asyncio.run(route.endpoint(_Req({})))
except Exception as exc: # FastAPI HTTPException
assert getattr(exc, "status_code", None) == 400
else:
raise AssertionError("expected a 400 for a missing request_id")
def test_edit_progress_rejects_an_unsafe_request_id(monkeypatch):
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
route = _route("/api/image/edit-progress")
try:
asyncio.run(route.endpoint(_Req({"request_id": "../secret"})))
except Exception as exc:
assert getattr(exc, "status_code", None) == 400
else:
raise AssertionError("expected a 400 for an unsafe request_id")
def test_edit_progress_returns_provider_payload(monkeypatch):
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
seen = {}
def respond(request):
seen["url"] = str(request.url)
# Live seneca contract stages: loading | editing | compositing | done | error.
return httpx.Response(
200,
json={"stage": "editing", "step": 3, "steps": 8, "eta_s": 42.0, "progress": 0.375},
)
client = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: client(transport=httpx.MockTransport(respond), **k))
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc-123"})))
assert out["available"] is True
assert out["stage"] == "editing" # passed through unchanged
assert out["step"] == 3 and out["steps"] == 8
assert seen["url"].endswith("/images/progress/abc-123")
def test_edit_progress_available_false_when_provider_has_no_route(monkeypatch):
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
client = httpx.AsyncClient
monkeypatch.setattr(
httpx, "AsyncClient",
lambda **k: client(transport=httpx.MockTransport(lambda req: httpx.Response(404)), **k),
)
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc"})))
assert out["available"] is False
def test_edit_progress_available_false_when_unreachable(monkeypatch):
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
class Boom:
async def __aenter__(self):
raise httpx.ConnectError("nope")
async def __aexit__(self, *a):
return False
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: Boom())
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc"})))
assert out["available"] is False
+1 -1
View File
@@ -6,7 +6,7 @@ EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
def test_history_snapshot_captures_document_and_layer_state():
snapshot = EDITOR[EDITOR.index("function _snapshotState()") : EDITOR.index("function _saveState(")]
snapshot = EDITOR[EDITOR.index("function _snapshotState(options = {})") : EDITOR.index("function _saveState(")]
assert "activeLayerId: state.activeLayerId" in snapshot
assert "nextLayerId: state.nextLayerId" in snapshot
+201
View File
@@ -0,0 +1,201 @@
"""Heavy in-process image ops must not load GB-scale models by default.
remove-bg / SAM mask / Real-ESRGAN upscale+denoise used to load rembg / torch
models inside the web process and OOM-killed the host. They now default to a
graceful 503, proxy to the seneca remote ops service when
``ODYSSEUS_IMAGE_OPS_BASE`` is set (translate to the /v1/images/* contract), and
only run in-process when explicitly enabled via ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS.
"""
import asyncio
import httpx
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.gallery import gallery_routes as gr
HEAVY_ROUTES = [
("/api/image/remove-bg", {"image": "x"}, "Background removal"),
("/api/image/mask", {"image": "x"}, "Smart mask"),
("/api/image/upscale-local", {"image": "x"}, "AI upscale"),
("/api/image/denoise", {"image": "x"}, "AI denoise"),
]
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
monkeypatch.setattr(gr, "require_privilege", lambda *a, **k: None)
monkeypatch.delenv("ODYSSEUS_IMAGE_OPS_BASE", raising=False)
monkeypatch.delenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", raising=False)
@pytest.fixture()
def client():
app = FastAPI()
app.include_router(gr.setup_gallery_routes())
return TestClient(app)
def test_local_image_models_disabled_by_default():
assert gr._local_image_models_enabled() is False
@pytest.mark.parametrize("value", ["1", "true", "Yes", "ON"])
def test_local_image_models_enabled_env(monkeypatch, value):
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", value)
assert gr._local_image_models_enabled() is True
@pytest.mark.parametrize("value", ["", "0", "no", "off", "maybe"])
def test_local_image_models_disabled_env(monkeypatch, value):
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", value)
assert gr._local_image_models_enabled() is False
def test_rss_mb_is_positive():
assert gr._rss_mb() > 0
def test_route_heavy_image_op_returns_none_when_enabled(monkeypatch):
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", "1")
assert asyncio.run(gr._route_heavy_image_op("/api/image/denoise", {}, label="x")) is None
@pytest.mark.parametrize("path,body,label", HEAVY_ROUTES)
def test_heavy_ops_503_when_disabled(client, path, body, label):
resp = client.post(path, json=body)
assert resp.status_code == 503
detail = resp.json().get("detail", "")
assert "temporarily unavailable" in detail
assert label in detail
def test_remove_bg_disabled_does_not_import_rembg(monkeypatch):
import sys
monkeypatch.setitem(sys.modules, "rembg", None)
app = FastAPI()
app.include_router(gr.setup_gallery_routes())
client = TestClient(app)
resp = client.post("/api/image/remove-bg", json={"image": "x"})
assert resp.status_code == 503
class _FakeResponse:
def __init__(self, payload, status_code=200):
self._payload = payload
self.status_code = status_code
def json(self):
return self._payload
class _FakeAsyncClient:
calls = []
response = _FakeResponse({})
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def post(self, url, json=None, files=None, data=None):
_FakeAsyncClient.calls.append((url, {"json": json, "files": files, "data": data}))
return _FakeAsyncClient.response
def _client():
app = FastAPI()
app.include_router(gr.setup_gallery_routes())
return TestClient(app)
def test_remove_bg_proxies_to_remote_contract(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.calls = []
_FakeAsyncClient.response = _FakeResponse(
{"data": [{"b64_json": "RB", "mask_b64": "M", "bbox": [0, 0, 1, 1]}], "model": "birefnet"}
)
resp = _client().post("/api/image/remove-bg", json={"image": "AAAA"})
assert resp.status_code == 200
assert resp.json()["image"] == "RB"
url, kw = _FakeAsyncClient.calls[-1]
assert url == "http://seneca.test:8188/v1/images/remove-background"
assert "image" in kw["files"]
def test_mask_proxies_to_remote_contract(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.calls = []
_FakeAsyncClient.response = _FakeResponse(
{"masks": [{"b64_json": "MASK", "bbox": [10, 20, 30, 40], "score": 0.9, "label": "person"}],
"model": "sam+grounding"}
)
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
assert resp.status_code == 200
body = resp.json()
assert body["mask"] == "MASK"
# remote [x,y,w,h] -> app [x1,y1,x2,y2]
assert body["bbox"] == [10, 20, 40, 60]
url, kw = _FakeAsyncClient.calls[-1]
assert url == "http://seneca.test:8188/v1/images/mask"
assert kw["data"]["prompt"] == "person"
def test_mask_picks_top_score_not_first(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _FakeResponse(
{"masks": [
{"b64_json": "LOW", "bbox": [0, 0, 1, 1], "score": 0.2},
{"b64_json": "HIGH", "bbox": [0, 0, 2, 2], "score": 0.95},
{"b64_json": "MID", "bbox": [0, 0, 3, 3], "score": 0.5},
], "model": "sam+grounding"}
)
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
assert resp.status_code == 200
assert resp.json()["mask"] == "HIGH"
def test_mask_box_is_sent_as_xywh(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.calls = []
_FakeAsyncClient.response = _FakeResponse({"masks": [{"b64_json": "M", "bbox": [0, 0, 1, 1], "score": 1.0}]})
resp = _client().post("/api/image/mask", json={"image": "AAAA", "box": [10, 20, 40, 60]})
assert resp.status_code == 200
_, kw = _FakeAsyncClient.calls[-1]
assert kw["data"]["box"] == "10,20,30,40"
def test_mask_remote_empty_masks_is_200_nothing_selected(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _FakeResponse({"masks": [], "model": "sam+grounding"})
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
assert resp.status_code == 200
body = resp.json()
assert body["mask"] is None
assert body["masks"] == []
assert "Nothing selected" in body["error"]
def test_remote_proxy_surfaces_error_status(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _FakeResponse({}, status_code=500)
resp = _client().post("/api/image/remove-bg", json={"image": "AAAA"})
assert resp.status_code == 503
assert "temporarily unavailable" in resp.json().get("detail", "")
@@ -0,0 +1,124 @@
"""Image endpoint fallback: image models on mixed (non image-only) endpoints.
Chat image generation resolves its model by name across every enabled endpoint,
so a ``qwen-image-edit`` / ``gpt-image-1`` model registered on a shared
OpenAI-compatible gateway works there. The editor's generative ops used a
stricter image-only lookup and reported "No image generation endpoint
configured" on those same setups. These tests pin the fallback that makes the
editor find the same endpoint.
"""
from types import SimpleNamespace
import routes.gallery_routes as gallery_routes
class _Predicate:
def __init__(self, check):
self._check = check
def __call__(self, row):
return self._check(row)
def __or__(self, other):
return _Predicate(lambda row: self(row) or other(row))
class _Column:
def __init__(self, name):
self.name = name
def __eq__(self, value):
return _Predicate(lambda row: getattr(row, self.name) == value)
class _ModelEndpoint:
base_url = _Column("base_url")
model_type = _Column("model_type")
is_enabled = _Column("is_enabled")
owner = _Column("owner")
class _Query:
def __init__(self, rows):
self._rows = list(rows)
def filter(self, *predicates):
self._rows = [row for row in self._rows if all(pred(row) for pred in predicates)]
return self
def all(self):
return list(self._rows)
class _DB:
def __init__(self, rows):
self._rows = rows
def query(self, model):
assert model is _ModelEndpoint
return _Query(self._rows)
def _ep(base_url, owner=None, *, enabled=True, model_type="chat", models=None):
return SimpleNamespace(
base_url=base_url,
owner=owner,
is_enabled=enabled,
model_type=model_type,
api_key="sk-secret",
cached_models=models or [],
pinned_models=[],
)
def _patch_model(monkeypatch):
monkeypatch.setattr(gallery_routes, "ModelEndpoint", _ModelEndpoint)
def test_mixed_endpoint_with_image_model_is_found(monkeypatch):
_patch_model(monkeypatch)
rows = [_ep("https://gateway.example.com/v1", models=["qwen-image-edit", "llama-3"])]
ep = gallery_routes._first_visible_image_endpoint(_DB(rows), None)
assert ep is not None
assert ep.base_url == "https://gateway.example.com/v1"
def test_mixed_endpoint_without_image_model_is_ignored(monkeypatch):
_patch_model(monkeypatch)
rows = [_ep("https://gateway.example.com/v1", models=["llama-3", "mistral"])]
assert gallery_routes._first_visible_image_endpoint(_DB(rows), None) is None
def test_image_only_endpoint_wins_over_mixed(monkeypatch):
_patch_model(monkeypatch)
rows = [
_ep("https://gateway.example.com/v1", models=["gpt-image-1"]),
_ep("https://images.example.com/v1", model_type="image"),
]
ep = gallery_routes._first_visible_image_endpoint(_DB(rows), None)
assert ep is not None
assert ep.model_type == "image"
def test_unreachable_endpoint_reports_502():
"""The inpaint proxy must distinguish an unreachable endpoint from a provider error.
502 (gateway) is reserved for "the image server could not be reached"; 503 is
reserved for "generation is switched off on the server", which the editor maps
to its own fixed message.
"""
from pathlib import Path
src = (
Path(__file__).resolve().parents[1] / "routes" / "gallery" / "gallery_routes.py"
).read_text()
assert "Image server unreachable" in src
assert "except httpx.TransportError as exc:" in src
assert "provider HTTP" in src
assert 'raise HTTPException(503, f"Image endpoint unreachable' not in src
+39
View File
@@ -0,0 +1,39 @@
"""Source checks for the gallery Settings-tab image-model picker.
The Quick Edit model picker lives in the editor; HARNESS mirrors it into the
gallery Settings tab, fed by GET /api/editor/image-models and sharing the
same localStorage key (`ge-ai-command-model`) so the two stay in sync.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GALLERY_JS = (ROOT / "static/js/gallery.js").read_text(encoding="utf-8")
def test_settings_tab_has_image_model_picker():
assert 'id="gallery-image-model"' in GALLERY_JS
assert "/api/editor/image-models" in GALLERY_JS
assert "ge-ai-command-model" in GALLERY_JS
def test_image_model_picker_lives_in_the_settings_container():
settings_idx = GALLERY_JS.index('id="gallery-settings-container"')
picker_idx = GALLERY_JS.index('id="gallery-image-model"')
assert picker_idx > settings_idx
def test_picker_persists_with_the_shared_localstorage_key():
assert "localStorage.setItem(_GE_MODEL_KEY" in GALLERY_JS
assert "localStorage.getItem(_GE_MODEL_KEY)" in GALLERY_JS
def test_cloud_image_models_are_listed_alongside_the_local_ones():
"""Gateway endpoints (OpenRouter) register as llm but carry image models.
The picker must surface those ids too — otherwise the only selectable
edit model is the local Qwen server.
"""
routes = (ROOT / "routes/editor_agent_routes.py").read_text(encoding="utf-8")
assert "from src.image_model_ids import looks_like_image_generation_model" in routes
assert 'image_endpoint = getattr(ep, "model_type", None) == "image"' in routes
assert "if not image_endpoint and not looks_like_image_generation_model(name):" in routes
@@ -0,0 +1,25 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GALLERY = ROOT / "static/js/gallery.js"
def test_editor_landing_has_an_import_button_next_to_new_canvas():
"""The editor landing must offer a direct "Import" action.
It sits in the same action row as "New canvas..." / "Browse gallery" and,
on file pick, opens the editor on the chosen raster image (``openEditor``
takes an image data URL as its first argument).
"""
src = GALLERY.read_text(encoding="utf-8")
assert 'id="gallery-editor-import"' in src, "landing Import button missing"
# Button order: New canvas → Import → Browse gallery.
new_at = src.index('id="gallery-editor-new"')
import_at = src.index('id="gallery-editor-import"')
pick_at = src.index('id="gallery-editor-pick"')
assert new_at < import_at < pick_at, "Import must sit next to New canvas"
# Wiring opens the editor on the selected file's data URL.
assert "getElementById('gallery-editor-import')?.addEventListener('click'" in src
assert "readAsDataURL(file)" in src
assert "await openEditor(reader.result, null, null, name)" in src
+187
View File
@@ -0,0 +1,187 @@
"""Tests for the seneca single-image op proxies (HARNESS-owned).
POST /api/image/replace-background -> provider /v1/images/replace-background
POST /api/image/outpaint -> provider /v1/images/outpaint (alias /extend)
Both forward a client-supplied JPEG/PNG as multipart with the prompt/params plus
``request_id`` (so GET /api/image/edit-progress lights the progress bar) and
return the provider image as ``{image}``.
"""
import asyncio
import base64
from types import SimpleNamespace
import httpx
import routes.gallery_routes as gallery
_IMG_B64 = base64.b64encode(b"abc-not-a-real-png").decode()
def _route(path):
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path)
class _Req:
def __init__(self, body):
self._body = body
async def json(self):
return self._body
def _fake_db():
class Db:
def close(self):
pass
def query(self, *args, **kwargs):
return self
def filter(self, *args, **kwargs):
return self
def first(self):
return None
return Db()
def _setup(monkeypatch, respond):
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
seen = {}
def _respond(request):
seen["url"] = str(request.url)
seen["body"] = bytes(request.content)
return respond(request)
client = httpx.AsyncClient
monkeypatch.setattr(
httpx, "AsyncClient",
lambda **k: client(transport=httpx.MockTransport(_respond), **k),
)
return seen
def _ok(_req):
return httpx.Response(200, json={"data": [{"b64_json": "AAAA"}]})
def test_replace_background_forwards_multipart_and_returns_image(monkeypatch):
seen = _setup(monkeypatch, _ok)
out = asyncio.run(_route("/api/image/replace-background").endpoint(_Req({
"image": _IMG_B64, "prompt": "put her on a beach", "steps": 8,
"harmonise": True, "shadow": False, "request_id": "rb-123",
})))
assert out["image"] == "AAAA"
assert seen["url"].endswith("/images/replace-background")
body = seen["body"]
assert b"put her on a beach" in body
assert b"rb-123" in body # request_id forwarded for progress
assert b"harmonise" in body and b"shadow" in body
assert b"8" in body # steps
def test_outpaint_forwards_ratio_size_context_and_returns_image(monkeypatch):
seen = _setup(monkeypatch, _ok)
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({
"image": _IMG_B64, "prompt": "extend the sky", "ratio": "16:9",
"size": "1920x1080", "steps": 4, "context": "calm blue sky",
"request_id": "op-9",
})))
assert out["image"] == "AAAA"
assert seen["url"].endswith("/images/outpaint")
body = seen["body"]
for token in (b"16:9", b"1920x1080", b"calm blue sky", b"op-9"):
assert token in body, token
def test_outpaint_extend_alias_uses_the_same_provider_route(monkeypatch):
seen = _setup(monkeypatch, _ok)
out = asyncio.run(_route("/api/image/extend").endpoint(_Req({
"image": _IMG_B64, "prompt": "extend", "ratio": "9:16",
})))
assert out["image"] == "AAAA"
assert seen["url"].endswith("/images/outpaint")
def test_outpaint_reads_openai_style_url_result(monkeypatch):
def _respond(_req):
return httpx.Response(200, json={"data": [{"url": "http://100.69.120.65:8188/v1/images/x.png"}]})
_setup(monkeypatch, _respond)
monkeypatch.setattr(gallery, "_fetch_result_image_b64", lambda url: _async_return("ZZZZ"))
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({"image": _IMG_B64, "prompt": "x"})))
assert out["image"] == "ZZZZ"
def test_replace_background_502_when_provider_returns_no_image(monkeypatch):
_setup(monkeypatch, lambda _req: httpx.Response(200, json={"data": []}))
try:
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64})))
except Exception as exc:
assert getattr(exc, "status_code", None) == 502
else:
raise AssertionError("expected a 502 when the provider returns no image")
async def _async_return(value):
return value
def _capture_forward(monkeypatch):
"""Replace _forward_seneca_image_op with a fake that records kwargs."""
captured = {}
async def _fake_forward(base, api_key, provider_path, image_bytes, fields, *, label, timeout=300.0):
captured["label"] = label
captured["provider_path"] = provider_path
captured["timeout"] = timeout
return {"image": "AAAA"}
monkeypatch.setattr(gallery, "_forward_seneca_image_op", _fake_forward)
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(
gallery, "_first_visible_image_endpoint",
lambda *a: SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k"),
)
return captured
def test_outpaint_proxy_uses_a_long_timeout(monkeypatch):
captured = _capture_forward(monkeypatch)
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({"image": _IMG_B64, "prompt": "extend to 9:16"})))
assert out["image"] == "AAAA"
assert captured["provider_path"] == "/images/outpaint"
assert captured["timeout"] >= 900, captured
def test_outpaint_extend_alias_also_uses_the_long_timeout(monkeypatch):
captured = _capture_forward(monkeypatch)
asyncio.run(_route("/api/image/extend").endpoint(_Req({"image": _IMG_B64, "prompt": "extend"})))
assert captured["timeout"] >= 900, captured
def test_replace_background_keeps_the_short_default_timeout(monkeypatch):
captured = _capture_forward(monkeypatch)
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64, "prompt": "beach"})))
assert captured["timeout"] == 300.0, captured
def test_outpaint_timeout_env_override(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", "720")
assert gallery._outpaint_timeout() == 720.0
monkeypatch.delenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", raising=False)
assert gallery._outpaint_timeout() == 900.0
def test_graceful_shutdown_window_covers_a_long_outpaint():
from pathlib import Path
src = (Path(__file__).resolve().parent.parent / "app.py").read_text()
assert '"ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT", "900"' in src
+4 -2
View File
@@ -120,8 +120,9 @@ def test_disabled_generation_is_not_restored():
@pytest.mark.asyncio
@pytest.mark.parametrize('incognito', [False, True])
@pytest.mark.parametrize('exit_code', [None, 0])
async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_code):
async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_code, incognito):
from types import SimpleNamespace
from src import ai_interaction, tool_execution
from src.agent_runtime.journal import ActionJournal, bind_journal
@@ -145,6 +146,7 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
_, denied = await execute(block, owner='pewds', session_id='fixture',
disabled_tools={'generate_image'}, security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
_, result = await execute(block, owner='pewds', session_id='fixture',
client_runtime_context={'web_ui_state': {'incognito': incognito}},
security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
assert denied['exit_code'] != 0
assert journal.actions[0].execution_id is None
@@ -152,7 +154,7 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
assert journal.actions[1].execution_id is not None
assert journal.actions[1].outcome['authoritative'] is (exit_code == 0)
assert result['image_id'] == 'test'
assert calls == [(block.content, {'owner': 'pewds', 'session_id': 'fixture'})]
assert calls == [(block.content, {'owner': 'pewds', 'session_id': 'fixture', **({'incognito': True} if incognito else {})})]
@pytest.mark.asyncio
+54
View File
@@ -0,0 +1,54 @@
"""The heavy image-edit CPU work must run off the event loop, and the app must
expose in-flight heavy-op counts + a graceful shutdown so a deploy restart does
not drop a 60-150 s edit."""
import base64
import io
from pathlib import Path
from PIL import Image
from routes.gallery import gallery_routes as gr
ROOT = Path(__file__).resolve().parents[1]
def _png_b64(w, h, color=(120, 160, 210)):
buf = io.BytesIO()
Image.new("RGBA", (w, h), color).save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
def test_prepare_local_edit_caps_large_input():
src, mask, mw, mh, orig = gr._prepare_local_edit(_png_b64(4000, 3000), None, 768)
assert orig == (4000, 3000)
assert max(mw, mh) <= 768 and mw % 16 == 0 and mh % 16 == 0
assert mask # a full-white mask is synthesised when none is supplied
assert Image.open(io.BytesIO(src)).size == (mw, mh)
def test_prepare_local_edit_leaves_small_input_unchanged():
_src, _mask, mw, mh, orig = gr._prepare_local_edit(_png_b64(96, 96), _png_b64(96, 96, (255, 255, 255)), 768)
assert orig == (96, 96) and (mw, mh) == (96, 96)
def test_restore_local_b64_resizes_back_to_original():
raw = _png_b64(768, 576)
out = gr._restore_local_b64(raw, (4000, 3000), (768, 576))
assert Image.open(io.BytesIO(base64.b64decode(out))).size == (4000, 3000)
# a same-size result is returned untouched
assert gr._restore_local_b64(raw, (768, 576), (768, 576)) == raw
def test_inpaint_offloads_cpu_work_to_a_thread():
src = (ROOT / "routes/gallery/gallery_routes.py").read_text()
assert "await asyncio.to_thread(\n _prepare_local_edit" in src
assert "asyncio.to_thread(\n _restore_local_b64" in src
def test_app_exposes_busy_count_and_graceful_shutdown():
src = (ROOT / "app.py").read_text()
assert "busy_image_ops" in src
assert '@app.get("/api/busy")' in src
assert "_HEAVY_IMAGE_OP_PREFIXES" in src
assert "timeout_graceful_shutdown" in src
assert "activity.begin_image_op" in src and "activity.end_image_op" in src
+32
View File
@@ -33,6 +33,38 @@ def test_native_generation_respects_admin_disable(monkeypatch):
assert 'disabled' in result['error']
@pytest.mark.parametrize('editing', [False, True])
def test_incognito_images_never_write_gallery_or_files(image_transport, editing):
configure, source, requests, rows = image_transport
configure('https://openrouter.ai/api/v1')
if editing:
result = asyncio.run(ai_interaction.do_edit_image(
'Private edit', str(source), model_spec='openai/gpt-5-image',
owner='alice', session_id='private-chat', incognito=True))
else:
result = asyncio.run(ai_interaction.do_generate_image(
'Private image\nopenai/gpt-5-image', owner='alice',
session_id='private-chat', incognito=True))
assert 'error' not in result
assert result['image_url'].startswith('data:image/png;base64,')
assert base64.b64decode(result['image_url'].split(',', 1)[1]) == source.read_bytes()
assert not result['image_id']
assert not rows
assert not (source.parent / 'generated').exists()
def test_incognito_url_image_is_not_persisted(image_transport, monkeypatch):
configure, source, requests, rows = image_transport
configure('https://api.openai.com/v1', body={'data': [{'url': 'https://example.com/output.png'}]})
monkeypatch.setattr(httpx, 'get', lambda *args, **kwargs: httpx.Response(200, content=source.read_bytes()))
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, ''))
result = asyncio.run(ai_interaction.do_generate_image(
'Private image\nopenai/gpt-5-image', owner='alice', incognito=True))
assert result['image_url'].startswith('data:image/png;base64,')
assert not rows
assert not (source.parent / 'generated').exists()
@pytest.fixture
def image_transport(monkeypatch, tmp_path):
rows = []
+257
View File
@@ -0,0 +1,257 @@
"""Regression tests for SSRF hardening on the manage_endpoints agent tool.
Verifies that the do_manage_endpoints tool function rejects user-supplied URLs
that resolve to link-local (cloud metadata), non-HTTP schemes, or (when
MODELENDPOINT_BLOCK_PRIVATE_IPS=true) private/loopback addresses -- applying
the shared outbound-URL policy in check_outbound_url(). The HTTP route
(POST /api/model-endpoints) receives the same guard in #6316.
The agent tool path bypasses the HTTP route entirely, so it needs its own
validation. A prompt-injection payload smuggled into a skill, note, fetched
page, or email could instruct the model to register a cloud-metadata or
internal-network endpoint via this tool.
"""
import ipaddress
import json
import os
import sys
import types
import pytest
import src.url_safety as _url_safety
_real_check_outbound_url = _url_safety.check_outbound_url
def _fake_resolver(host):
"""Resolver that returns deterministic IPs without real DNS."""
_HOST_MAP = {
"169.254.169.254": ["169.254.169.254"],
"metadata.google.internal": ["169.254.169.254"],
"localhost": ["127.0.0.1"],
"127.0.0.1": ["127.0.0.1"],
"0.0.0.0": ["0.0.0.0"],
"example.com": ["93.184.216.34"],
"192.168.1.1": ["192.168.1.1"],
"10.0.0.1": ["10.0.0.1"],
"api.openai.com": ["104.18.6.192"],
}
if host in _HOST_MAP:
return _HOST_MAP[host]
try:
ipaddress.ip_address(host)
return [host]
except ValueError:
pass
raise OSError(f"Name or service not known: {host}")
def _check_with_fake_resolver(url, *, block_private=False, resolver=None):
"""Call the real check_outbound_url with our fake resolver."""
return _real_check_outbound_url(url, block_private=block_private, resolver=_fake_resolver)
def _make_content(base_url, *, name="", api_key=""):
"""Build a JSON content string for the manage_endpoints 'add' action."""
return json.dumps({
"action": "add",
"name": name,
"base_url": base_url,
"api_key": api_key,
})
class _FakeQuery:
def __init__(self, model):
self._model = model
def filter(self, *args):
return self
def first(self):
return None
def all(self):
return []
class _FakeDB:
def __init__(self):
self.added = []
self.committed = False
def query(self, model):
return _FakeQuery(model)
def add(self, obj):
self.added.append(obj)
def commit(self):
self.committed = True
def close(self):
pass
class _FakeModelEndpoint:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
def _setup_mocks(monkeypatch, db):
"""Set up faked core.database and patched check_outbound_url."""
fake_core_db = types.ModuleType("core.database")
fake_core_db.SessionLocal = lambda: db
fake_core_db.ModelEndpoint = _FakeModelEndpoint
monkeypatch.setitem(sys.modules, "core.database", fake_core_db)
monkeypatch.setattr(_url_safety, "check_outbound_url", _check_with_fake_resolver)
if "src.agent_tools.admin_tools" in sys.modules:
monkeypatch.delitem(sys.modules, "src.agent_tools.admin_tools", raising=False)
def _get_do_manage_endpoints():
"""Import and return do_manage_endpoints from the reloaded admin_tools."""
from src.agent_tools.admin_tools import do_manage_endpoints
return do_manage_endpoints
@pytest.mark.asyncio
async def test_cloud_metadata_blocked(monkeypatch):
"""Cloud metadata IP (169.254.169.254) must be rejected."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://169.254.169.254/latest/meta-data/")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_non_http_scheme_blocked(monkeypatch):
"""Non-HTTP schemes (file://, gopher://) must be rejected."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("file:///etc/passwd")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_loopback_accepted_by_default(monkeypatch):
"""Loopback addresses are accepted by default (local-first design).
The agent tool uses check_outbound_url directly, which allows
loopback by default -- this is correct for local model endpoints
(Ollama, llama.cpp, etc.). Loopback is only blocked in strict mode.
"""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://localhost:11434/v1")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 0
assert "Added endpoint" in result["response"]
assert len(db.added) == 1
@pytest.mark.asyncio
async def test_strict_mode_blocks_private(monkeypatch):
"""MODELENDPOINT_BLOCK_PRIVATE_IPS=true blocks private IPs."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
monkeypatch.setenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "true")
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://192.168.1.1:8080/v1")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_safe_url_accepted(monkeypatch):
"""A safe public URL should be accepted and stored."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("https://api.openai.com/v1", name="OpenAI")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 0
assert "Added endpoint" in result["response"]
assert len(db.added) == 1
assert db.committed is True
@pytest.mark.asyncio
async def test_empty_url_rejected(monkeypatch):
"""An empty base_url must be rejected before SSRF check."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "base_url is required" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_gopher_scheme_blocked(monkeypatch):
"""gopher:// scheme must be rejected."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("gopher://evil:6379/secret")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_error_not_leaked(monkeypatch):
"""Error messages must not leak raw exception text."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://169.254.169.254/latest/meta-data/")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert "Traceback" not in result["error"]
@pytest.mark.asyncio
async def test_ipv6_loopback_accepted_by_default(monkeypatch):
"""::1 must be treated as loopback (accepted local-first), not reserved.
Proves this branch carries the dfd1f7e7 url_safety fix after rebasing
onto current dev: the pre-fix guard checked is_reserved before is_loopback
and rejected ::1 even in default local-first mode.
"""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://[::1]:11434/v1")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 0
assert "Added endpoint" in result["response"]
assert len(db.added) == 1
@@ -0,0 +1,289 @@
"""manage_tasks must apply the admin-only task-action policy.
``POST /api/tasks`` (routes/task/task_routes.py) and the scheduler
(src/task_scheduler.py) both refuse ``action`` tasks whose action is in
``ADMIN_ONLY_TASK_ACTIONS`` (run_local, run_script, ssh_command,
cookbook_serve) for owners without admin task privileges — those actions run
``subprocess.run(shell=True)`` or SSH with no sandbox. The model-facing
``do_manage_tasks`` applied neither check, so an agent could store such a task
on create/edit and resume/run it; the scheduler would only pause it at
execution time. Regression for issue #6021: the tool now enforces the same
policy on create, edit, resume and run, and keeps allowing admins and the
non-privileged actions.
"""
import json
import tempfile
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
from tests.helpers.import_state import clear_fake_database_modules
clear_fake_database_modules()
import core.database as cdb
from core.database import ScheduledTask
import src.task_action_policy as policy_mod
import src.tools.system as system_mod
from src.tools.system import do_manage_tasks
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_ENGINE = create_engine(
f"sqlite:///{_TMPDB.name}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(_ENGINE)
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
@pytest.fixture(autouse=True)
def _bind_session_factory(monkeypatch):
# do_manage_tasks does `from core.database import SessionLocal` at call
# time. Bind it per test (not at import) so this file stays
# order-independent from sibling files that point it at their own DB.
monkeypatch.setattr(cdb, "SessionLocal", _TS)
@pytest.fixture
def privileges(monkeypatch):
"""Drive owner_has_admin_task_privileges from a plain dict."""
admins = {"root"}
monkeypatch.setattr(
policy_mod, "owner_has_admin_task_privileges",
lambda owner: owner in admins,
)
return admins
def _seed(task_id, owner, task_type="llm", action=None, status="active"):
db = _TS()
try:
db.add(ScheduledTask(
id=task_id, owner=owner, name=task_id, prompt="original",
task_type=task_type, action=action, trigger_type="webhook",
status=status, output_target="session",
))
db.commit()
finally:
db.close()
def _get(task_id):
db = _TS()
try:
return db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
finally:
db.close()
def _count(owner):
db = _TS()
try:
return db.query(ScheduledTask).filter(ScheduledTask.owner == owner).count()
finally:
db.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("action_name", sorted(policy_mod.ADMIN_ONLY_TASK_ACTIONS))
async def test_create_admin_only_action_denied_for_non_admin(privileges, action_name):
before = _count("alice")
out = await do_manage_tasks(json.dumps({
"action": "create", "task_type": "action", "action_name": action_name,
"name": "pwn", "schedule": "daily", "scheduled_time": "09:00",
}), owner="alice")
assert out["exit_code"] == 1, out
assert "requires admin privileges" in out["error"]
assert _count("alice") == before
@pytest.mark.asyncio
async def test_create_admin_only_action_allowed_for_admin(privileges):
out = await do_manage_tasks(json.dumps({
"action": "create", "task_type": "action", "action_name": "run_local",
"name": "ok", "schedule": "daily", "scheduled_time": "09:00",
}), owner="root")
assert out["exit_code"] == 0, out
assert _get(out["task_id"]).action == "run_local"
@pytest.mark.asyncio
async def test_create_regular_action_allowed_for_non_admin(privileges):
out = await do_manage_tasks(json.dumps({
"action": "create", "task_type": "action", "action_name": "tidy_sessions",
"name": "ok", "schedule": "daily", "scheduled_time": "09:00",
}), owner="alice")
assert out["exit_code"] == 0, out
@pytest.mark.asyncio
async def test_create_llm_task_allowed_for_non_admin(privileges):
out = await do_manage_tasks(json.dumps({
"action": "create", "task_type": "llm", "prompt": "summarize",
"schedule": "daily", "scheduled_time": "09:00",
}), owner="alice")
assert out["exit_code"] == 0, out
@pytest.mark.asyncio
async def test_edit_cannot_turn_llm_task_into_shell_action(privileges):
_seed("alice-llm", "alice")
out = await do_manage_tasks(json.dumps({
"action": "edit", "task_id": "alice-llm",
"task_type": "action", "action_name": "ssh_command",
}), owner="alice")
assert out["exit_code"] == 1, out
assert "requires admin privileges" in out["error"]
task = _get("alice-llm")
assert task.task_type == "llm" and task.action is None
@pytest.mark.asyncio
async def test_edit_cannot_swap_action_name_to_admin_only(privileges):
_seed("alice-action", "alice", task_type="action", action="tidy_sessions")
out = await do_manage_tasks(json.dumps({
"action": "edit", "task_id": "alice-action", "action_name": "run_script",
}), owner="alice")
assert out["exit_code"] == 1, out
assert _get("alice-action").action == "tidy_sessions"
@pytest.mark.asyncio
async def test_edit_of_unrelated_field_on_admin_only_task_denied_for_non_admin(privileges):
# A task that slipped in before the policy (or via demotion of its owner)
# cannot be kept alive by a non-admin: the route rejects the same edit.
_seed("alice-legacy", "alice", task_type="action", action="run_local")
out = await do_manage_tasks(json.dumps({
"action": "edit", "task_id": "alice-legacy", "name": "renamed",
}), owner="alice")
assert out["exit_code"] == 1, out
assert _get("alice-legacy").name == "alice-legacy"
@pytest.mark.asyncio
async def test_resume_admin_only_task_denied_for_non_admin(privileges):
_seed("alice-paused", "alice", task_type="action", action="run_local", status="paused")
out = await do_manage_tasks(json.dumps({
"action": "resume", "task_id": "alice-paused",
}), owner="alice")
assert out["exit_code"] == 1, out
assert _get("alice-paused").status == "paused"
@pytest.mark.asyncio
async def test_pause_admin_only_task_still_allowed(privileges):
# Narrowing is always fine: a non-admin may pause a task they own.
_seed("alice-active", "alice", task_type="action", action="run_local")
out = await do_manage_tasks(json.dumps({
"action": "pause", "task_id": "alice-active",
}), owner="alice")
assert out["exit_code"] == 0, out
assert _get("alice-active").status == "paused"
@pytest.mark.asyncio
async def test_run_admin_only_task_denied_before_scheduler(privileges, monkeypatch):
_seed("alice-run", "alice", task_type="action", action="ssh_command")
calls = []
class _Sched:
async def run_task_now(self, task_id):
calls.append(task_id)
return True
import src.event_bus as event_bus
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
out = await do_manage_tasks(json.dumps({
"action": "run", "task_id": "alice-run",
}), owner="alice")
assert out["exit_code"] == 1, out
assert calls == []
@pytest.mark.asyncio
async def test_run_admin_only_task_allowed_for_admin(privileges, monkeypatch):
_seed("root-run", "root", task_type="action", action="ssh_command")
calls = []
class _Sched:
async def run_task_now(self, task_id):
calls.append(task_id)
return True
import src.event_bus as event_bus
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
out = await do_manage_tasks(json.dumps({
"action": "run", "task_id": "root-run",
}), owner="root")
assert out["exit_code"] == 0, out
assert calls == ["root-run"]
def test_policy_helper_uses_shared_constants(privileges):
# The tool must consult the shared policy module, not a private copy of
# the action names, so a new admin-only action is covered everywhere.
assert system_mod._admin_task_action_denied("alice", "action", "run_local") is not None
assert system_mod._admin_task_action_denied("root", "action", "run_local") is None
assert system_mod._admin_task_action_denied("alice", "action", "tidy_sessions") is None
assert system_mod._admin_task_action_denied("alice", "llm", "run_local") is None
@pytest.mark.asyncio
async def test_run_admin_only_task_by_name_denied_for_non_admin(privileges, monkeypatch):
_seed("alice-run-name", "alice", task_type="action", action="ssh_command")
calls = []
class _Sched:
async def run_task_now(self, task_id):
calls.append(task_id)
return True
import src.event_bus as event_bus
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
out = await do_manage_tasks(json.dumps({
"action": "run", "name": "alice-run-name",
}), owner="alice")
assert out["exit_code"] == 1, out
assert "requires admin privileges" in out["error"]
assert calls == []
@pytest.mark.asyncio
async def test_create_admin_only_action_seals_authority_for_admin(privileges):
out = await do_manage_tasks(json.dumps({
"action": "create", "task_type": "action", "action_name": "run_local",
"name": "sealed-task", "schedule": "daily", "scheduled_time": "09:00",
}), owner="root")
assert out["exit_code"] == 0, out
task = _get(out["task_id"])
assert task.action == "run_local"
assert task.request_authority_json is not None
def test_real_owner_has_admin_task_privileges_branches(monkeypatch):
"""Verify owner_has_admin_task_privileges without mocking."""
# Auth disabled branch
monkeypatch.setenv("AUTH_ENABLED", "false")
assert policy_mod.owner_has_admin_task_privileges("alice") is True
assert policy_mod.owner_has_admin_task_privileges(None) is True
# Auth enabled branch with configured AuthManager
monkeypatch.delenv("AUTH_ENABLED", raising=False)
class FakeAuth:
is_configured = True
def is_admin(self, u):
return u == "admin"
import core.auth as c_auth
monkeypatch.setattr(c_auth, "AuthManager", lambda: FakeAuth())
assert policy_mod.owner_has_admin_task_privileges("admin") is True
assert policy_mod.owner_has_admin_task_privileges("alice") is False
assert policy_mod.owner_has_admin_task_privileges("internal-tool") is True
assert policy_mod.owner_has_admin_task_privileges(None) is False
+228
View File
@@ -264,3 +264,231 @@ def test_launchers_export_the_port_they_serve_on():
text = (_repo_root() / name).read_text(encoding="utf-8")
assert uvicorn_flag in text, f"{name}: launcher no longer passes {uvicorn_flag}"
assert export in text, f"{name}: serves on a port the app cannot read back"
# ── Callback & Exchange Expired State Handling ───────────────────
def test_oauth_callback_expired_state_returns_dedicated_expired_page(monkeypatch):
"""An unknown or expired generic MCP OAuth state in the callback must return
the dedicated 'Authorization Expired' page instead of a bare 403 'Admin only'
or a 404 'Server not found'."""
from unittest.mock import MagicMock
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from routes.mcp import mcp_routes
# Simulate unauthenticated request that would fail admin check if invoked
def _deny_admin(request):
raise HTTPException(403, "Admin only")
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
# Empty DB / no matching server
class FakeQuery:
def filter(self, *a):
return self
def first(self):
return None
class FakeSession:
def query(self, *a):
return FakeQuery()
def close(self):
pass
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
app = FastAPI()
manager = MagicMock()
router = mcp_routes.setup_mcp_routes(manager)
app.include_router(router)
client = TestClient(app)
# Both code and state are provided, but state is not in _pending (expired/unknown)
resp = client.get("/api/mcp/oauth/callback?code=some-code&state=expired-state-123")
assert resp.status_code == 400
assert "Authorization Expired" in resp.text
assert (
"This authorization round has expired. Press Reconnect in Odysseus and try again."
in resp.text
)
# Ensure it did not return the bare 403 "Admin only"
assert "Admin only" not in resp.text
def test_oauth_callback_active_pending_state_succeeds_without_admin(monkeypatch):
"""An active pending generic MCP OAuth state resolves without requiring admin auth."""
from unittest.mock import MagicMock
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from routes.mcp import mcp_routes
# Admin check fails if invoked
def _deny_admin(request):
raise HTTPException(403, "Admin only")
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
app = FastAPI()
manager = MagicMock()
router = mcp_routes.setup_mcp_routes(manager)
app.include_router(router)
client = TestClient(app)
async def go():
fut = mcp_oauth.register_pending("pending-round-1")
resp = client.get("/api/mcp/oauth/callback?code=auth-code-xyz&state=pending-round-1")
assert resp.status_code == 200
assert "Authorization Successful" in resp.text
code, state = await asyncio.wait_for(fut, timeout=1)
assert code == "auth-code-xyz"
assert state == "pending-round-1"
asyncio.run(go())
def test_oauth_callback_legacy_google_server_requires_admin(monkeypatch):
"""Legacy Google OAuth callbacks (where state is server_id of a server with
oauth_config) still enforce require_admin."""
import json
from unittest.mock import MagicMock
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from routes.mcp import mcp_routes
from core.database import McpServer
# Server exists in DB with Google oauth_config
google_srv = McpServer(
id="google-srv-1",
name="Google Workspace",
oauth_config=json.dumps({"provider": "google", "keys_file": "k.json", "token_file": "t.json"}),
)
class FakeQuery:
def filter(self, *a):
return self
def first(self):
return google_srv
class FakeSession:
def query(self, *a):
return FakeQuery()
def close(self):
pass
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
# When require_admin denies, 403 is raised for the legacy Google path
def _deny_admin(request):
raise HTTPException(403, "Admin only")
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
app = FastAPI()
manager = MagicMock()
router = mcp_routes.setup_mcp_routes(manager)
app.include_router(router)
client = TestClient(app)
resp = client.get("/api/mcp/oauth/callback?code=code-123&state=google-srv-1")
assert resp.status_code == 403
assert "Admin only" in resp.text
def test_oauth_exchange_expired_generic_state_returns_expired_page(monkeypatch):
"""Remote paste-back flow for a generic MCP server returns the dedicated expired
page when the state is no longer pending, instead of 'No OAuth config.'."""
from unittest.mock import MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.mcp import mcp_routes
from core.database import McpServer
monkeypatch.setattr(mcp_routes, "require_admin", lambda r: None)
# Server exists in DB without oauth_config (generic MCP server)
generic_srv = McpServer(
id="http-srv-1",
name="Remote HTTP MCP",
oauth_config=None,
)
class FakeQuery:
def filter(self, *a):
return self
def first(self):
return generic_srv
class FakeSession:
def query(self, *a):
return FakeQuery()
def close(self):
pass
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
app = FastAPI()
manager = MagicMock()
router = mcp_routes.setup_mcp_routes(manager)
app.include_router(router)
client = TestClient(app)
resp = client.post(
"/api/mcp/oauth/exchange/http-srv-1",
data={"callback_url": "http://localhost:7000/api/mcp/oauth/callback?code=code-1&state=expired-state"},
)
assert resp.status_code == 400
assert "Authorization Expired" in resp.text
assert (
"This authorization round has expired. Press Reconnect in Odysseus and try again."
in resp.text
)
assert "No OAuth config." not in resp.text
def test_oauth_callback_missing_state_returns_malformed_error_not_expired():
"""Missing state parameter in callback must return a bad-request error and must
not be treated as an expired authorization round."""
from unittest.mock import MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.mcp import mcp_routes
app = FastAPI()
manager = MagicMock()
router = mcp_routes.setup_mcp_routes(manager)
app.include_router(router)
client = TestClient(app)
resp = client.get("/api/mcp/oauth/callback?code=some-code")
assert resp.status_code == 400
assert "No OAuth state parameter found in the callback URL." in resp.text
assert "Authorization Expired" not in resp.text
assert "This authorization round has expired" not in resp.text
def test_oauth_callback_missing_code_returns_malformed_error_not_expired():
"""Missing code parameter in callback must return a bad-request error and must
not be treated as an expired authorization round."""
from unittest.mock import MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.mcp import mcp_routes
app = FastAPI()
manager = MagicMock()
router = mcp_routes.setup_mcp_routes(manager)
app.include_router(router)
client = TestClient(app)
resp = client.get("/api/mcp/oauth/callback?state=some-state")
assert resp.status_code == 400
assert "No authorization code found in the URL." in resp.text
assert "Authorization Expired" not in resp.text
assert "This authorization round has expired" not in resp.text
+296
View File
@@ -0,0 +1,296 @@
"""Regression tests for SSRF hardening on model endpoint routes.
Verifies that POST /model-endpoints/test, POST /model-endpoints, and
PATCH /model-endpoints/{ep_id} reject user-supplied URLs that resolve to
link-local (cloud metadata), non-HTTP schemes, or (when
MODELENDPOINT_BLOCK_PRIVATE_IPS=true) private/loopback addresses — matching
the existing SSRF guard pattern in embedding_routes.py.
A stub resolver is injected so the loopback tests never touch real DNS.
"""
import os
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
# ── Resolver stub (same pattern as tests/test_url_safety.py) ────────────────
def _resolver(mapping):
"""Create a stub resolver for deterministic DNS in tests."""
def resolve(host):
if host in mapping:
return mapping[host]
raise OSError(f"unresolvable: {host}")
return resolve
LOCALHOST_V4 = _resolver({"localhost": ["127.0.0.1"]})
LOCALHOST_V6 = _resolver({"localhost": ["::1"]})
@pytest.fixture
def client(monkeypatch):
"""Build a TestClient with auth disabled and minimal DB stubs."""
monkeypatch.setenv("AUTH_ENABLED", "false")
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
# Stub heavy deps that model_routes imports at module level.
# Use monkeypatch so entries are removed after the test.
for mod in [
"src.tls_overrides",
"src.llm_core",
"src.settings",
"src.constants",
"core.log_safety",
]:
if mod not in sys.modules:
monkeypatch.setitem(sys.modules, mod, MagicMock())
# Provide real endpoint_resolver so the route logic works
if "src.endpoint_resolver" not in sys.modules or isinstance(
sys.modules["src.endpoint_resolver"], MagicMock
):
er = MagicMock()
er.normalize_base = lambda url: url
er.resolve_url = lambda url: url
er.build_chat_url = lambda base, **kw: base
er.build_models_url = lambda base, **kw: base + "/v1/models"
er.build_headers = lambda base, **kw: {}
monkeypatch.setitem(sys.modules, "src.endpoint_resolver", er)
# Clear cached model_routes module so it picks up our stubs
monkeypatch.delitem(sys.modules, "routes.model_routes", raising=False)
from fastapi.testclient import TestClient
import routes.model_routes as mr
from fastapi import FastAPI
app = FastAPI()
router = mr.setup_model_routes(model_discovery=None)
app.include_router(router)
app.state.auth_enabled = False
return TestClient(app, raise_server_exceptions=False)
class TestModelEndpointSSRF:
"""SSRF protection for POST /model-endpoints/test."""
def test_cloud_metadata_blocked(self, client):
"""Cloud metadata IP (169.254.169.254) must be rejected."""
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://169.254.169.254/latest/meta-data/"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
def test_non_http_scheme_blocked(self, client):
"""Non-HTTP schemes (file://, ftp://) must be rejected."""
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "file:///etc/passwd"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
def test_loopback_accepted_by_default(self, client):
"""Local-first: loopback is accepted when block_private=False."""
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://localhost:11434/v1"},
)
# Guard passes; probe fails (no server) but that is not SSRF.
assert "Rejected endpoint URL" not in str(resp.json())
def test_loopback_rejected_in_strict_mode(self, client):
"""Strict mode: loopback is rejected when block_private=True."""
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
try:
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://localhost:11434/v1"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
finally:
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
def test_strict_mode_blocks_direct_loopback_ip(self, client):
"""Strict mode rejects 127.0.0.1 passed directly (no DNS needed)."""
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
try:
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://127.0.0.1:11434/v1"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
finally:
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
def test_ipv6_loopback_literal_accepted_by_default(self, client):
"""::1 is loopback, not a disallowed 'reserved' address (dfd1f7e7)."""
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://[::1]:11434/v1"},
)
assert "Rejected endpoint URL" not in str(resp.json())
def test_ipv6_loopback_localhost_accepted_by_default(self, client):
"""Dual-stack hosts whose resolver answers ::1 first must work."""
with patch("src.url_safety._default_resolver", LOCALHOST_V6):
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://localhost:11434/v1"},
)
assert "Rejected endpoint URL" not in str(resp.json())
def test_ipv6_loopback_rejected_in_strict_mode(self, client):
"""Strict mode still rejects ::1."""
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
try:
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://[::1]:11434/v1"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
finally:
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
class TestModelEndpointCreateSSRF:
"""SSRF protection for POST /model-endpoints."""
def test_cloud_metadata_blocked(self, client):
"""Cloud metadata IP must be rejected on endpoint creation."""
resp = client.post(
"/api/model-endpoints",
data={"base_url": "http://169.254.169.254/latest/meta-data/"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
def test_non_http_scheme_blocked(self, client):
"""Non-HTTP schemes must be rejected on endpoint creation."""
resp = client.post(
"/api/model-endpoints",
data={"base_url": "gopher://evil:6379/secret"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
def test_loopback_accepted_by_default(self, client):
"""Local-first: loopback is accepted when block_private=False."""
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
resp = client.post(
"/api/model-endpoints",
data={"base_url": "http://localhost:11434/v1"},
)
# Guard passes; route may 500 on DB/probe but never 400 SSRF.
assert resp.status_code != 400
body = resp.text if resp.status_code == 500 else str(resp.json())
assert "Rejected endpoint URL" not in body
def test_loopback_rejected_in_strict_mode(self, client):
"""Strict mode: loopback is rejected when block_private=True."""
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
try:
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
resp = client.post(
"/api/model-endpoints",
data={"base_url": "http://localhost:11434/v1"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
finally:
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
class TestModelEndpointPatchSSRF:
"""SSRF protection for PATCH /api/model-endpoints/{ep_id} (base_url).
The update path rewrites the stored base_url, which later fetches probe,
list models and serve chat through — it must pass the same guard as the
POST routes instead of bypassing it.
"""
def _install_fake_endpoint(self, monkeypatch, base_url="http://localhost:9999/v1"):
import routes.model_routes as mr
ep = SimpleNamespace(
id="ep1",
name="EP",
base_url=base_url,
api_key=None,
is_enabled=True,
owner=None,
hidden_models=None,
cached_models=None,
pinned_models=None,
model_type="llm",
supports_tools=None,
endpoint_kind="auto",
model_refresh_mode="auto",
model_refresh_interval=None,
model_refresh_timeout=None,
created_at=None,
updated_at=None,
)
class _FakeDb:
def query(self, _model):
return self
def filter(self, *_args, **_kwargs):
return self
def first(self):
return ep
def commit(self):
pass
def close(self):
pass
monkeypatch.setattr(mr, "SessionLocal", lambda: _FakeDb())
monkeypatch.setattr(mr, "require_admin", lambda request: None)
monkeypatch.setattr(mr, "_chatgpt_endpoint_visible", lambda ep_, request: True)
return ep
def test_patch_cloud_metadata_blocked(self, client, monkeypatch):
"""PATCH must reject the cloud metadata URL, not store it."""
ep = self._install_fake_endpoint(monkeypatch)
resp = client.patch(
"/api/model-endpoints/ep1",
json={"base_url": "http://169.254.169.254/latest/meta-data"},
)
assert resp.status_code == 400
assert "Rejected endpoint URL" in resp.json()["detail"]
assert ep.base_url == "http://localhost:9999/v1"
def test_patch_non_http_scheme_blocked(self, client, monkeypatch):
"""PATCH must reject non-HTTP schemes."""
ep = self._install_fake_endpoint(monkeypatch)
resp = client.patch(
"/api/model-endpoints/ep1",
json={"base_url": "file:///etc/passwd"},
)
assert resp.status_code == 400
assert "Rejected endpoint URL" in resp.json()["detail"]
assert ep.base_url == "http://localhost:9999/v1"
def test_patch_valid_url_accepted(self, client, monkeypatch):
"""Control: a legitimate local endpoint URL still updates fine."""
ep = self._install_fake_endpoint(monkeypatch)
resp = client.patch(
"/api/model-endpoints/ep1",
json={"base_url": "http://127.0.0.1:11434/v1"},
)
assert resp.status_code == 200
assert ep.base_url == "http://127.0.0.1:11434/v1"

Some files were not shown because too many files have changed in this diff Show More