merge(dev): integrate latest dev into fix/ios-safe-area-postmerge-hardening

This commit is contained in:
Alexandre Teixeira
2026-10-30 13:25:33 +00:00
583 changed files with 75243 additions and 2164 deletions
+402
View File
@@ -0,0 +1,402 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
test('Chat Photo Editor Action Integrity and Turn Scoping', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const serverResponses = new Map();
const recordedUploadRequests = [];
await page.route('http://photo-test.local/**', async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
if (path === '/') {
return route.fulfill({
contentType: 'text/html',
body: `<!DOCTYPE html>
<html>
<body>
<div id="toast"></div>
<div id="chat-history"></div>
</body>
</html>`,
});
}
if (path.startsWith('/api/upload/')) {
const id = path.replace('/api/upload/', '');
const method = route.request().method();
recordedUploadRequests.push({ id, method });
const status = serverResponses.get(id) || 200;
if (status === 200) {
return route.fulfill({
status: 200,
headers: {
'Content-Type': 'image/png',
'Content-Length': '16',
'X-Content-Type-Options': 'nosniff',
},
body: method === 'HEAD' ? Buffer.alloc(0) : Buffer.from('fake-image-bytes'),
});
}
return route.fulfill({
status,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ detail: 'Upload error or forbidden' }),
});
}
try {
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
return route.fulfill({ contentType: 'text/javascript', body: source });
} catch (err) {
return route.abort();
}
});
await page.goto('http://photo-test.local/');
const results = await page.evaluate(async () => {
const box = document.getElementById('chat-history');
// Import chat.js to test hooks
await import('/static/js/chat.js');
const hooks = window.__chatPhotoEditorTestHooks;
if (!hooks) throw new Error('Test hooks not exposed on window.__chatPhotoEditorTestHooks');
const { _isAuthorizedImageUrl, _findOriginatingUserMessage, _attachedImageForTurn, renderPhotoEditorApplyButton } = hooks;
// --- Scenario 1: Authorization checks ---
const authValidUpload = _isAuthorizedImageUrl('/api/upload/img_123');
const authValidGallery = _isAuthorizedImageUrl('/api/gallery/img_123');
const authBlob = _isAuthorizedImageUrl('blob:http://photo-test.local/550e8400-e29b-41d4-a716-446655440000');
const authCrossOriginBlob = _isAuthorizedImageUrl('blob:https://evil.com/550e8400-e29b-41d4-a716-446655440000');
const authMalformedBlob = _isAuthorizedImageUrl('blob:javascript:alert(1)');
const pixel = document.createElement('canvas'); pixel.width = pixel.height = 1;
const authDataPng = _isAuthorizedImageUrl(pixel.toDataURL('image/png'));
const authDataJpeg = _isAuthorizedImageUrl(pixel.toDataURL('image/jpeg'));
const authDataWebp = _isAuthorizedImageUrl(pixel.toDataURL('image/webp'));
const authDataGif = _isAuthorizedImageUrl('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
const authDataSvg = _isAuthorizedImageUrl('data:image/svg+xml;base64,PHN2Zz4=');
const authDataHtml = _isAuthorizedImageUrl('data:text/html;base64,PGgxPlRlc3Q=');
const authDataMalformed = _isAuthorizedImageUrl('data:image/png;base64,invalid!@#$');
const authDataOversized = _isAuthorizedImageUrl('data:image/png;base64,' + 'A'.repeat(11 * 1024 * 1024));
const authMaliciousDomain = _isAuthorizedImageUrl('https://evil.com/api/upload/img_123');
const authSchemeRelative = _isAuthorizedImageUrl('//evil.com/api/upload/img_123');
const authJavascript = _isAuthorizedImageUrl('javascript:alert(1)');
const authDirectoryTraversal = _isAuthorizedImageUrl('/api/upload/../../../etc/passwd');
const authEncodedTraversal = _isAuthorizedImageUrl('/api/upload/%2e%2e%2f');
// --- Scenario 2: Multi-turn isolation ---
// Build Turn 1: User attaches img_turn1
const user1 = document.createElement('div');
user1.className = 'msg msg-user';
user1.innerHTML = '<div class="body">Turn 1 text</div><div class="attach-image-preview" data-file-id="img_turn1"><img src="/api/upload/img_turn1" /></div>';
box.appendChild(user1);
const ai1 = document.createElement('div');
ai1.className = 'msg msg-ai round-holder';
ai1.innerHTML = '<div class="body">Turn 1 AI reply</div>';
box.appendChild(ai1);
// Build Turn 2: User attaches NO photo (just text)
const user2 = document.createElement('div');
user2.className = 'msg msg-user';
user2.innerHTML = '<div class="body">Turn 2 text without photo</div>';
box.appendChild(user2);
const ai2 = document.createElement('div');
ai2.className = 'msg msg-ai round-holder';
ai2.innerHTML = '<div class="body">Turn 2 AI reply</div>';
box.appendChild(ai2);
// Build Turn 3: User attaches img_turn3
const user3 = document.createElement('div');
user3.className = 'msg msg-user';
user3.innerHTML = '<div class="body">Turn 3 text</div><div class="attach-card" data-file-id="img_turn3"><img src="/api/upload/img_turn3" /></div>';
box.appendChild(user3);
const ai3 = document.createElement('div');
ai3.className = 'msg msg-ai round-holder';
ai3.innerHTML = '<div class="body">Turn 3 AI reply</div>';
box.appendChild(ai3);
// Resolve images for each turn
const turn1Resolved = _attachedImageForTurn(ai1, { kind: 'open_and_apply', prompt: 'edit turn 1' });
const turn2Resolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', prompt: 'edit turn 2' });
const turn3Resolved = _attachedImageForTurn(ai3, { kind: 'open_and_apply', prompt: 'edit turn 3' });
// Action with explicit imageId should honor explicit id
const explicitActionResolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', imageId: 'explicit_999' });
const filenameResolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', imageId: 'explicit_999.png' });
const invalidExplicitResolved = _attachedImageForTurn(ai1, { kind: 'open_and_apply', imageUrl: 'data:image/png;base64,=' });
// Action on container with no user message above
const detachedAi = document.createElement('div');
detachedAi.className = 'msg msg-ai';
const detachedResolved = _attachedImageForTurn(detachedAi, { kind: 'open_and_apply' });
return {
authValidUpload,
authValidGallery,
authBlob,
authCrossOriginBlob,
authMalformedBlob,
authDataPng,
authDataJpeg,
authDataWebp,
authDataGif,
authDataSvg,
authDataHtml,
authDataMalformed,
authDataOversized,
authMaliciousDomain,
authSchemeRelative,
authJavascript,
authDirectoryTraversal,
authEncodedTraversal,
turn1Resolved,
turn2Resolved,
turn3Resolved,
explicitActionResolved,
filenameResolved,
invalidExplicitResolved,
detachedResolved,
};
});
// Verify Scenario 1: Authorization
assert.equal(results.authValidUpload, true, 'Valid same-origin upload is authorized');
assert.equal(results.authValidGallery, true, 'Valid same-origin gallery is authorized');
assert.equal(results.authBlob, false, 'Same-origin alone does not authorize a blob URL');
assert.equal(results.authCrossOriginBlob, false, 'Cross-origin blob URL is rejected');
assert.equal(results.authMalformedBlob, false, 'Malformed blob URL is rejected');
assert.equal(results.authDataPng, true, 'PNG data URL is authorized');
assert.equal(results.authDataJpeg, true, 'JPEG data URL is authorized');
assert.equal(results.authDataWebp, true, 'WebP data URL is authorized');
assert.equal(results.authDataGif, true, 'GIF data URL is authorized');
assert.equal(results.authDataSvg, false, 'SVG data URL is rejected');
assert.equal(results.authDataHtml, false, 'HTML data URL is rejected');
assert.equal(results.authDataMalformed, false, 'Malformed data URL is rejected');
assert.equal(results.authDataOversized, false, 'Oversized data URL > 10MB is rejected');
assert.equal(results.authMaliciousDomain, false, 'External malicious domain rejected');
assert.equal(results.authSchemeRelative, false, 'Scheme-relative URL rejected');
assert.equal(results.authJavascript, false, 'Javascript scheme rejected');
assert.equal(results.authDirectoryTraversal, false, 'Directory traversal in path rejected');
assert.equal(results.authEncodedTraversal, false, 'Encoded directory traversal in path rejected');
// Verify Scenario 2: Multi-turn isolation
assert.equal(results.turn1Resolved, '/api/upload/img_turn1', 'Turn 1 AI correctly resolves Turn 1 user image');
assert.equal(results.turn2Resolved, null, 'Turn 2 AI resolves NULL because Turn 2 had no attached image');
assert.equal(results.turn3Resolved, '/api/upload/img_turn3', 'Turn 3 AI correctly resolves Turn 3 user image');
assert.equal(results.explicitActionResolved, '/api/upload/explicit_999', 'Explicit action imageId honored');
assert.equal(results.filenameResolved, '/api/upload/explicit_999.png', 'Canonical filenames keep their extensions');
assert.equal(results.invalidExplicitResolved, null, 'Invalid explicit data never falls back to a different image');
assert.equal(results.detachedResolved, null, 'Detached AI container without user message resolves to null');
// --- Scenario 3: Button click lifecycle (deleted upload, rapid click, open apply) ---
const clickResults = await page.evaluate(async () => {
const hooks = window.__chatPhotoEditorTestHooks;
const { renderPhotoEditorApplyButton } = hooks;
let editorCalls = [];
window.gePhotoEditorApply = async (opts) => {
editorCalls.push(opts);
await new Promise((r) => setTimeout(r, 50));
};
let errorsShown = [];
const fakeUi = {
showError(msg) { errorsShown.push(msg); },
showToast(msg) {},
};
// Case A: Missing attachment on Turn 2
const ai2 = document.querySelectorAll('.msg-ai')[1];
renderPhotoEditorApplyButton(ai2, { kind: 'open_and_apply', prompt: 'test turn 2' });
const btn2 = ai2.querySelector('.chat-open-in-editor-btn');
btn2.click();
await new Promise((r) => setTimeout(r, 20));
const btn2DisabledAfterMissing = btn2.disabled;
// Case B: Deleted upload (server returns 404)
const userDel = document.createElement('div');
userDel.className = 'msg msg-user';
userDel.innerHTML = '<div class="attach-image-preview" data-file-id="img_deleted"><img src="/api/upload/img_deleted" /></div>';
document.getElementById('chat-history').appendChild(userDel);
const aiDel = document.createElement('div');
aiDel.className = 'msg msg-ai round-holder';
document.getElementById('chat-history').appendChild(aiDel);
renderPhotoEditorApplyButton(aiDel, { kind: 'open_and_apply', prompt: 'test deleted' });
const btnDel = aiDel.querySelector('.chat-open-in-editor-btn');
return {
hasBtn2: !!btn2,
btn2DisabledAfterMissing,
};
});
assert.equal(clickResults.hasBtn2, true);
assert.equal(clickResults.btn2DisabledAfterMissing, false, 'Button re-enabled after missing attachment');
// Set server status 404 for img_deleted
serverResponses.set('img_deleted', 404);
const deletedResults = await page.evaluate(async () => {
const aiDel = document.querySelectorAll('.msg-ai')[3];
const btnDel = aiDel.querySelector('.chat-open-in-editor-btn');
btnDel.click();
await new Promise((r) => setTimeout(r, 50));
return {
disabled: btnDel.disabled,
};
});
assert.equal(deletedResults.disabled, false, 'Button re-enabled when server returns 404');
// Case C: Valid attachment on Turn 1 click launches editor
const validResults = await page.evaluate(async () => {
let editorCalls = [];
window.gePhotoEditorApply = async (opts) => {
editorCalls.push(opts);
};
const ai1 = document.querySelectorAll('.msg-ai')[0];
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
renderPhotoEditorApplyButton(ai1, { kind: 'open_and_apply', prompt: 'make b&w' });
const btn1 = ai1.querySelector('.chat-open-in-editor-btn');
// Rapid clicks: trigger multiple synchronous clicks
btn1.click();
btn1.click();
btn1.click();
await new Promise((r) => setTimeout(r, 50));
return {
editorCallsCount: editorCalls.length,
editorCall: editorCalls[0],
};
});
assert.equal(validResults.editorCallsCount, 1, 'Rapid clicks deduplicated, editor called exactly once');
assert.equal(validResults.editorCall.imageUrl, '/api/upload/img_turn1', 'Editor opened with Turn 1 image');
assert.equal(validResults.editorCall.prompt, 'make b&w');
// Case D: Verify HEAD probe was used for verification
const headProbeObserved = recordedUploadRequests.some((r) => r.id === 'img_turn1' && r.method === 'HEAD');
assert.equal(headProbeObserved, true, 'Chat-to-editor preflight executed an authorized HEAD probe');
// Case E: Unauthorized upload returns 403, surfaces error and re-enables button
serverResponses.set('img_forbidden', 403);
const forbiddenResults = await page.evaluate(async () => {
const userForbidden = document.createElement('div');
userForbidden.className = 'msg msg-user';
userForbidden.innerHTML = '<div class="attach-image-preview" data-file-id="img_forbidden"><img src="/api/upload/img_forbidden" /></div>';
document.getElementById('chat-history').appendChild(userForbidden);
const aiForbidden = document.createElement('div');
aiForbidden.className = 'msg msg-ai round-holder';
document.getElementById('chat-history').appendChild(aiForbidden);
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
renderPhotoEditorApplyButton(aiForbidden, { kind: 'open_and_apply', prompt: 'forbidden test' });
const btnForb = aiForbidden.querySelector('.chat-open-in-editor-btn');
btnForb.click();
await new Promise((r) => setTimeout(r, 50));
return {
disabled: btnForb.disabled,
};
});
assert.equal(forbiddenResults.disabled, false, 'Button re-enabled when server returns 403');
} finally {
await browser.close();
}
});
test('editor actions validate raster bytes, dimensions, decode and reject all unverified blobs before launch', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const apiRequests = [];
await page.route('http://raster.local/**', async route => {
const path = new URL(route.request().url()).pathname;
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<div id="chat-history"></div>' });
if (path === '/api/gallery/owned') { apiRequests.push(path); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ id: 'owned', url: '/api/generated-image/owned.png' }) }); }
if (path === '/api/gallery/foreign') { apiRequests.push(path); return route.fulfill({ status: 404, body: '' }); }
if (path.startsWith('/api/upload/')) { apiRequests.push(path); return route.fulfill({ status: 200, headers: { 'Content-Type': 'image/png' }, body: '' }); }
if (path.startsWith('/api/')) return route.fulfill({ contentType: 'application/json', body: '{}' });
try { return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') }); }
catch { return route.fulfill({ status: 404, body: '' }); }
});
await page.goto('http://raster.local/');
const results = await page.evaluate(async () => {
await import('/static/js/chat.js');
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 2;
canvas.getContext('2d').fillRect(0, 0, 2, 2);
const png = canvas.toDataURL('image/png');
const bytes = Uint8Array.from(atob(png.split(',')[1]), ch => ch.charCodeAt(0));
const encode = bytes => { let text = ''; for (const byte of bytes) text += String.fromCharCode(byte); return 'data:image/png;base64,' + btoa(text); };
const headerDimensions = (w, h) => { const b = bytes.slice(); const v = new DataView(b.buffer); v.setUint32(16, w); v.setUint32(20, h); return encode(b); };
const invalidDecode = bytes.slice(0, 33); // valid signature/dimensions but no pixel data
const ownBlob = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }));
const revoked = URL.createObjectURL(new Blob([bytes], { type: 'image/png' })); URL.revokeObjectURL(revoked);
// This action intentionally has no blob registration API. Even application-created
// object URLs must use their authorized server attachment or raster data source.
const cases = [
['upload', '/api/upload/owned', true], ['gallery', '/api/gallery/owned', true],
['png', png, true], ['jpeg', canvas.toDataURL('image/jpeg'), true], ['webp', canvas.toDataURL('image/webp'), true],
['gif', 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', true],
['padding-one', 'data:image/png;base64,=', false], ['padding-length', 'data:image/png;base64,A', false],
['padding-middle', 'data:image/png;base64,AA=AA', false], ['padding-excess', 'data:image/png;base64,AAAA====', false], ['foreign-gallery', '/api/gallery/foreign', false],
['pad-bits', 'data:image/png;base64,AB==', false], ['bytes', 'data:image/png;base64,AAAA', false],
['mismatch', png.replace('image/png', 'image/jpeg'), false], ['truncated-image', encode(invalidDecode), false],
['svg', 'data:image/svg+xml;base64,PHN2Zy8+', false], ['html', 'data:text/html;base64,PGgxLz4=', false],
['svg-disguised', 'data:image/png;base64,PHN2Zy8+', false],
['foreign-blob', 'blob:https://foreign.invalid/deadbeef', false], ['arbitrary-blob', 'blob:http://raster.local/deadbeef', false],
['application-created-blob', ownBlob, false], ['revoked-blob', revoked, false],
['oversized-url', 'data:image/png;base64,' + 'A'.repeat(10 * 1024 * 1024), false],
['oversized-decoded-bytes', 'data:image/png;base64,' + 'A'.repeat(4 * Math.ceil((7 * 1024 * 1024 + 3) / 3)), false],
['dimension', headerDimensions(8193, 1), false], ['pixel-budget', headerDimensions(8192, 8192), false],
['scheme-relative', '//raster.local/api/upload/owned', false], ['encoded-traversal', '/api/upload/%2e%2e/owned', false],
];
const oldDecode = window.createImageBitmap; let decodes = 0;
window.createImageBitmap = (...args) => { decodes++; return oldDecode(...args); };
let launches = []; window.gePhotoEditorApply = async opts => launches.push(opts.imageUrl);
const output = [];
for (const [name, url, accepted] of cases) {
const host = document.createElement('div'); document.body.append(host);
renderPhotoEditorApplyButton(host, { kind: 'open_and_apply', imageUrl: url, prompt: 'synthetic edit' });
const btn = host.querySelector('button'); const before = launches.length; const decodeBefore = decodes;
btn.click(); btn.click(); btn.click();
while (btn.disabled) await new Promise(resolve => setTimeout(resolve, 5));
output.push({ name, accepted, launches: launches.length - before, decodes: decodes - decodeBefore }); host.remove();
}
// Cache reuse across repeated activation of the same complete inline image.
const host = document.createElement('div'); document.body.append(host);
renderPhotoEditorApplyButton(host, { kind: 'open_and_apply', imageUrl: png, prompt: 'repeat' });
const btn = host.querySelector('button'); const before = decodes;
for (let i = 0; i < 3; i++) { btn.click(); while (btn.disabled) await new Promise(resolve => setTimeout(resolve, 5)); }
const cachedDecodes = decodes - before;
URL.revokeObjectURL(ownBlob);
return { cases: output, cachedDecodes, launches };
});
assert.deepEqual(results.cases.filter(item => item.launches !== (item.accepted ? 1 : 0)).map(item => item.name), [], 'every raster authorization case must match');
for (const name of ['dimension', 'pixel-budget', 'oversized-url', 'oversized-decoded-bytes', 'bytes', 'mismatch']) {
assert.equal(results.cases.find(item => item.name === name).decodes, 0, name + ' rejected before browser allocation');
}
assert.equal(results.cases.find(item => item.name === 'truncated-image').decodes, 1, 'actual decoder rejects incomplete image');
assert.equal(results.cachedDecodes, 1, 'same image only decoded once for repeated activation');
assert.deepEqual(apiRequests, ['/api/upload/owned', '/api/gallery/owned', '/api/gallery/foreign']);
assert.ok(results.launches.includes('http://raster.local/api/generated-image/owned.png'));
} finally { await browser.close(); }
});
+67
View File
@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const src = await readFile(new URL('../static/js/sessions.js', import.meta.url), 'utf8');
const START = '// === copy-chat header (debug trace block) ===';
const END = '// === end copy-chat header ===';
const block = src.slice(src.indexOf(START) + START.length, src.indexOf(END));
const extract = new Function(block + '\nreturn { _buildCopyChatHeader, _toolSurfaceLabel, _formatJst };');
const { _buildCopyChatHeader, _toolSurfaceLabel, _formatJst } = extract();
test('copy-chat header has exact shape and values', () => {
const header = _buildCopyChatHeader({
version: '1.0.3',
build: '20261006.1',
sourceCommit: 'a1b2c3d4e5f6',
model: 'gpt-4o',
endpointUrl: 'https://api.example.com/v1',
toolSurface: 'Ajax Spec (router)',
sessionId: 'sess-123',
copiedAt: new Date(Date.UTC(2026, 9, 13, 5, 30)),
});
const expected = [
'--- Odysseus info ---',
'Odysseus: 1.0.3 | Harness build: 20261006.1 | Commit: a1b2c3d4',
'Model: gpt-4o @ https://api.example.com/v1 | Tool surface: Ajax Spec (router)',
'Copied: 2026-10-13 14:30 JST | Session: sess-123',
'----------------------',
].join('\n');
assert.equal(header, expected);
});
test('unknown fields are never omitted', () => {
const header = _buildCopyChatHeader({});
const expected = [
'--- Odysseus info ---',
'Odysseus: unknown | Harness build: unknown | Commit: unknown',
'Model: unknown @ unknown | Tool surface: unknown',
'Copied: unknown JST | Session: unknown',
'----------------------',
].join('\n');
assert.equal(header, expected);
});
test('closing separator is exactly 22 dashes', () => {
const header = _buildCopyChatHeader({});
const lines = header.split('\n');
assert.equal(lines.length, 5);
assert.equal(lines[lines.length - 1], '----------------------');
assert.equal(lines[lines.length - 1].length, 22);
});
test('tool surface labels map modes to human labels', () => {
assert.equal(_toolSurfaceLabel('full'), 'Regular tools');
assert.equal(_toolSurfaceLabel('compact'), 'Ajax Compact');
assert.equal(_toolSurfaceLabel('spec'), 'Ajax Spec (router)');
assert.equal(_toolSurfaceLabel('none'), 'Tools off');
assert.equal(_toolSurfaceLabel(''), 'Auto');
assert.equal(_toolSurfaceLabel(undefined), 'Auto');
assert.equal(_toolSurfaceLabel(null), 'Auto');
assert.equal(_toolSurfaceLabel('weird'), 'unknown');
});
test('JST timestamp is UTC+9 with HH:MM padding', () => {
assert.equal(_formatJst(new Date(Date.UTC(2026, 0, 1, 0, 5))), '2026-01-01 09:05');
assert.equal(_formatJst(new Date(Date.UTC(2026, 11, 31, 15, 0))), '2027-01-01 00:00');
});
+68 -68
View File
@@ -1,10 +1,10 @@
{
"digest": "f7e60e4a567c46dc",
"digest": "67c4508478daa8e0",
"elements": {
"app-shell": {
"app-loader": "f04ad5bf6312d53f",
"attach-strip": "a6474951e2481214",
"body": "4956f449e6a30e93",
"body": "4a4b8a158ea6b360",
"chat-container": "b00a7c6be4e107b1",
"chat-context-pill": "dd905b87245a9a71",
"chat-history": "e65bdc51f4d1675f",
@@ -17,7 +17,7 @@
"export-dropdown-item": "d90f6f513d06a6f7",
"export-dropdown-menu": "0793e06a6aae6a7a",
"hamburger-btn": "34714f6eef3734d8",
"icon-rail": "e3f72205260a4a5f",
"icon-rail": "c800b4ef07f5f1f0",
"icon-rail-btn": "54ffb10e056ebf44",
"incognito-btn": "ee42bd3f6a32576b",
"input-icon-btn": "3d1c773acd84a825",
@@ -42,7 +42,7 @@
"rail-new-chat": "f8f1c2f6be426ab9",
"reasoning-effort-btn": "2a07cdd8ec2b4d26",
"rename-session-modal": "9345624780009d14",
"root": "a9f7ace2c1cefb24",
"root": "a6694a5a5dbfbe44",
"save-custom-preset": "443da70ef5fe9b88",
"scroll-bottom-btn": "24602d72e2949991",
"search-input": "53b94f3e5fdb384d",
@@ -59,7 +59,7 @@
"settings-row": "de568724268cec6e",
"settings-select": "a96e8430b0ec6668",
"settings-sidebar-toggle": "6be9025ab24bc434",
"sidebar": "77ec42a646a8db66",
"sidebar": "0dbf510da61afa07",
"sidebar-brand": "d3d7ef4b21514e80",
"sidebar-new-chat": "567fae30048b7ff3",
"sidebar-resize-handle": "6607b819ee45409d",
@@ -399,10 +399,10 @@
".ge-adj-icon": "405c77944c2f21c7",
".ge-adj-popup": "e1a93233ab11f043",
".ge-adj-row": "fed828d535ba710b",
".ge-adj-row > label": "21bd09c7cf6167e5",
".ge-adj-row > label": "92541b678e8ddd3e",
".ge-adj-row input[type=\"range\"]": "3f1690aad291a7da",
".ge-adj-title": "7609b8e4f862ad23",
".ge-ai-command-collapsed": "35a892b0246538ba",
".ge-ai-command-collapsed": "5a38c8ab93d1818f",
".ge-alpha-badge": "59eb1bb7eddf6df0",
".ge-canvas-area": "b6a7426fcd6ad230",
".ge-canvas-prompt": "97ddee153cdaf1f2",
@@ -430,11 +430,11 @@
".ge-layer-group-row .ge-layer-group-toggle": "cb04950c0a50aacb",
".ge-layer-group-row .ge-layer-lock-btn": "bb0f88ce29ae7637",
".ge-layer-group-row .ge-layer-opacity-row": "e697b236b3b4ba6e",
".ge-layer-group-row .ge-layer-vis": "2e6dbbab8d823b4f",
".ge-layer-group-row .ge-layer-vis": "ac882350a59601a1",
".ge-layer-lock-menu": "528deb6e31022e89",
".ge-layer-lock-option": "38a3da4a8785330a",
".ge-layers-grab": "094e764489b1b069",
".ge-layers-header": "459d17627f2e83e6",
".ge-layers-header": "1793db7034e4b67b",
".ge-layers-list": "0b4bfbd17d414d6d",
".ge-main-canvas": "274650d62945ac12",
".ge-mask-sub-item": "546f03f9c46eb507",
@@ -442,13 +442,13 @@
".ge-section-help": "8bd5ecd734f48ca2",
".ge-shortcuts-grid": "84d3ab6453dab9b3",
".ge-slider-bubble": "2bc54e40357f68cf",
".ge-tool-btn": "fd54cca726e51429",
".ge-tool-btn": "67f8bb05e2438d92",
".ge-tool-sep": "4ae61e4e4eddd504",
".ge-toolbar": "ca827cf012ad8863",
".ge-topbar": "6c39a6ade412d5fa",
".ge-toolbar": "0e05d7a6bc8d85c0",
".ge-topbar": "cdb801be32727e0b",
".ge-transform-field": "76a785f630632652",
".ge-transform-field > input.ge-transform-popup-input": "b870e4e9d5fc2d91",
".ge-transform-popup-body": "d9d70292e5be7acc",
".ge-transform-popup-body": "af7f9dceddb20374",
".ge-transform-popup-head .ge-adj-icon": "88ac29d44343e6fa",
".ge-transform-popup-head .ge-adj-title": "1d6ca66f2c39c9ef",
".ge-transform-popup-head .ge-head-btns": "ecd0a09cb27407d7",
@@ -456,9 +456,9 @@
".ge-transform-popup-suffix": "d55491303f919f61",
".ge-transform-quick-btn": "e74ea16e29b9b001",
".ge-transform-spin": "22dc5f2bfab2ec9a",
".ge-transform-spin button": "527810c9f1ac1be5",
".ge-transform-spin button[data-spin=\"down\"]": "c0aff9fcdb755ed2",
".ge-transform-spin button[data-spin=\"up\"]": "4fccf8fbe6c04fa9",
".ge-transform-spin button": "ad94130c6f1829d9",
".ge-transform-spin button[data-spin=\"down\"]": "9cb76293b224cf00",
".ge-transform-spin button[data-spin=\"up\"]": "4f6f103a02c3838d",
".hamburger": "2b0e4a6cb3333ded",
".hamburger span": "6e2fee4770672a20",
".hamburger-btn": "dc0f91fb82ccd472",
@@ -488,7 +488,7 @@
".hwfit-serve-topline .hwfit-serve-runtime-note": "15b3fef4e5ceaffc",
".hwfit-serve-topline .hwfit-serve-runtime-text": "57c6822b0d13147d",
".hwfit-spec-group .hwfit-spec-method": "5d1c329116272cf1",
".icon-rail": "e3f72205260a4a5f",
".icon-rail": "c800b4ef07f5f1f0",
".icon-rail-btn": "a5f207c3be15401e",
".incognito-btn": "9833a2e86fab6a94",
".incognito-indicator": "b3945c46844c7278",
@@ -617,15 +617,15 @@
".settings-sidebar.settings-sidebar-collapsed": "d9d8f25df95d9ad8",
".settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content": "608e51170864586a",
".settings-system-logs-tab": "d0d90a13ff327ace",
".sidebar": "77ec42a646a8db66",
".sidebar": "0dbf510da61afa07",
".sidebar .section": "37fc733d59519a08",
".sidebar-brand-title": "2b20f75768b50346",
".sidebar-header": "0d58816ca65b7216",
".sidebar-inner": "ad5e28fedbfeb5db",
".sidebar-notif-dot": "2673e215d0cd84a6",
".sidebar-user-bar": "b77372dbdcb069ef",
".sidebar.hidden": "0df301b638e2f93f",
".sidebar.right-side": "2b6bcc7d59f7b6bd",
".sidebar.hidden": "9021ec7e4e052131",
".sidebar.right-side": "bb6ed4427d07657e",
".skill-card > .skill-card-header": "e2408e762818d2fb",
".skill-card-tags": "a327c85e053e6d3f",
".skill-card-textcol": "f1059ed37c120b60",
@@ -686,56 +686,56 @@
},
"variants": {
"app-shell": {
"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"
"desktop-dark-comfortable": "6ccb1b611fff1417",
"desktop-dark-compact": "fa8811351d590698",
"desktop-dark-spacious": "8b23cc34d8dacb5d",
"desktop-light-comfortable": "4a98edc12f46490e",
"desktop-light-compact": "3712d798c576cf07",
"desktop-light-spacious": "4aee43267423df1f",
"laptop-dark-comfortable": "35682719e8aeea91",
"laptop-dark-compact": "a113f77e47ef20d6",
"laptop-dark-spacious": "a94cb0602b8ebf8d",
"laptop-light-comfortable": "b523ba8c4d5f5180",
"laptop-light-compact": "aff4e1e8a5c192a8",
"laptop-light-spacious": "dc83f76cba448444",
"phone-dark-comfortable": "10af205a321eb35d",
"phone-dark-compact": "1f2816f672a2ff9f",
"phone-dark-spacious": "10af205a321eb35d",
"phone-light-comfortable": "b781b1eec0f33d3e",
"phone-light-compact": "131a7b11605617bb",
"phone-light-spacious": "b781b1eec0f33d3e",
"tablet-dark-comfortable": "4e005f33938a3026",
"tablet-dark-compact": "3a389740245ce8e9",
"tablet-dark-spacious": "4e005f33938a3026",
"tablet-light-comfortable": "e7d5a710864503d3",
"tablet-light-compact": "e65e1bcd50cebf33",
"tablet-light-spacious": "e7d5a710864503d3"
},
"bench": {
"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",
"phone-light-comfortable": "ef876073cc2dc45e",
"phone-light-compact": "2ef6f5dc7a646be9",
"phone-light-spacious": "b0d4cd9bd0361966",
"tablet-dark-comfortable": "58922240b216e786",
"tablet-dark-compact": "3991c22f99a394e0",
"tablet-dark-spacious": "9848c3b7debac974",
"tablet-light-comfortable": "12dfbad36172de20",
"tablet-light-compact": "80d4843e622f1353",
"tablet-light-spacious": "c420b8d46c991a44"
"desktop-dark-comfortable": "48a5eeb1259fd422",
"desktop-dark-compact": "cef4e9f2ceacefbb",
"desktop-dark-spacious": "d87bef476928e845",
"desktop-light-comfortable": "69a68072200b3669",
"desktop-light-compact": "9c6deaecd2265ad9",
"desktop-light-spacious": "8a355029e7261110",
"laptop-dark-comfortable": "aaf69c3500c38635",
"laptop-dark-compact": "ca5a5cb542ee1901",
"laptop-dark-spacious": "9f85415f735e040e",
"laptop-light-comfortable": "06caf416811d5778",
"laptop-light-compact": "f2bab4d31395dd49",
"laptop-light-spacious": "6c56a4469a2b42ff",
"phone-dark-comfortable": "eb27d09bf0ff4ca1",
"phone-dark-compact": "e60d82de1d36c09b",
"phone-dark-spacious": "3578747ada4bcbb2",
"phone-light-comfortable": "8b0b9f1e7d73d36f",
"phone-light-compact": "d2f58cb1c1675907",
"phone-light-spacious": "edae71cdde26fd58",
"tablet-dark-comfortable": "8fc2eb75bed6417d",
"tablet-dark-compact": "2fd7fb77dfb379ba",
"tablet-dark-spacious": "1376b658cfc7cb4b",
"tablet-light-comfortable": "b456d13e3bbf627e",
"tablet-light-compact": "44eb1164a0db4654",
"tablet-light-spacious": "5d999940aa547f86"
},
"login": {
"desktop-dark-comfortable": "3cf4809db0f8e119",
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN || 'http://127.0.0.1:7011';
const browser = await chromium.launch({ headless: true });
try {
for (const width of [390, 900]) {
const page = await browser.newPage({ viewport: { width, height: 844 }, acceptDownloads: true });
if (!process.env.ODYSSEUS_TEST_STATIC_ORIGIN) {
await page.route(`${origin}/**`, async route => {
const url = new URL(route.request().url());
const rel = url.pathname.replace(/^\//, '');
try {
const file = new URL('../' + rel, import.meta.url);
const body = await readFile(file);
const ext = rel.split('.').pop();
const type = ext === 'css' ? 'text/css' : ext === 'js' ? 'text/javascript' : 'application/octet-stream';
return route.fulfill({ contentType: type, body });
} catch {
return route.fulfill({ status: 404, body: 'Not found' });
}
});
}
await page.goto(`${origin}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/css/00-tokens.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?mobile-export-regression=1');
mod.init('/api');
mod.injectFreshDoc({ id: 'export-test', title: 'Export test', language: 'markdown', version_count: 1, current_content: '# Mobile export\n\nActual document content.' });
});
await page.locator('#doc-footer-export-btn').waitFor({ state: 'visible' });
await page.evaluate(() => {
document.querySelector('.doc-editor-pane').style.setProperty('z-index', '12000', 'important');
});
await page.locator('#doc-footer-export-btn').click();
const menu = page.locator('#doc-export-menu');
await menu.waitFor({ state: 'visible' });
const bounds = await menu.boundingBox();
assert(bounds.x >= 0 && bounds.y >= 0);
assert(bounds.x + bounds.width <= width && bounds.y + bounds.height <= 844);
assert(await menu.evaluate(el => Number(getComputedStyle(el).zIndex) > 12000));
await page.screenshot({ path: `/tmp/document-export-${width}.png` });
// A second click closes the menu, then reopening must still allow export.
await page.locator('#doc-footer-export-btn').click();
assert.equal(await menu.count(), 0);
await page.locator('#doc-footer-export-btn').click();
const pending = page.waitForEvent('download');
await page.locator('#doc-export-menu button').filter({ hasText: /^Export Markdown$/ }).click();
const download = await pending;
assert(download.suggestedFilename().endsWith('.md'));
assert.equal(await download.failure(), null);
const stream = await download.createReadStream();
let content = '';
for await (const chunk of stream) content += chunk;
assert.equal(content, '# Mobile export\n\nActual document content.');
await page.close();
console.log(`PASS ${width}px: visible menu, toggle, download contents`);
}
} finally {
await browser.close();
}
+7 -2
View File
@@ -1,7 +1,7 @@
# Browser End-to-End Tests
The photo editor release gate launches an isolated, authentication-disabled
Odysseus server on port `7013` with a temporary SQLite database.
The photo editor release gate launches an isolated Odysseus server on port
`7013` with a temporary SQLite database and disposable authenticated account.
```bash
npm install
@@ -13,3 +13,8 @@ Set `PHOTO_EDITOR_E2E_PORT` to use another port. Failure screenshots and traces
are written to `test-results/photo-editor/`. The server uses the repository
`.venv` automatically; set `ODYSSEUS_TEST_PYTHON` to another Python executable
when using a different environment layout.
The disposable account is created in the temporary data directory and exercises
owner-scoped draft and notification routes without using an existing account.
`PHOTO_EDITOR_E2E_AUTH=0` selects the legacy unauthenticated configuration for
investigating it; offline-upgrade coverage requires the authenticated mode.
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { chooseFilterAction, selectTool } = require('./helpers.js');
const {
dragOnCanvas,
encodedImagePixelDigest,
@@ -10,7 +11,7 @@ const {
} = require('./helpers.js');
async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click();
await page.locator('#ge-add-fx').click();
const menu = page.locator('.ge-add-layer-menu');
await expect(menu).toBeVisible();
await menu.locator(`[data-adjustment-type="${type}"]`).click();
@@ -19,7 +20,7 @@ async function addAdjustment(page, type) {
test('Levels is a retained stack layer with clipping, masks, history, and persistence', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 260 }, 'Adjustment layers E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.18, y: 0.25 }, { x: 0.78, y: 0.7 });
const before = await flattenedPixelDigest(page);
@@ -36,9 +37,9 @@ test('Levels is a retained stack layer with clipping, masks, history, and persis
expect(await flattenedPixelDigest(page)).not.toEqual(before);
const row = page.locator(`.ge-layer-item[data-layer-id="${levels.id}"]`);
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-clip-btn').click();
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).clipped).toBe(true);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === levels.id).masks).toHaveLength(1);
@@ -89,7 +90,7 @@ test('Curves supports editable RGB and channel points with reset and cancel', as
expect(curves.adjustment.params.points.blue).toHaveLength(3);
const row = page.locator(`.ge-layer-item[data-layer-id="${curves.id}"]`);
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-fx-btn').click();
await page.locator('[data-adj-action="reset"]').click();
await page.locator('[data-adj-action="cancel"]').click();
current = await editorState(page);
@@ -134,7 +135,7 @@ test('color adjustment controls retain neutral reusable parameters', async ({ pa
const nextDigest = await flattenedPixelDigest(page);
expect(nextDigest).not.toBe(priorDigest);
if (item.compare) {
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-fx-btn').click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
await page.locator('[data-adj-action="compare"]').click();
expect(await flattenedPixelDigest(page)).toEqual(priorDigest);
@@ -247,12 +248,11 @@ test('all adjustment popups remain usable on a narrow phone viewport', async ({
test('retained Gaussian Blur survives flattening and draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained effects E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
const before = await flattenedPixelDigest(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await chooseFilterAction(page, 'effect-blur-gaussian');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -281,8 +281,7 @@ test('retained Gaussian Blur survives flattening and draft reopen', async ({ pag
test('retained Sharpen keeps editable amount and survives draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained sharpen E2E');
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-preset-crisp-detail"]').click();
await chooseFilterAction(page, 'effect-preset-crisp-detail');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
expect(await page.locator('.ge-filter-row input[data-key="amount"]').inputValue()).toBe('35');
await page.locator('.ge-filter-row input[data-key="amount"]').fill('75');
@@ -301,22 +300,20 @@ test('retained Sharpen keeps editable amount and survives draft reopen', async (
test('retained overlay and shadow effects keep editable metadata', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained color effects E2E');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(canvasBox.x + 24, canvasBox.y + 24);
await page.mouse.down();
await page.mouse.move(canvasBox.x + 150, canvasBox.y + 140);
await page.mouse.up();
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await chooseFilterAction(page, 'effect-color-overlay');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
await page.locator('button[title="Add effect mask from selection"]').click();
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-drop-shadow"]').click();
await chooseFilterAction(page, 'effect-drop-shadow');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="blur"]').fill('18');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -345,10 +342,9 @@ test('retained overlay and shadow effects keep editable metadata', async ({ page
test('retained Stroke uses layer alpha and persists its controls', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-stroke"]').click();
await chooseFilterAction(page, 'effect-stroke');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="width"]').fill('9');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -1,5 +1,9 @@
const { test, expect } = require('@playwright/test');
// These tests borrow /login as a lightweight page. A signed-in session would
// make the login page redirect to the app mid-test, so run them signed out.
test.use({ storageState: { cookies: [], origins: [] } });
async function addAppStyles(page) {
const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs');
for (const url of await stylesheetUrls()) {
+2 -1
View File
@@ -1,9 +1,10 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { openBlankEditor } = require('./helpers.js');
test('brush cursor matches the rendered brush diameter and replaces the crosshair', async ({ page }) => {
await openBlankEditor(page, { width: 400, height: 300 });
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
+32 -16
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
async function canvasPoint(page, xRatio, yRatio) {
@@ -8,18 +9,23 @@ async function canvasPoint(page, xRatio, yRatio) {
test('brush presets and shared stroke controls remain reusable', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
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');
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');
@@ -31,10 +37,12 @@ test('brush presets and shared stroke controls remain reusable', async ({ page }
test('long sampled strokes stay responsive and undo atomically', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
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());
@@ -46,7 +54,8 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page }
await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 240 });
await page.mouse.up();
expect(Date.now() - startedAt).toBeLessThan(5000);
const strokeDuration = Date.now() - startedAt;
expect(strokeDuration).toBeLessThan(5000);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
await expect(thumb).toHaveAttribute('width', '68');
@@ -59,7 +68,7 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page }
test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#33aa55';
input.dispatchEvent(new Event('input', { bubbles: true }));
@@ -67,22 +76,25 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
const sample = await canvasPoint(page, 0.3, 0.3);
await page.mouse.click(sample.x, sample.y);
await page.locator('.ge-tool-btn[data-tool="eyedropper"]').click();
await selectTool(page, 'eyedropper');
await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
await page.locator('#ge-eyedropper-sample').selectOption('composite');
await page.mouse.move(sample.x, sample.y);
// The options strip height differs per tool, which re-centres the canvas a
// few pixels; hover the same image point that was painted.
const painted = await canvasPoint(page, 0.3, 0.3);
await page.mouse.move(painted.x, painted.y);
await expect(page.locator('#ge-eyedropper-live-value')).toHaveText('#33aa55');
await expect(page.locator('#ge-eyedropper-live-rgb')).toHaveText('RGB 51 170 85');
await expect(page.locator('#ge-eyedropper-live-hsl')).toHaveText('HSL 137 54% 43%');
await expect(page.locator('#ge-eyedropper-live-swatch')).not.toHaveClass(/empty/);
await expect(page.locator('#ge-eyedropper-loupe')).toBeVisible();
expect((await page.locator('#ge-eyedropper-loupe').evaluate(canvas => canvas.getContext('2d').getImageData(42, 42, 1, 1).data[3]))).toBe(255);
await page.mouse.click(sample.x, sample.y);
await page.mouse.click(painted.x, painted.y);
await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55');
for (const tool of ['dodge', 'burn']) {
const before = await flattenedPixelDigest(page);
await page.locator(`.ge-tool-btn[data-tool="${tool}"]`).click();
await selectTool(page, tool);
const from = await canvasPoint(page, 0.35, tool === 'dodge' ? 0.45 : 0.6);
const to = await canvasPoint(page, 0.65, tool === 'dodge' ? 0.45 : 0.6);
await page.mouse.move(from.x, from.y);
@@ -95,13 +107,13 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
}
const blemish = await canvasPoint(page, 0.72, 0.3);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#111111';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.mouse.click(blemish.x, blemish.y);
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
await selectTool(page, 'heal');
const beforeHeal = await flattenedPixelDigest(page);
const healTarget = blemish;
await page.mouse.move(healTarget.x, healTarget.y);
@@ -115,7 +127,7 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
test('healing brush can use an optional sampled source', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Sampled healing E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.locator('.ge-size-slider').fill('420');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#d24b62';
@@ -124,7 +136,7 @@ test('healing brush can use an optional sampled source', async ({ page }) => {
const source = await canvasPoint(page, 0.28, 0.5);
await page.mouse.click(source.x, source.y);
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
await selectTool(page, 'heal');
await page.keyboard.down('Alt');
await page.mouse.click(source.x, source.y);
await page.keyboard.up('Alt');
@@ -153,7 +165,7 @@ test('healing brush can use an optional sampled source', async ({ page }) => {
test('smudge carries nearby pixels along a stroke and undoes atomically', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Smudge E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.locator('.ge-size-slider').fill('700');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#d24b62';
@@ -169,8 +181,12 @@ test('smudge carries nearby pixels along a stroke and undoes atomically', async
const y = Math.round(canvas.height * 0.5);
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
});
await page.locator('.ge-tool-btn[data-tool="smudge"]').click();
await selectTool(page, 'smudge');
// Smudge strength lives in the brush-family "More" menu with the other
// per-tool stroke modifiers.
await page.locator('#ge-brush-more').click();
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
await page.keyboard.press('Escape');
await page.mouse.move(source.x, source.y);
await page.mouse.down();
await page.mouse.move(target.x, target.y, { steps: 20 });
+2 -1
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor } = require('./helpers.js');
async function mainCanvasHash(page) {
@@ -26,7 +27,7 @@ test('before compare is visual-only and toggles back to the edited document', as
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
const baselineHash = await mainCanvasHash(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
const editedState = await editorState(page);
const editedHash = await mainCanvasHash(page);
+6 -5
View File
@@ -1,5 +1,6 @@
const fs = require('node:fs');
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const {
compareExportPixels,
dragOnCanvas,
@@ -16,7 +17,7 @@ test('layered document survives edit, mask, transform, crop, reopen, and export'
await openBlankEditor(page);
const initial = await editorState(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.20, y: 0.30 }, { x: 0.72, y: 0.60 });
const painted = await editorState(page);
const paintedLayer = painted.layers.find(layer => layer.name === 'Edit');
@@ -25,11 +26,11 @@ test('layered document survives edit, mask, transform, crop, reopen, and export'
const editItem = page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first();
await editItem.click();
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await editItem.locator('.ge-true-mask-btn').click();
let current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').masks).toHaveLength(1);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-w')).toBeVisible();
await page.locator('#ge-transform-w').fill('560');
await page.locator('#ge-transform-apply').click();
@@ -43,7 +44,7 @@ test('layered document survives edit, mask, transform, crop, reopen, and export'
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').size[0]).toBe(560);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
await expect(page.locator('#ge-text-section')).toBeVisible();
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvasBox.x + canvasBox.width * 0.28, canvasBox.y + canvasBox.height * 0.22);
@@ -52,7 +53,7 @@ test('layered document survives edit, mask, transform, crop, reopen, and export'
await page.locator('#ge-text-size').press('Enter');
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Durable photo edit' })).toBeVisible();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
await dragOnCanvas(page, { x: 0.08, y: 0.10 }, { x: 0.92, y: 0.88 });
await expect(page.locator('.ge-crop-apply-btn')).toBeVisible();
await page.locator('.ge-crop-apply-btn').click();
@@ -1,12 +1,13 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor, settledCanvasBox } = require('./helpers.js');
test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Gesture cancellation');
const canvas = page.locator('.ge-main-canvas');
const box = await canvas.boundingBox();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
await page.mouse.move(box.x + 40, box.y + 35);
await page.mouse.down();
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
@@ -14,7 +15,7 @@ test('tool switching cancels incomplete crop and selection gestures without stal
const { state } = await import('/static/js/editor/state.js');
return { cropping: state.cropping, rect: state.cropRect };
})).toMatchObject({ cropping: true, rect: { w: 150, h: 115 } });
await page.locator('.ge-tool-btn[data-tool="move"]').dispatchEvent('click');
await selectTool(page, 'move');
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { tool: state.tool, cropping: state.cropping, moving: state.cropMoving, rect: state.cropRect };
@@ -22,7 +23,7 @@ test('tool switching cancels incomplete crop and selection gestures without stal
await page.mouse.up();
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
const beforeMove = await editorState(page);
const bounds = beforeMove.selection.bounds;
@@ -33,7 +34,7 @@ test('tool switching cancels incomplete crop and selection gestures without stal
await page.mouse.down();
await page.mouse.move(box.x + bounds.x + bounds.width / 2 + 35, box.y + bounds.y + bounds.height / 2 + 20);
expect((await editorState(page)).selection.bounds).not.toEqual(bounds);
await page.locator('.ge-tool-btn[data-tool="brush"]').dispatchEvent('click');
await selectTool(page, 'brush');
const afterSwitch = await editorState(page);
expect(afterSwitch.selection.bounds).toEqual(bounds);
expect(afterSwitch.undo).toBe(beforeMove.undo);
@@ -49,7 +50,7 @@ test('Escape cancels an active crop without changing the document', async ({ pag
await openBlankEditor(page, { width: 320, height: 240 }, 'Escape crop cancellation');
const canvas = page.locator('.ge-main-canvas');
const box = await canvas.boundingBox();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
await page.mouse.move(box.x + 40, box.y + 35);
await page.mouse.down();
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
@@ -74,13 +75,15 @@ test('touch crop and selection gestures complete through the shared lifecycle',
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch gestures');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
// Picking a tool minimizes the phone Layers sheet and refits the canvas;
// aim at where the canvas settles, as a finger would.
const canvasBox = await settledCanvasBox(page);
await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30);
await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145);
await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145);
@@ -90,7 +93,7 @@ test('touch crop and selection gestures complete through the shared lifecycle',
return state.cropRect;
})).toMatchObject({ w: 155, h: 115 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await touch('touchStart', canvasBox.x + 45, canvasBox.y + 40);
await touch('touchMove', canvasBox.x + 165, canvasBox.y + 125);
await touch('touchEnd', canvasBox.x + 165, canvasBox.y + 125);
@@ -112,18 +115,24 @@ test('touch brush paints and undoes as one stroke', async ({ browser, browserNam
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch brush');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
// Re-tapping the active tool collapses its controls sheet (canvas-first
// phone editing), which also uncovers the Layers sheet grab handle.
await selectTool(page, 'brush');
const canvasBox = await settledCanvasBox(page);
const before = await flattenedPixelDigest(page);
await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5);
await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
await touch('touchEnd', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
// History buttons live in the Layers sheet, which the tool pick minimized.
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
} finally {
@@ -144,7 +153,7 @@ test('touch cancellation rolls back a partial brush stroke', async ({ browser, b
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch cancellation');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
const before = await flattenedPixelDigest(page);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
@@ -168,7 +177,7 @@ test('touch cancellation rolls back a partial brush stroke', async ({ browser, b
test('pen input completes a crop gesture', async ({ page, context, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP pen injection');
await openBlankEditor(page, { width: 320, height: 240 }, 'Pen crop gesture');
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
const box = await page.locator('.ge-main-canvas').boundingBox();
const cdp = await context.newCDPSession(page);
await cdp.send('Input.dispatchMouseEvent', {
@@ -39,7 +39,7 @@ async function downloadFormat(page, format, filename) {
}
async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click();
await page.locator('#ge-add-fx').click();
await expect(page.locator('.ge-add-layer-menu')).toBeVisible();
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${type}"]`).click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
@@ -100,7 +100,7 @@ test('composited correction exports preserve pixels and format metadata', async
await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity');
await seedGradient(page);
await page.locator('#ge-add-layer').click();
await page.locator('#ge-add-fx').click();
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('35');
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
+5 -6
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { chooseFilterAction, selectTool } = require('./helpers.js');
const fs = require('node:fs');
const {
compareExportPixels,
@@ -23,7 +24,7 @@ async function downloadPng(page, filename) {
test('group retained effects edit, toggle, reorder surface, and survive reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 360, height: 240 }, 'Group effects E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 48;
@@ -37,8 +38,7 @@ test('group retained effects edit, toggle, reorder surface, and survive reopen',
const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await chooseFilterAction(page, 'effect-blur-gaussian', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -73,7 +73,7 @@ test('group retained effects edit, toggle, reorder surface, and survive reopen',
test('group retained effects export the visible composite and survive reopen', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Group effects export E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 64;
@@ -83,8 +83,7 @@ test('group retained effects export the visible composite and survive reopen', a
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await chooseFilterAction(page, 'effect-color-overlay', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('40');
+5 -4
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function compositeAlphaAtCenter(page) {
@@ -16,17 +17,17 @@ test('group mask paints, toggles, survives history and reopen, and protects ungr
await page.locator('#ge-group-selected').click();
const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await page.locator('#ge-layer-tools .ge-group-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-group-mask-btn').click();
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
await expect(page.locator('#ge-layer-tools button[title="Delete group masks before ungrouping"]')).toBeDisabled();
await expect(page.locator('.ge-layer-controls:visible button[title="Delete group masks before ungrouping"]')).toBeDisabled();
let current = await editorState(page);
expect(current.groups[0].masks).toHaveLength(1);
expect(current.groups[0].masks[0]).toMatchObject({ mode: 'group', size: [420, 300] });
const whiteMaskHash = current.groups[0].masks[0].pixelHash;
expect(await compositeAlphaAtCenter(page)).toBe(255);
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await selectTool(page, 'eraser');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 90;
@@ -62,7 +63,7 @@ test('group mask paints, toggles, survives history and reopen, and protects ungr
await page.locator('.ge-group-mask-sub-item button[title="Delete group mask"]').click();
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(0);
await groupRow.click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
await page.locator('.ge-layer-controls:visible button[title="Ungroup layers"]').click();
expect((await editorState(page)).groups).toHaveLength(0);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
+1 -2
View File
@@ -10,7 +10,6 @@ test('group drag reorders the complete subtree with undo and server persistence'
const originalOrder = current.layers.map(layer => layer.id);
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page);
const looseId = current.activeLayerId;
expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
@@ -66,7 +65,7 @@ test('mobile layer drag handle supports long-press reorder', async ({ browser, b
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder');
await page.locator('.ge-layers-header').click();
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
const before = await editorState(page);
// State is bottom-to-top; the panel is top-to-bottom.
+69 -34
View File
@@ -1,40 +1,22 @@
const { expect } = require('@playwright/test');
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
let lastError = null;
for (let pageAttempt = 0; pageAttempt < 3; pageAttempt += 1) {
try {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.evaluate(async () => {
window.__photoEditorImport = async path => {
let lastImportError = null;
for (let attempt = 0; attempt < 4; attempt += 1) {
const url = attempt === 0 ? path : `${path}?e2e_retry=${attempt}-${Date.now()}`;
try { return await import(url); } catch (error) { lastImportError = error; }
await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1)));
}
throw lastImportError;
};
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}, { size, name });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
lastError = null;
break;
} catch (error) {
lastError = error;
await page.goto('about:blank');
await page.waitForTimeout(250 * (pageAttempt + 1));
}
}
if (lastError) throw lastError;
await page.goto('/', { waitUntil: 'domcontentloaded' });
// Workflow tests exercise the canvas, not the optional setup overlay.
await page.evaluate(() => localStorage.setItem('ge-preflight-dismissed', '1'));
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.evaluate(async () => {
window.__photoEditorImport = path => import(path);
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}, { size, name });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.layers.length;
@@ -362,7 +344,57 @@ async function compareExportPixels(page, bytes, size) {
}, { base64: bytes.toString('base64'), targetSize: size });
}
async function selectTool(page, toolId) {
const face = page.locator(`.ge-tool-slot-face[data-tool="${toolId}"]`);
if (await face.count() > 0) {
await face.first().click();
return;
}
// Open the flyout via its corner triangle, wait for item visibility, and click realistically
const slot = page.locator('.ge-tool-slot', { has: page.locator(`[data-tool-option="${toolId}"]`) }).first();
const tri = slot.locator('.ge-tool-flyout-tri');
if (await tri.count() > 0) {
await tri.click();
}
const item = slot.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`);
await expect(item).toBeVisible({ timeout: 3000 });
await item.click();
}
// On phones, picking a tool minimizes the Layers sheet and refits the canvas.
// Wait for the canvas to stop moving before aiming at it.
async function settledCanvasBox(page) {
let box = null;
await expect.poll(async () => {
const previous = box;
box = await page.locator('.ge-main-canvas').boundingBox();
return JSON.stringify(previous) === JSON.stringify(box);
}).toBe(true);
return box;
}
// Rare text/shape options live in the options strip's "More" popover, which
// closes on any outside press. Open it (idempotently) before using them.
async function openOptionsMore(page, kind) {
const menu = page.locator(`#ge-${kind}-more`);
if (await menu.isHidden()) await page.locator(`#ge-${kind}-more-toggle`).click();
await expect(menu).toBeVisible();
}
// Filter actions live in Photoshop-style flyouts (Blur, Retained effects, ...).
// Open the menu, then the item's parent row, as a user would.
async function chooseFilterAction(page, action, opener = '#ge-filter-menu-btn') {
await page.locator(opener).click();
const menu = page.locator('#ge-filter-menu');
await expect(menu).toBeVisible();
const item = menu.locator(`[data-filter-action="${action}"]`);
const panel = await item.evaluate(node => node.closest('.ge-submenu')?.dataset.submenuPanel || null);
if (panel) await menu.locator(`.ge-menu-parent[data-submenu="${panel}"]`).click();
await item.click();
}
module.exports = {
chooseFilterAction,
compareExportPixels,
dragOnCanvas,
encodedImagePixelDigest,
@@ -370,6 +402,9 @@ module.exports = {
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
openOptionsMore,
reopenDraft,
selectTool,
settledCanvasBox,
waitForDraft,
};
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, editorState, dragOnCanvas } = require('./helpers');
const { chooseFilterAction, dragOnCanvas, editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
test.beforeEach(async ({ page }) => {
// The isolated editor server has no logged-in notification owner.
@@ -35,6 +35,96 @@ test('top toolbar stays on one row and its menus remain clickable at narrow widt
}
});
test('save button icon follows the draft status through an edit and autosave', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
// Autosave can settle within a frame, so record the rendered icon at every
// status change instead of racing it with polling assertions.
await page.evaluate(() => {
const status = document.getElementById('ge-draft-status');
const visibleIcon = () => [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')]
.filter(icon => icon.getBoundingClientRect().width > 0)
.map(icon => (icon.classList.contains('ge-save-state-saved') ? 'saved' : 'dirty'));
window.__saveStates = [];
new MutationObserver(() => window.__saveStates.push({ text: status.textContent, icons: visibleIcon() }))
.observe(status, { childList: true, characterData: true, subtree: true });
});
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
await waitForDraft(page);
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
const states = await page.evaluate(() => window.__saveStates);
expect(states.some(entry => entry.text === 'Unsaved')).toBe(true);
for (const entry of states) {
expect(entry.icons).toEqual([entry.text === 'Saved' ? 'saved' : 'dirty']);
}
});
test('save-state representations cover all draft states without error normalization', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await waitForDraft(page);
const cases = [
{ label: 'Unsaved', state: 'dirty', expectedText: 'Unsaved', expectedState: 'dirty', iconClass: 'ge-save-state-dirty', isError: false },
{ label: 'Saving soon…', state: 'pending', expectedText: 'Saving soon…', expectedState: 'pending', iconClass: 'ge-save-state-dirty', isError: false },
{ label: 'Saving…', state: 'saving', expectedText: 'Saving…', expectedState: 'saving', iconClass: 'ge-save-state-dirty', isError: false },
{ label: 'Saved', state: 'saved', expectedText: 'Saved', expectedState: 'saved', iconClass: 'ge-save-state-saved', isError: false },
{ label: 'Save failed', state: 'error', expectedText: 'Save failed', expectedState: 'error', iconClass: 'ge-save-state-error', isError: true },
{ label: 'Save paused', state: 'paused', expectedText: 'Save paused', expectedState: 'paused', iconClass: 'ge-save-state-error', isError: true },
{ label: 'Save failed: network offline', state: '', expectedText: 'Save failed: network offline', expectedState: 'error', iconClass: 'ge-save-state-error', isError: true },
];
for (const c of cases) {
await page.evaluate(({ label, state }) => {
window.__geSetDraftStatus(label, state);
}, c);
const statusEl = page.locator('#ge-draft-status');
const saveBtn = page.locator('#ge-save-menu-btn');
await expect(statusEl).toHaveText(c.expectedText);
await expect(statusEl).toHaveAttribute('data-state', c.expectedState);
const visibleIcon = await page.evaluate(() => {
const icons = [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')];
const visible = icons.filter(i => !i.hasAttribute('hidden'));
return visible.map(i => i.className.baseVal || i.className);
});
expect(visibleIcon.length).toBe(1);
expect(visibleIcon[0]).toContain(c.iconClass);
if (c.isError) {
await expect(saveBtn).toHaveAttribute('aria-label', new RegExp(`Draft autosave failed: ${c.expectedText}`));
} else {
await expect(saveBtn).toHaveAttribute('aria-label', `Draft status: ${c.expectedText}`);
}
}
});
test('autosave network failure transitions to error state and shows failure indicator', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await page.route(/\/api\/editor-drafts/, route => route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ detail: 'Database error' }),
}));
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
const statusEl = page.locator('#ge-draft-status');
await expect(statusEl).toHaveText('Save failed', { timeout: 10_000 });
await expect(statusEl).toHaveAttribute('data-state', 'error');
const saveBtn = page.locator('#ge-save-menu-btn');
await expect(saveBtn).toHaveAttribute('aria-label', /Draft autosave failed/);
const visibleIcons = await page.evaluate(() => {
const icons = [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')];
return icons.filter(i => !i.hasAttribute('hidden')).map(i => i.className.baseVal || i.className);
});
expect(visibleIcons).toHaveLength(1);
expect(visibleIcons[0]).toContain('ge-save-state-error');
});
test('committed paint refreshes the layer thumbnail and flashes only the edited row', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
const activeId = (await editorState(page)).activeLayerId;
@@ -55,8 +145,7 @@ test('filter Escape restores pixels and preserves redo history', async ({ page }
await dragOnCanvas(page, { x: 0.7, y: 0.2 }, { x: 0.2, y: 0.7 });
await page.keyboard.press('Control+z');
const before = await editorState(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="blur-gaussian"]').click();
await chooseFilterAction(page, 'blur-gaussian');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.ge-filter-modal')).toHaveCount(0);
@@ -75,7 +164,10 @@ test('focused fields own undo, selection and clipboard without changing layers',
await input.fill('Editable text');
await input.press('Control+Enter');
// A tool's ordinary text input must also retain native clipboard ownership.
// Frame width lives in the text options' collapsed "More" section.
await page.locator('#ge-text-more-toggle').click();
await page.locator('#ge-text-frame-width').focus();
await expect(page.locator('#ge-text-frame-width')).toBeFocused();
const before = await editorState(page);
await page.keyboard.press('Control+z');
await page.keyboard.press('Control+j');
@@ -247,3 +339,76 @@ test('switching away from a held brush ends one stroke and desktop reselect keep
await page.keyboard.press('Control+z');
expect((await editorState(page)).layers).toEqual(before.layers);
});
test('concurrent edits during in-flight save preserve dirty unsaved status and revision sequence', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await waitForDraft(page);
let delayedRouteResolve = null;
const delayedRoutePromise = new Promise(resolve => { delayedRouteResolve = resolve; });
let saveCallCount = 0;
await page.route(/\/api\/editor-drafts/, async route => {
saveCallCount += 1;
if (saveCallCount === 1) {
await delayedRoutePromise;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'concurrent-draft-1', name: 'Untitled' }),
});
});
// Edit 1
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
// Wait until save is in flight
await expect(page.locator('#ge-draft-status')).toHaveText('Saving…', { timeout: 10_000 });
// Edit 2 while save 1 is in flight
await dragOnCanvas(page, { x: 0.5, y: 0.5 }, { x: 0.7, y: 0.7 });
const revsDuringFlight = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return {
docRev: state.documentRevision,
persistingRev: state.persistingRevision,
dirty: state.persistDirty,
};
});
expect(revsDuringFlight.docRev).toBeGreaterThan(revsDuringFlight.persistingRev);
expect(revsDuringFlight.dirty).toBe(true);
// Now let save 1 complete
delayedRouteResolve();
// Wait for the chained second save to complete
await expect.poll(async () => {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.savedRevision >= state.documentRevision && !state.persistDirty;
});
}, { timeout: 15_000 }).toBe(true);
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
});
test('failed draft save retains unsaved dirty status and failure indicator', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await waitForDraft(page);
await page.route(/\/api\/editor-drafts/, route => route.abort('failed'));
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
await expect(page.locator('#ge-draft-status')).toHaveText('Save failed', { timeout: 10_000 });
const dirtyAfterFailure = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.persistDirty;
});
expect(dirtyAfterFailure).toBe(true);
});
@@ -26,7 +26,7 @@ test('clipping mask uses base alpha and survives undo, redo, and server reopen',
});
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-clip-btn').click();
await expect(editRow).toHaveClass(/clipped/);
await expect(editRow.locator('.ge-layer-clipped-marker')).toBeVisible();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
@@ -45,13 +45,13 @@ test('clipping mask uses base alpha and survives undo, redo, and server reopen',
expect((await sampleComposite(page)).right[3]).toBe(0);
await expect(page.locator('.ge-layer-item.clipped .ge-layer-clipped-marker')).toBeVisible();
await page.locator('#ge-layer-tools button[title="Merge down into layer below"]').click();
await page.locator('.ge-layer-controls:visible button[title="Merge down into layer below"]').click();
expect((await editorState(page)).layers).toHaveLength(1);
expect((await sampleComposite(page)).right[3]).toBe(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-clip-btn').click();
expect((await sampleComposite(page)).right).toEqual([0, 0, 255, 255]);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
+3 -2
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('independent layer locks enforce operations and survive undo and reopen', async ({ page, request }) => {
@@ -23,7 +24,7 @@ test('independent layer locks enforce operations and survive undo and reopen', a
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').locks.position).toBe(true);
await expect(lockButton).toHaveClass(/active/);
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.55, y: 0.50 });
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 0, y: 0 });
@@ -35,7 +36,7 @@ test('independent layer locks enforce operations and survive undo and reopen', a
await toggleLock('pixels');
const emptyHash = (await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash;
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.58, y: 0.50 });
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash).toBe(emptyHash);
await toggleLock('pixels');
@@ -1,15 +1,16 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
test('layer mask link controls independent movement and survives reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Linked layer mask');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.2, y: 0.35 }, { x: 0.75, y: 0.6 });
let current = await editorState(page);
const layerId = current.activeLayerId;
const layerRow = page.locator(`.ge-layer-item[data-layer-id="${layerId}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
@@ -63,7 +64,7 @@ test('layer mask link controls independent movement and survives reopen', async
await maskRow.locator('.ge-mask-link-btn').click();
await expect(maskRow.locator('.ge-mask-link-btn')).toHaveAttribute('aria-pressed', 'false');
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await dragOnCanvas(page, { x: 0.45, y: 0.45 }, { x: 0.55, y: 0.5 });
current = await editorState(page);
@@ -90,7 +91,7 @@ test('layer mask link controls independent movement and survives reopen', async
}).toEqual(fixedDocumentMask);
const beforeTransform = layer;
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-w').fill('280');
await page.locator('#ge-transform-w').dispatchEvent('input');
await page.locator('#ge-transform-apply').click();
+9 -14
View File
@@ -1,9 +1,8 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden', async ({ page }) => {
// Notification polling requires login even in the auth-disabled test server.
await page.route('**/api/tasks/notification-logs*', route => route.fulfill({ json: { logs: [] } }));
await openBlankEditor(page, { width: 240, height: 160 }, 'Thumbnail selection');
const id = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
@@ -31,7 +30,6 @@ test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden
test('selected layers align to the canvas and undo as one operation', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Alignment E2E');
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const edit = state.layers.find(layer => layer.name === 'Edit');
@@ -110,7 +108,6 @@ test('group rows can be selected with the keyboard', async ({ page }) => {
test('Delete removes the selected layer and undo restores it', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard delete E2E');
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
const addedId = (await editorState(page)).activeLayerId;
await page.locator(`.ge-layer-item[data-layer-id="${addedId}"]`).click();
await page.keyboard.press('Backspace');
@@ -133,19 +130,19 @@ test('Ctrl/Cmd+J duplicates the active layer through the layer panel path', asyn
test('move tool can auto-select the topmost visible layer under the pointer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Auto-select E2E');
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await selectTool(page, 'shape');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.75 });
const shape = (await editorState(page)).layers.find(layer => layer.kind === 'shape');
expect(shape).toBeTruthy();
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await expect(page.locator('.ge-auto-select-option')).toBeVisible();
await page.locator('.ge-auto-select-option').click();
await expect(page.locator('#ge-auto-select-layer')).toBeChecked();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await expect(page.locator('.ge-auto-select-option')).toBeHidden();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await expect(page.locator('.ge-auto-select-option')).toBeVisible();
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(box.x + box.width * 0.5, box.y + box.height * 0.5);
@@ -163,7 +160,7 @@ test('multi-selected layers move together and support history-backed bulk action
expect((await editorState(page)).selectedLayerIds).toHaveLength(2);
await expect(page.locator('#ge-layer-selection-bar')).toBeVisible();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.52, y: 0.50 });
let current = await editorState(page);
const edit = current.layers.find(layer => layer.name === 'Edit');
@@ -192,7 +189,6 @@ test('multi-selected layers move together and support history-backed bulk action
await editRow.click();
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
const addedId = (await editorState(page)).activeLayerId;
await editRow.click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
@@ -236,7 +232,7 @@ test('layer groups composite, lock, collapse, undo, and survive server reopen',
await page.locator('.ge-layer-group-row .ge-layer-lock-btn').click();
await page.locator('.ge-layer-group-row').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await dragOnCanvas(page, { x: 0.40, y: 0.40 }, { x: 0.52, y: 0.48 });
current = await editorState(page);
expect(current.layers[0].offset).toEqual(current.layers[1].offset);
@@ -260,7 +256,7 @@ test('layer groups composite, lock, collapse, undo, and survive server reopen',
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(0);
await page.locator('.ge-layer-group-row').click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
await page.locator('.ge-layer-controls:visible button[title="Ungroup layers"]').click();
expect((await editorState(page)).groups).toHaveLength(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups).toHaveLength(1);
@@ -277,7 +273,6 @@ test('nested groups preserve hierarchy, ancestor locks, collapse, and server reo
const innerId = current.groups[0].id;
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page);
const looseId = current.activeLayerId;
await page.locator(`.ge-layer-group-row[data-group-id="${innerId}"]`).click();
@@ -318,7 +313,7 @@ test('nested groups preserve hierarchy, ancestor locks, collapse, and server reo
await expect(page.locator('.ge-layer-group-row')).toHaveCount(2);
await page.locator(`.ge-layer-group-row[data-group-id="${outer.id}"]`).click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
await page.locator('.ge-layer-controls:visible button[title="Ungroup layers"]').click();
current = await editorState(page);
expect(current.groups).toHaveLength(1);
expect(current.groups[0]).toMatchObject({ id: inner.id, parentId: null });
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test');
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
const { chooseFilterAction, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
async function seedVisibleLayers(page) {
await page.evaluate(async () => {
@@ -18,14 +18,13 @@ test('Merge all preserves retained effects and adjustment output', async ({ page
await openBlankEditor(page, { width: 320, height: 220 }, 'Merge fidelity E2E');
await seedVisibleLayers(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await chooseFilterAction(page, 'effect-color-overlay');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#f0c04a');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
await page.locator('#ge-add-layer').click();
await page.locator('#ge-add-fx').click();
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('45');
@@ -0,0 +1,57 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, selectTool } = require('./helpers.js');
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('minimizing layers restores fit view and keeps an explicit zoom', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
const canvas = page.locator('.ge-main-canvas');
const zoom = () => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoom);
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
await expect.poll(async () => (await canvas.boundingBox()).width).toBeGreaterThan(200);
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-zoom-in').evaluate(button => button.click());
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoomViewMode)).toBe('scale');
const manualZoom = await zoom();
await page.locator('.ge-layers-title').click();
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
await expect.poll(zoom).toBe(manualZoom);
});
test('minimizing layers keeps a pinch zoom', async ({ page, context, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP multi-touch injection');
await openBlankEditor(page, { width: 320, height: 240 });
const state = () => page.evaluate(async () => {
const { state: editor } = await import('/static/js/editor/state.js');
return { zoom: editor.zoom, mode: editor.zoomViewMode };
});
const fitted = await state();
const box = await page.locator('.ge-main-canvas').boundingBox();
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
const cdp = await context.newCDPSession(page);
const fingers = spread => [{ x: cx - spread, y: cy, id: 1 }, { x: cx + spread, y: cy, id: 2 }];
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: fingers(20) });
for (const spread of [30, 40, 50]) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: fingers(spread) });
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const pinched = await state();
expect(pinched.mode).toBe('scale');
expect(pinched.zoom).toBeGreaterThan(fitted.zoom * 2);
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
await expect.poll(async () => (await state()).zoom).toBe(pinched.zoom);
});
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const fs = require('node:fs');
const { editorState, openBlankEditor, waitForDraft } = require('./helpers');
@@ -18,7 +19,7 @@ test('mobile tool and layer sheets do not overlap mask controls', async ({ page
const layerId = (await editorState(page)).activeLayerId;
const layerRow = page.locator(`.ge-layer-item[data-layer-id="${layerId}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
const maskName = maskRow.locator('.ge-layer-name');
@@ -39,15 +40,15 @@ test('mobile tool and layer sheets do not overlap mask controls', async ({ page
await maskRow.locator('.ge-mask-link-btn').click();
await expect(maskRow.locator('.ge-mask-link-btn')).toHaveAttribute('aria-pressed', 'false');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await expect(controls).not.toHaveClass(/dismissed/);
await expect(layerSheet).toHaveClass(/minimized/);
const controlsBox = await controls.boundingBox();
const sheetBox = await layerSheet.boundingBox();
expect(controlsBox.y).toBeLessThan(sheetBox.y + sheetBox.height);
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(controls).toHaveClass(/dismissed/);
});
@@ -79,17 +80,17 @@ test('mobile touch mask editing supports undo and persists through reload', asyn
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
const before = (await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash;
// A new layer mask is fully white (revealed), so Brush would paint white
// onto white. Eraser makes the first touch edit observable by hiding pixels.
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await selectTool(page, 'eraser');
// Collapse the bottom-sheet controls so the full canvas is available to the
// touch gesture, matching the canvas-first mobile editing mode.
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await selectTool(page, 'eraser');
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const cdp = await page.context().newCDPSession(page);
const visibleCanvasPoint = await page.evaluate(() => {
@@ -120,6 +121,10 @@ test('mobile touch mask editing supports undo and persists through reload', asyn
const painted = await editorState(page);
const paintedLayer = painted.layers.find(layer => layer.masks.length);
expect(paintedLayer.masks[0].pixelHash).not.toBe(before);
// History controls live in the layers header, which is hidden while the
// sheet is minimized for painting. Reopen it as a phone user would.
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
.toBe(before);
@@ -169,12 +174,12 @@ test('mobile touch moves an unlinked layer mask without moving its layer', async
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
const layerId = (await editorState(page)).activeLayerId;
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
await maskRow.locator('.ge-mask-link-btn').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await selectTool(page, 'move');
const point = await page.evaluate(() => {
const target = document.querySelector('.ge-main-canvas');
@@ -206,6 +211,8 @@ test('mobile touch moves an unlinked layer mask without moving its layer', async
expect(movedLayer.offset).toEqual(beforeLayer.offset);
expect(movedLayer.masks[0].offset.x).not.toBe(beforeLayer.masks[0].offset.x);
expect(movedLayer.masks[0].offset.y).not.toBe(beforeLayer.masks[0].offset.y);
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.id === layerId).masks[0].offset)
.toEqual(beforeLayer.masks[0].offset);
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const fs = require('node:fs/promises');
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
@@ -14,9 +15,9 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
await openBlankEditor(page, { width: 400, height: 300 }, 'Multi-transform E2E');
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await editRow.click();
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvasBox.x + canvasBox.width * 0.35, canvasBox.y + canvasBox.height * 0.25);
await page.locator('#ge-text-content').fill('Transform together');
@@ -25,7 +26,7 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await editRow.click();
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-clip-btn').click();
await editRow.click();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
@@ -36,7 +37,7 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
expect(initial.selectedLayerIds).toHaveLength(3);
const bounds = selectionBounds(initial.layers);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await expect(page.locator('.ge-transform-popup .ge-adj-title')).toHaveText('Transform 3 layers');
expect(Number(await page.locator('#ge-transform-w').inputValue())).toBe(bounds.width);
expect(Number(await page.locator('#ge-transform-h').inputValue())).toBe(bounds.height);
@@ -71,7 +72,7 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers).toEqual(transformed.layers);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
const secondWidth = await page.locator('#ge-transform-w').inputValue();
await page.locator('#ge-transform-rot-90').click();
await expect(page.locator('#ge-transform-rot')).toHaveValue('90');
@@ -87,7 +88,7 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
expect(cancelled.groups).toEqual(transformed.groups);
expect(cancelled.redo).toBe(0);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-rot-90').click();
await expect(page.locator('#ge-transform-rot')).toHaveValue('90');
await page.locator('#ge-transform-flip-h').click();
@@ -0,0 +1,87 @@
const { test, expect } = require('@playwright/test');
const path = require('node:path');
test('static editor modules survive a cache upgrade and offline reopen', async ({ browser }, testInfo) => {
const context = await browser.newContext({
baseURL: testInfo.project.use.baseURL,
storageState: path.join(process.env.PHOTO_EDITOR_E2E_DATA_DIR, 'browser-auth.json'),
serviceWorkers: 'allow',
});
const page = await context.newPage();
const pickerUrl = '/static/js/colorPicker.js?v=20260910eyedropper1';
const cssUrl = '/static/css/13-image-editor.css?v=20260929csssplit';
const editorUrl = '/static/js/galleryEditor.js?v=20260909movepicklayer1';
try {
// The app's worker intentionally controls /static/, not the SPA root.
await page.goto('/static/js/panels.js?v=20260909movepicklayer1');
const installed = await page.evaluate(async ({ pickerUrl, cssUrl }) => {
const previous = await caches.open('odysseus-v1113-pr40-editor-precache');
await previous.put(pickerUrl, new Response('stale picker'));
await previous.put(cssUrl, new Response('stale css'));
const registration = await navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1');
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
}
return { scope: new URL(registration.scope).pathname, root: !!(await navigator.serviceWorker.getRegistration('/')) };
}, { pickerUrl, cssUrl });
expect(installed).toEqual({ scope: '/static/', root: false });
await expect.poll(() => page.evaluate(async () => (await caches.keys()).includes('odysseus-v1113-pr40-editor-precache'))).toBe(false);
for (const url of [pickerUrl, cssUrl, editorUrl, '/static/js/platform.js', '/static/js/modelSort.js']) {
expect(await page.evaluate(async value => !!(await caches.match(value)), url)).toBe(true);
}
// Simulate stale entries in the active version, then verify network-first
// refresh updates both JS and CSS before testing the offline fallback.
const currentCache = await page.evaluate(async () => (await caches.keys()).find(name => name.startsWith('odysseus-')));
expect(currentCache).toMatch(/^odysseus-v\d+-pr40-editor-precache$/);
expect(currentCache).not.toBe('odysseus-v1113-pr40-editor-precache');
await page.evaluate(async ({ currentCache, pickerUrl, cssUrl }) => {
const cache = await caches.open(currentCache);
await cache.put(pickerUrl, new Response('stale picker'));
await cache.put(cssUrl, new Response('stale css'));
for (const url of [pickerUrl, cssUrl]) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Network refresh failed: ${url} ${response.status}`);
}
}, { currentCache, pickerUrl, cssUrl });
for (const url of [pickerUrl, cssUrl]) {
await expect.poll(() => page.evaluate(async value => {
const response = await caches.match(value);
return response ? !(await response.text()).startsWith('stale ') : false;
}, url)).toBe(true);
}
// Origin is the only Vary header the offline fallback may ignore. An entry
// that varies on anything else (as a per-user response would) must miss.
const varyProbeUrl = '/static/js/e2e-vary-probe.js';
await page.evaluate(async ({ currentCache, varyProbeUrl }) => {
const cache = await caches.open(currentCache);
await cache.put(
new Request(varyProbeUrl, { headers: { 'X-Probe-Owner': 'another-user' } }),
new Response('export default "private";', { headers: { 'Content-Type': 'text/javascript', Vary: 'Origin, X-Probe-Owner' } }),
);
}, { currentCache, varyProbeUrl });
await context.setOffline(true);
for (let attempt = 0; attempt < 2; attempt += 1) {
const offline = await context.newPage();
await offline.goto('/static/js/panels.js?v=20260909movepicklayer1');
expect(await offline.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
const result = await offline.evaluate(async ({ pickerUrl, editorUrl, cssUrl, varyProbeUrl }) => {
const editor = await import(editorUrl);
const picker = await import(pickerUrl);
const css = await fetch(cssUrl);
const privateProbe = await import(varyProbeUrl).then(() => 'served', () => 'rejected');
return {
editor: typeof editor.openEditor,
picker: ['getRecentColors', 'recordRecentColor', 'suggestColorsFor'].every(key => typeof picker[key] === 'function'),
cssOk: css.ok,
privateProbe,
};
}, { pickerUrl, editorUrl, cssUrl, varyProbeUrl });
expect(result).toEqual({ editor: 'function', picker: true, cssOk: true, privateProbe: 'rejected' });
await offline.close();
}
} finally { await context.close(); }
});
@@ -0,0 +1,95 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers.js');
test('channel and path panels display hostile layer metadata as text', async ({ page }) => {
await openBlankEditor(page, { width: 64, height: 64 });
const result = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const { createColorPanels } = await import('/static/js/editor/panels/panels.js');
const { createStudioPanels } = await import('/static/js/editor/panels/studio-panels.js');
const attack = '<img src=x onerror="window.__panelXss=true">';
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.name = attack;
layer.kind = 'shape';
layer.shape = { type: attack };
const colors = createColorPanels({ state });
const studio = createStudioPanels({ state });
document.body.append(colors.channels, studio.paths);
colors.refresh();
studio.refresh();
return {
attack,
channel: colors.channels.querySelector('.ge-channel-name').textContent,
path: studio.paths.querySelector('.ge-channel-name').textContent,
images: colors.channels.querySelectorAll('img').length + studio.paths.querySelectorAll('img').length,
executed: !!window.__panelXss,
};
});
expect(result.channel).toBe(result.attack);
expect(result.path).toBe(`${result.attack} · ${result.attack}`);
expect(result.images).toBe(0);
expect(result.executed).toBe(false);
});
test('unknown effect types cannot inject markup into the layer panel', async ({ page }) => {
await openBlankEditor(page, { width: 64, height: 64 });
const attack = '<img src=x onerror="window.__effectXss=true">';
const id = await page.evaluate(async value => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.effects = [{ id: 'effect-test', type: value, visible: true, params: {} }];
return layer.id;
}, attack);
await page.locator(`.ge-layer-item[data-layer-id="${id}"] .ge-layer-vis`).first().click();
const list = page.locator('#ge-layers-list');
await expect(list).toContainText('Gaussian Blur');
await expect(list).not.toContainText(attack);
await expect(list.locator('img')).toHaveCount(0);
expect(await page.evaluate(() => !!window.__effectXss)).toBe(false);
});
test('saved brush preset names remain literal text in the flyout', async ({ page }) => {
const attack = '<img src=x onerror="window.__brushXss=true">';
await page.addInitScript(value => {
localStorage.setItem('odysseus.editor.brush-presets.v1', JSON.stringify([
{ id: 'hostile-preset', name: value, size: 8, opacity: 100, flow: 100, softness: 20 },
]));
}, attack);
await openBlankEditor(page, { width: 64, height: 64 });
const preset = page.locator('.ge-tool-flyout-preset[data-brush-preset="hostile-preset"]');
await expect(preset.locator('.ge-tool-flyout-label')).toHaveText(attack);
await expect(preset.locator('img')).toHaveCount(0);
expect(await page.evaluate(() => !!window.__brushXss)).toBe(false);
});
test('imported adjustment parameters cannot inject markup into the adjustment popup', async ({ page }) => {
await openBlankEditor(page, { width: 64, height: 64 });
const attack = '"><img src=x onerror="window.__adjXss=true">';
const cases = [
{ type: 'color-lookup', params: { cube: { size: attack, data: [0, 0, 0] }, strength: attack }, text: '.ge-adj-lut-name', expected: 'Loaded LUT · 0³' },
{ type: 'gradient-map', params: { shadows: attack, highlights: attack } },
{ type: 'levels', params: { inBlack: attack, outWhite: attack } },
];
for (const item of cases) {
await page.locator('#ge-add-fx').click();
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${item.type}"]`).click();
await page.locator('.ge-adj-popup [data-adj-action="ok"]').click();
await expect(page.locator('.ge-adj-popup')).toHaveCount(0);
// Project files and drafts reach the editor through the codec's
// normalizer, which validates the type but not every parameter.
await page.evaluate(async ({ type, params }) => {
const { state } = await import('/static/js/editor/state.js');
const { normalizeAdjustmentData } = await import('/static/js/editor/adjustment-layer.js');
const layer = state.layers.find(entry => entry.id === state.activeLayerId);
layer.adjustment = normalizeAdjustmentData({ type, params });
}, item);
await page.locator('.ge-layer-controls:visible .ge-layer-fx-btn').click();
const popup = page.locator('.ge-adj-popup');
await expect(popup).toBeVisible();
await expect(popup.locator('img')).toHaveCount(0);
if (item.text) await expect(popup.locator(item.text)).toHaveText(item.expected);
expect(await popup.evaluate(node => node.innerHTML.includes('onerror'))).toBe(false);
await popup.locator('[data-adj-action="cancel"]').click();
}
expect(await page.evaluate(() => !!window.__adjXss)).toBe(false);
});
@@ -0,0 +1,82 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers');
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
async function gaps(page) {
return page.evaluate(() => {
const rect = selector => document.querySelector(selector).getBoundingClientRect();
const canvas = rect('.ge-main-canvas');
const footer = rect('.ge-editor-footer');
const sheet = rect('.ge-right-panel');
return {
canvasToFooter: Math.abs(canvas.bottom - footer.top),
footerToSheet: Math.abs(footer.bottom - sheet.top),
footerLeft: footer.left,
footerRight: footer.right,
sheetTop: sheet.top,
};
});
}
test('mobile footer docks flush between the canvas and the layers sheet', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile footer dock');
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
await expect(page.locator('.ge-editor-footer')).toHaveClass(/ge-footer-floating/);
await expect(page.locator('.ge-layers-grab')).toBeHidden();
// Wait until the dock has settled: canvas bottom == footer top == sheet top.
await expect.poll(async () => {
const g = await gaps(page);
return g.canvasToFooter <= 2 && g.footerToSheet <= 2;
}, { timeout: 10_000 }).toBe(true);
const g = await gaps(page);
expect(g.canvasToFooter).toBeLessThanOrEqual(2);
expect(g.footerToSheet).toBeLessThanOrEqual(2);
expect(g.footerLeft).toBeLessThanOrEqual(1);
expect(Math.abs(g.footerRight - 390)).toBeLessThanOrEqual(1);
});
test('phone footer controls for zoom, setup, and AI settings are accessible via normal clicks', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile footer controls');
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
const footer = page.locator('.ge-editor-footer');
await expect(footer).toBeVisible();
const setupBtn = page.locator('#ge-setup-btn');
const aiBtn = page.locator('#ge-ai-settings-btn');
const zoomIn = page.locator('#ge-zoom-in');
const zoomOut = page.locator('#ge-zoom-out');
const zoomFit = page.locator('#ge-zoom-100');
await expect(setupBtn).toBeVisible();
await expect(aiBtn).toBeVisible();
await expect(zoomIn).toBeVisible();
await expect(zoomOut).toBeVisible();
await expect(zoomFit).toBeVisible();
// Test zoom controls via normal clicks
const zoomLabel = page.locator('.ge-zoom-label');
const initialZoom = await zoomLabel.textContent();
await zoomIn.click();
await expect(zoomLabel).not.toHaveText(initialZoom);
await zoomFit.click();
await expect(zoomLabel).toHaveText(/%/);
// Test Setup modal accessibility via normal click
await setupBtn.click();
const preflight = page.locator('.ge-preflight');
await expect(preflight).toBeVisible();
await page.locator('.ge-preflight-start').click();
await expect(preflight).toBeHidden();
// Test AI Settings modal accessibility via normal click
await aiBtn.click();
const aiSettings = page.locator('.ge-ai-settings');
await expect(aiSettings).toBeVisible();
await page.locator('.ge-ai-settings-done').click();
await expect(aiSettings).toBeHidden();
});
+13 -12
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { editorState, flattenedPixelDigest, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function patternedPng(page, width, height, colors) {
@@ -30,12 +31,12 @@ test('placed image transforms from source, replaces in place, rasterizes, and re
const sourceHash = imported.placed.sourcePixelHash;
const row = page.locator(`.ge-layer-item[data-layer-id="${imported.id}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
await selectTool(page, 'transform');
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth / 2)));
await page.locator('#ge-transform-apply').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-w').fill(String(originalWidth));
await page.locator('#ge-transform-apply').click();
@@ -48,7 +49,7 @@ test('placed image transforms from source, replaces in place, rasterizes, and re
const replacement = await patternedPng(page, 20, 40, ['#111111', '#f8f8f8', '#ff00aa']);
const replaceChooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-layer-tools button[title="Replace placed image"]').click();
await page.locator('.ge-layer-controls:visible button[title="Replace placed image"]').click();
const replaceChooser = await replaceChooserPromise;
await replaceChooser.setFiles({ name: 'replacement.png', mimeType: 'image/png', buffer: replacement });
await expect.poll(async () => {
@@ -62,7 +63,7 @@ test('placed image transforms from source, replaces in place, rasterizes, and re
expect(replaced.masks[0]).toEqual(beforeReplaceMask);
const beforeRasterize = await flattenedPixelDigest(page);
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
await page.locator('.ge-layer-controls:visible button[title="Rasterize placed layer"]').click();
const rasterized = (await editorState(page)).layers.find(layer => layer.id === imported.id);
expect(rasterized.kind).toBe('raster');
expect(rasterized.placed).toBeNull();
@@ -92,11 +93,11 @@ test('raster layers can be converted to editable sources without changing pixels
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
await page.locator('.ge-layer-controls:visible button[title="Rasterize placed layer"]').click();
const raster = (await editorState(page)).layers.find(layer => layer.id === placed.id);
const before = { pixelHash: raster.pixelHash, size: raster.size, offset: raster.offset };
await page.locator('#ge-layer-tools button[title="Convert to editable source"]').click();
await page.locator('.ge-layer-controls:visible button[title="Convert to editable source"]').click();
await expect.poll(async () => (await editorState(page)).layers.find(layer => layer.id === placed.id).kind).toBe('placed');
const converted = (await editorState(page)).layers.find(layer => layer.id === placed.id);
expect(converted.placed.sourceSize).toEqual(before.size);
@@ -139,7 +140,7 @@ test('duplicating a placed layer copies its source independently', async ({ page
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const original = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator(`.ge-layer-item[data-layer-id="${original.id}"]`).click();
await page.locator('#ge-layer-tools button[title="Duplicate layer"]').click();
await page.locator('.ge-layer-controls:visible button[title="Duplicate layer"]').click();
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(2);
const sourceOwnership = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
@@ -165,10 +166,10 @@ test('raster transforms preserve source pixels by default and can opt out', asyn
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
await page.locator('.ge-layer-controls:visible button[title="Rasterize placed layer"]').click();
const rasterBeforeTransform = (await editorState(page)).layers.find(layer => layer.id === placed.id);
const sourceHash = rasterBeforeTransform.pixelHash;
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-preserve-source')).toBeChecked();
await page.locator('#ge-transform-w').fill('84');
await page.locator('#ge-transform-apply').click();
@@ -191,8 +192,8 @@ test('raster transforms can explicitly replace source pixels', async ({ page, re
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('.ge-layer-controls:visible button[title="Rasterize placed layer"]').click();
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-preserve-source')).toBeChecked();
await page.locator('#ge-transform-preserve-source').uncheck();
await page.locator('#ge-transform-w').fill('60');
@@ -0,0 +1,17 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers.js');
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('setup checklist remains usable above the mobile layers sheet', async ({ page }) => {
await openBlankEditor(page);
await page.locator('#ge-setup-btn').click();
const checklist = page.locator('.ge-preflight');
const start = checklist.locator('[data-preflight-start]');
await expect(checklist).toBeVisible();
await start.scrollIntoViewIfNeeded();
await expect(start).toHaveCSS('white-space', 'nowrap');
expect(await start.evaluate(button => button.scrollWidth <= button.clientWidth)).toBe(true);
await start.click();
await expect(checklist).toBeHidden();
});
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test');
const { editorState, openBlankEditor } = require('./helpers.js');
const { editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
async function currentPng(page) {
return page.evaluate(async () => {
@@ -46,6 +46,7 @@ test('mixed-corrupt project recovers valid layers and remains undoable', async (
test('fully corrupt project leaves the open document unchanged', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Recovery E2E');
await waitForDraft(page);
const before = await editorState(page);
await loadProject(page, {
type: 'odysseus-gallery-editor-project',
@@ -108,13 +109,13 @@ test('new project size dialog cancels cleanly with Escape', async ({ page }) =>
await expect(page.locator('#gallery-editor-new')).toBeVisible();
});
test('editor topbar uses uppercase action labels', async ({ page }) => {
test('editor topbar uses Photoshop text menu labels', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Topbar labels');
await expect(page.locator('#ge-view-menu-btn')).toHaveText(/VIEW/);
await expect(page.locator('#ge-image-menu-btn')).toHaveText(/IMAGE/);
await expect(page.locator('#ge-selection-menu-btn')).toHaveText(/SELECT/);
await expect(page.locator('#ge-filter-menu-btn')).toHaveText(/FILTER/);
await expect(page.locator('#ge-view-menu-btn')).toHaveText(/View/);
await expect(page.locator('#ge-image-menu-btn')).toHaveText(/Image/);
await expect(page.locator('#ge-selection-menu-btn')).toHaveText(/Select/);
await expect(page.locator('#ge-filter-menu-btn')).toHaveText(/Filter/);
await expect(page.locator('#ge-import-topbar')).toHaveText(/IMPORT/);
await expect(page.locator('#ge-save-menu-btn')).toHaveText(/SAVE/);
});
@@ -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); });
@@ -1,28 +1,48 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, editorState } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, selectTool } = require('./helpers.js');
test('paint tools offer rasterization, cancel preserves text, Enter accepts and undo restores it', async ({ page }) => {
test('paint tools require confirmation before rasterizing text layers, preserving text on cancel', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvas.x + 80, canvas.y + 80);
const text = page.locator('.ge-direct-text-editor');
await text.fill('Keep editable');
await text.press('Control+Enter');
const retained = (await editorState(page)).layers.find(layer => layer.kind === 'text');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await expect(page.locator('#styled-confirm-ok')).toHaveText('Rasterize');
await page.keyboard.press('Escape');
expect(retained).toBeDefined();
const kind = async () => (await editorState(page)).layers.find(layer => layer.id === retained.id)?.kind;
// Tool selection alone does not prompt and does not rasterize
for (const tool of ['brush', 'eraser']) {
await selectTool(page, tool);
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect(await kind()).toBe('text');
}
// Attempting to draw triggers confirmation
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.25, y: 0.2 });
await expect(page.locator('#styled-confirm-overlay')).toBeVisible();
// Cancelling keeps text editable with no changes
await page.locator('#styled-confirm-cancel').click();
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await expect(page.locator('#styled-confirm-ok')).toBeFocused();
await page.keyboard.press('Enter');
expect(await kind()).toBe('text');
// Second stroke prompt confirmed -> rasterizes layer
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.25, y: 0.2 });
await expect(page.locator('#styled-confirm-overlay')).toBeVisible();
await page.locator('#styled-confirm-ok').click();
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('raster');
expect(await kind()).toBe('raster');
// Subsequent strokes on the raster layer draw immediately without prompting
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.35, y: 0.3 });
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
// Undo restores editable text
await page.keyboard.press('Control+z');
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await expect(page.locator('#styled-confirm-ok')).toBeVisible();
await page.keyboard.press('Escape');
await page.keyboard.press('Control+z');
expect(await kind()).toBe('text');
});
@@ -1,9 +1,10 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
test('active editor draft reopens after a hard refresh', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Refresh recovery E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
const before = await editorState(page);
const draftId = await waitForDraft(page);
@@ -11,8 +12,15 @@ test('active editor draft reopens after a hard refresh', async ({ page }) => {
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('#gallery-editor-tab')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('.gallery-editor')).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => (await editorState(page)).draftId, { timeout: 20_000 }).toBe(draftId);
await expect.poll(async () => (await editorState(page)).documentRenderReady, { timeout: 20_000 }).toBe(true);
const readState = async (key) => {
try {
return (await editorState(page))[key];
} catch {
return null;
}
};
await expect.poll(() => readState('draftId'), { timeout: 20_000 }).toBe(draftId);
await expect.poll(() => readState('documentRenderReady'), { timeout: 20_000 }).toBe(true);
const after = await editorState(page);
expect(after.layers).toEqual(before.layers);
@@ -1,9 +1,10 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
test('selection boundary animates, moves independently, and supports Quick Mask', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection workflow');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
const initial = await editorState(page);
@@ -44,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 page.locator('.ge-tool-btn[data-tool="marquee"]').click();
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(box.x + box.width * 0.8, 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();
@@ -72,14 +63,14 @@ test('marquee supports exact fixed-size and fixed-ratio geometry', async ({ page
test('transform selection moves, scales, rotates, cancels, and preserves layer pixels', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Transform selection');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.5, y: 0.55 });
const original = await editorState(page);
const originalLayer = original.layers.find(layer => layer.id === original.activeLayerId);
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);
@@ -115,20 +106,20 @@ 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);
await expect(page.locator('#ge-transform-section')).toBeVisible();
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();
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.transformActive)).toBe(false);
expect((await editorState(page)).selection.pixelHash).toBe(original.selection.pixelHash);
});
test('named selections support reselect, load, delete, and server-draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Saved selections');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.5, y: 0.6 });
const original = (await editorState(page)).selection;
@@ -179,3 +170,56 @@ test('named selections support reselect, load, delete, and server-draft reopen',
expect(mobileMenu.x).toBeGreaterThanOrEqual(0);
expect(mobileMenu.x + mobileMenu.width).toBeLessThanOrEqual(390);
});
test('marquee released over the right panel commits a clamped selection', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Marquee overshoot');
await selectTool(page, 'marquee');
const setup = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const rect = state.mainCanvas.getBoundingClientRect();
const panel = document.querySelector('.ge-right-panel');
const panelBox = panel ? panel.getBoundingClientRect() : null;
return {
document: { width: state.imgWidth, height: state.imgHeight },
start: { x: rect.left + rect.width * 0.2, y: rect.top + rect.height * 0.2 },
panelPoint: panelBox
? { x: panelBox.left + panelBox.width / 2, y: rect.top + rect.height * 0.6 }
: null,
};
});
expect(setup.panelPoint, 'right panel must be visible on desktop').toBeTruthy();
await page.mouse.move(setup.start.x, setup.start.y);
await page.mouse.down();
// Drag straight onto the right panel. The window mousemove guard must
// keep the raw rect from extending off-canvas, and the release must
// commit a clamped selection rather than discard it.
await page.mouse.move(setup.panelPoint.x, setup.panelPoint.y, { steps: 12 });
await page.mouse.up();
const result = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
if (!state.wandMask) return { active: false };
const data = state.wandMask.getContext('2d')
.getImageData(0, 0, state.wandMask.width, state.wandMask.height).data;
let left = state.wandMask.width, top = state.wandMask.height, right = -1, bottom = -1;
for (let y = 0; y < state.wandMask.height; y += 1) {
for (let x = 0; x < state.wandMask.width; x += 1) {
if (data[(y * state.wandMask.width + x) * 4 + 3] < 1) continue;
left = Math.min(left, x); top = Math.min(top, y);
right = Math.max(right, x); bottom = Math.max(bottom, y);
}
}
return { active: true, left, top, right, bottom, width: state.wandMask.width, height: state.wandMask.height };
});
expect(result.active).toBe(true);
expect(result.right).toBeGreaterThanOrEqual(result.left);
expect(result.bottom).toBeGreaterThanOrEqual(result.top);
// Clamped: never beyond the document edge on any side.
expect(result.left).toBeGreaterThanOrEqual(0);
expect(result.top).toBeGreaterThanOrEqual(0);
expect(result.right).toBeLessThan(result.width);
expect(result.bottom).toBeLessThan(result.height);
expect(result.right - result.left).toBeGreaterThan(0);
expect(result.bottom - result.top).toBeGreaterThan(0);
});
+15 -12
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
async function openRefine(page) {
@@ -23,9 +24,9 @@ async function drawLasso(page, points) {
test('selection clipboard copy and paste creates an undoable independent layer', async ({ page, request }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection clipboard');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
const before = await editorState(page);
@@ -55,11 +56,11 @@ test('selection clipboard copy and paste creates an undoable independent layer',
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('cut selection is one undoable move from source to new layer', async ({ page }) => {
test('cut selection removes source pixels in one undoable step', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection cut');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
const before = await editorState(page);
@@ -76,9 +77,11 @@ test('cut selection is one undoable move from source to new layer', async ({ pag
});
const beforeSource = await sourceSignature();
await page.keyboard.press('Control+x');
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Wand copy' })).toBeVisible();
// Cut copies to the clipboard and erases the selected pixels in place —
// it does not create a new layer (that is Ctrl+Shift+J, Layer via Cut).
const cut = await editorState(page);
expect(cut.layers).toHaveLength(before.layers.length + 1);
expect(cut.layers).toHaveLength(before.layers.length);
expect(cut.selection).toBeNull();
expect(await sourceSignature()).not.toBe(beforeSource);
await page.locator('#ge-undo').click();
@@ -93,9 +96,9 @@ test('completed lasso copy preserves the source layer coordinate space', async (
state.layerOffsets.set(state.activeLayerId, { x: 32, y: 24 });
window.galleryEditorComposite?.();
});
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.28, y: 0.3 }, { x: 0.62, y: 0.58 });
await page.locator('.ge-tool-btn[data-tool="lasso"]').click();
await selectTool(page, 'lasso');
await drawLasso(page, [[0.26, 0.26], [0.68, 0.26], [0.68, 0.64], [0.26, 0.64]]);
const before = await editorState(page);
@@ -110,7 +113,7 @@ test('completed lasso copy preserves the source layer coordinate space', async (
test('selection refine previews safely and layer masks round-trip to selection', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection refinement');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.55, y: 0.55 });
const original = (await editorState(page)).selection;
@@ -132,7 +135,7 @@ test('selection refine previews safely and layer masks round-trip to selection',
expect(refined.bounds.width).toBeGreaterThan(original.bounds.width);
expect(refined.pixelHash).not.toBe(original.pixelHash);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
let current = await editorState(page);
const active = current.layers.find(layer => layer.id === current.activeLayerId);
expect(active.masks).toHaveLength(1);
@@ -149,7 +152,7 @@ test('selection refine previews safely and layer masks round-trip to selection',
test('lasso uses the shared replace, add, subtract, and intersect modes', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Lasso combine modes');
await page.locator('.ge-tool-btn[data-tool="lasso"]').click();
await selectTool(page, 'lasso');
await drawLasso(page, [[0.1, 0.2], [0.35, 0.2], [0.35, 0.55], [0.1, 0.55]]);
const first = (await editorState(page)).selection;
expect(first.source).toBe('lasso');
@@ -0,0 +1,46 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, selectTool } = require('./helpers.js');
// Editors opened wider than 700px move the tool controls into the options
// strip. At tablet widths (701-820px) that strip must stay visible, or every
// tool control is stranded in a hidden container.
for (const width of [768, 820]) {
test(`tool controls stay reachable at a ${width}px tablet width`, async ({ page }) => {
await page.setViewportSize({ width, height: 1024 });
await openBlankEditor(page, { width: 320, height: 240 }, `Tablet ${width}`);
const inViewport = locator => locator.evaluate(el => {
const box = el.getBoundingClientRect();
return box.width > 0 && box.left >= 0 && box.right <= innerWidth + 1 && box.bottom <= innerHeight + 1;
});
await selectTool(page, 'brush');
await expect(page.locator('#ge-options-bar')).toBeVisible();
await expect(page.locator('#ge-options-bar .ge-size-slider')).toBeVisible();
expect(await inViewport(page.locator('#ge-options-bar .ge-size-slider'))).toBe(true);
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-w')).toBeVisible();
await page.locator('#ge-transform-w').fill('200');
await page.locator('#ge-transform-w').dispatchEvent('input');
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.transformPendingW)).toBe(200);
await page.locator('#ge-transform-cancel-btn').click();
});
}
test('responsive transition across 700px breakpoint preserves dock/sheet layout and state', async ({ page }) => {
// Desktop -> Phone transition
await page.setViewportSize({ width: 1024, height: 768 });
await openBlankEditor(page, { width: 320, height: 240 }, 'Responsive transitions');
await expect(page.locator('.ge-right-panel')).toHaveClass(/ge-right-panel--dock/);
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeVisible();
// Resize across 700px to phone
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/ge-right-panel--dock/);
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeHidden();
// Resize back to desktop
await page.setViewportSize({ width: 1024, height: 768 });
await expect(page.locator('.ge-right-panel')).toHaveClass(/ge-right-panel--dock/);
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeVisible();
});
@@ -0,0 +1,42 @@
const { test, expect } = require('@playwright/test');
const crypto = require('crypto');
const { openBlankEditor } = require('./helpers');
const FONTS = [
'Inter', 'Roboto', 'Playfair Display', 'Lobster',
'Bebas Neue', 'Roboto Mono', 'Space Grotesk', 'Fraunces',
'Syne', 'Bungee', 'Instrument Serif', 'Georgia', 'Courier New',
];
const md5 = (buf) => crypto.createHash('md5').update(buf).digest('hex');
test('text font dropdown renders a distinct face for each font', async ({ page }) => {
await openBlankEditor(page, { width: 900, height: 600 }, 'Font swap');
await page.evaluate(() => {
document.querySelectorAll('.ge-tour-close,.ge-protip,.ge-tip,[class*="protip"]').forEach((e) => e.remove());
});
await page.evaluate(async () => {
const tool = document.querySelector('[data-tool="text"]');
if (tool) tool.click();
await window.geEditorControl.invoke('text.add', { text: 'AWjgyq 128', x: 60, y: 80 });
});
await page.waitForTimeout(400);
const box = await page.locator('.ge-main-canvas').boundingBox();
expect(box).toBeTruthy();
const hashes = {};
for (const font of FONTS) {
await page.evaluate((f) => {
const sel = document.getElementById('ge-text-font');
sel.value = f;
sel.dispatchEvent(new Event('input', { bubbles: true }));
sel.dispatchEvent(new Event('change', { bubbles: true }));
}, font);
await page.waitForTimeout(250);
const shot = await page.screenshot({ clip: box });
hashes[font] = md5(shot);
}
const distinct = new Set(Object.values(hashes)).size;
expect(distinct, `distinct font hashes: ${JSON.stringify(hashes)}`).toBeGreaterThanOrEqual(5);
});
+22 -9
View File
@@ -1,9 +1,11 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, openOptionsMore, reopenDraft, waitForDraft } = require('./helpers.js');
test('text edits directly on canvas and remains retained after reopen', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
await openOptionsMore(page, 'text');
await page.locator('#ge-text-frame-width').fill('260');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvas.x + canvas.width * 0.2, canvas.y + canvas.height * 0.2);
@@ -13,11 +15,13 @@ test('text edits directly on canvas and remains retained after reopen', async ({
await editor.press('Control+Enter');
await expect(editor).toHaveCount(0);
await openOptionsMore(page, 'text');
await page.locator('#ge-text-letter-spacing').fill('3.5');
await page.locator('#ge-text-line-height').fill('1.4');
await page.locator('#ge-text-font').selectOption('Georgia');
await page.locator('.ge-text-auto-width-option').click();
await expect(page.locator('#ge-text-auto-width')).toBeChecked();
await openOptionsMore(page, 'text');
await expect(page.locator('#ge-text-frame-width')).toBeDisabled();
await page.locator('#ge-text-frame-height').fill('180');
await page.locator('#ge-text-vertical-align').selectOption('bottom');
@@ -42,17 +46,22 @@ test('text edits directly on canvas and remains retained after reopen', async ({
test('text paragraph controls remain usable in a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile text layout E2E');
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
const section = page.locator('#ge-text-section');
await expect(section).toBeVisible();
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await openOptionsMore(page, 'text');
await expect(page.locator('#ge-text-frame-height')).toBeVisible();
await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
expect(await page.locator('#ge-text-more').evaluate(el => {
const box = el.getBoundingClientRect();
return box.left >= 0 && box.right <= innerWidth + 1;
})).toBe(true);
});
test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await selectTool(page, 'shape');
await expect(page.locator('#ge-shape-section')).toBeVisible();
const types = ['rectangle', 'ellipse', 'line', 'polygon'];
@@ -70,6 +79,7 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
let current = await editorState(page);
const shapes = current.layers.filter(layer => layer.kind === 'shape');
expect(shapes.map(layer => layer.shape.type)).toEqual(types);
await openOptionsMore(page, 'shape');
await page.locator('#ge-shape-sides').fill('7');
await page.locator('#ge-shape-stroke-width').fill('6');
await page.locator('#ge-shape-radius').fill('14');
@@ -77,7 +87,7 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
const polygon = current.layers.find(layer => layer.kind === 'shape' && layer.shape.type === 'polygon');
expect(polygon.shape).toMatchObject({ sides: 7, strokeWidth: 6, cornerRadius: 14 });
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(originalWidth + 40));
await page.locator('#ge-transform-apply').click();
@@ -94,7 +104,8 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
test('gradient shape fill remains editable after reopening the project', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await selectTool(page, 'shape');
await openOptionsMore(page, 'shape');
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeHidden();
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.65, y: 0.55 });
await page.locator('#ge-shape-gradient-angle').evaluate((angle) => {
@@ -109,6 +120,7 @@ test('gradient shape fill remains editable after reopening the project', async (
angle.value = '35';
angle.dispatchEvent(new Event('input', { bubbles: true }));
});
await openOptionsMore(page, 'shape');
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible();
const before = await editorState(page);
@@ -140,8 +152,9 @@ test('gradient shape fill remains editable after reopening the project', async (
test('shape gradients retain added stops through editing and reopen', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E');
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await selectTool(page, 'shape');
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
await openOptionsMore(page, 'shape');
await page.locator('#ge-shape-fill-type').selectOption('linear-gradient');
await page.evaluate(() => {
for (const [id, value] of [['ge-shape-gradient-start', '#ff0000'], ['ge-shape-gradient-end', '#0000ff']]) {
@@ -220,7 +233,7 @@ test('shape gradient stop normalization keeps imported endpoints bounded', async
test('gradient tool paints a reversible drag on the active layer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Gradient tool E2E');
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
await selectTool(page, 'gradient');
await expect(page.locator('#ge-gradient-section')).toBeVisible();
await page.evaluate(() => {
@@ -309,7 +322,7 @@ test('gradient tool paints a reversible drag on the active layer', async ({ page
test('radial gradient remains retained and survives reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Radial gradient E2E');
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
await selectTool(page, 'gradient');
await page.locator('#ge-gradient-type').selectOption('radial-gradient');
await page.evaluate(() => {
const start = document.getElementById('ge-gradient-start');
+107 -12
View File
@@ -1,10 +1,11 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
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 page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-transform-popup')).toBeVisible();
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-section')).toBeVisible();
const frame = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
@@ -43,7 +44,7 @@ test('transform frame exposes eight accurate handles and supports edge resize',
test('rotated edge resize follows the frame axis and keeps its opposite edge anchored', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Rotated transform frame');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-rot').fill('90');
const frame = await page.evaluate(async () => {
@@ -84,7 +85,7 @@ test('rotated edge resize follows the frame axis and keeps its opposite edge anc
test('rotated frame moves only from its visible interior and supports keyboard nudging', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform interaction');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-rot').fill('45');
const frame = await page.evaluate(async () => {
@@ -153,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;
@@ -182,14 +183,14 @@ test('transform previews remain source-derived and reject unsafe allocations', a
return { width: layer.canvas.width, height: layer.canvas.height, hash: hash >>> 0 };
});
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.8)));
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
const afterSequentialPreviews = await layerSnapshot();
await page.locator('#ge-transform-cancel-btn').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
const afterDirectPreview = await layerSnapshot();
expect(afterSequentialPreviews).toEqual(afterDirectPreview);
@@ -214,23 +215,31 @@ test('mobile touch input can grab every transform handle', async ({ browser, bro
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile transform handles');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-transform-popup')).toBeVisible();
await selectTool(page, 'transform');
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'];
const handlePoint = id => page.evaluate(async handleId => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const geometry = transformFrameGeometry({
const frame = {
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom, rotationInside: handleId === 'rot' });
const handle = geometry.handles.find(item => item.id === handleId);
};
const rect = state.mainCanvas.getBoundingClientRect();
const outside = transformFrameGeometry(frame, { zoom: state.zoom }).rotationHandle;
const x = rect.left + outside.x * rect.width / state.mainCanvas.width;
const y = rect.top + outside.y * rect.height / state.mainCanvas.height;
const area = state.container.querySelector('.ge-canvas-area').getBoundingClientRect();
const popup = state.transformPopup?.getBoundingClientRect();
const rotationInside = y < area.top + 6 || x < area.left + 6 || x > area.right - 6 ||
!!(popup && x >= popup.left && x <= popup.right && y >= popup.top && y <= popup.bottom);
const geometry = transformFrameGeometry(frame, { zoom: state.zoom, rotationInside });
const handle = geometry.handles.find(item => item.id === handleId);
return {
x: rect.left + handle.x * rect.width / state.mainCanvas.width,
y: rect.top + handle.y * rect.height / state.mainCanvas.height,
@@ -318,3 +327,89 @@ test('mobile touch input can grab every transform handle', async ({ browser, bro
await context.close();
}
});
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-section')).toBeVisible();
const setup = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const geometry = transformFrameGeometry({
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom });
const right = geometry.resizeHandles.find(handle => handle.id === 'r');
const rect = state.mainCanvas.getBoundingClientRect();
const panel = document.querySelector('.ge-right-panel');
const panelBox = panel ? panel.getBoundingClientRect() : null;
return {
width: state.transformPendingW,
handle: {
x: rect.left + right.x * rect.width / state.mainCanvas.width,
y: rect.top + right.y * rect.height / state.mainCanvas.height,
},
// A point safely inside the right panel (editor chrome).
panelPoint: panelBox
? { x: panelBox.left + panelBox.width / 2, y: rect.top + rect.height / 2 }
: null,
};
});
expect(setup.panelPoint, 'right panel must be visible on desktop').toBeTruthy();
await page.mouse.move(setup.handle.x, setup.handle.y);
await page.mouse.down();
// Resize far to the right, ending over the right panel. The handle must
// keep tracking across chrome — an overshoot must not freeze the drag.
await page.mouse.move(setup.panelPoint.x, setup.panelPoint.y, { steps: 12 });
await page.mouse.up();
const after = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
});
expect(after).toBeGreaterThan(setup.width);
});
test('phone transform overlay follows the canvas through refits and zoom', async ({ browser }) => {
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 640, height: 480 }, 'Phone transform overlay');
const fitBefore = await page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoom);
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-section')).toBeVisible();
const alignment = () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const overlay = state.transformOverlay.getBoundingClientRect();
const canvas = state.mainCanvas.getBoundingClientRect();
return {
zoom: state.zoom,
overlayScale: overlay.width / state.transformOverlay.width,
covers: overlay.left <= canvas.left + 1 && overlay.top <= canvas.top + 1
&& overlay.right >= canvas.right - 1 && overlay.bottom >= canvas.bottom - 1,
};
});
// Selecting Transform collapses the Layers sheet, which refits the canvas.
await expect.poll(async () => (await alignment()).zoom).not.toBe(fitBefore);
let current = await alignment();
expect(Math.abs(current.overlayScale - current.zoom)).toBeLessThan(0.002);
expect(current.covers).toBe(true);
await page.locator('#ge-zoom-in').evaluate(button => button.click());
await expect.poll(async () => (await alignment()).zoom).toBeGreaterThan(current.zoom);
current = await alignment();
expect(Math.abs(current.overlayScale - current.zoom)).toBeLessThan(0.002);
} finally {
await context.close();
}
});
+15 -4
View File
@@ -10,10 +10,12 @@ const defaultPython = process.platform === 'win32'
? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe')
: path.join(repositoryRoot, '.venv', 'bin', 'python');
const python = process.env.ODYSSEUS_TEST_PYTHON || (fs.existsSync(defaultPython) ? defaultPython : 'python');
const tmpBase = fs.existsSync('/var/tmp') ? '/var/tmp' : os.tmpdir();
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-photo-editor-e2e-'));
|| fs.mkdtempSync(path.join(tmpBase, 'odysseus-photo-editor-e2e-'));
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH
|| path.join(dataDirectory, 'app.db');
const authenticated = process.env.PHOTO_EDITOR_E2E_AUTH !== '0';
process.env.PHOTO_EDITOR_E2E_DB_PATH = databasePath;
process.env.PHOTO_EDITOR_E2E_DATA_DIR = dataDirectory;
@@ -22,6 +24,9 @@ if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
}
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
module.exports = defineConfig({
testDir: path.join(__dirname, 'photo-editor'),
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
@@ -29,18 +34,24 @@ module.exports = defineConfig({
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
reporter: isCI ? 'line' : 'line',
globalSetup: require.resolve('./setup.js'),
globalTeardown: require.resolve('./teardown.js'),
use: {
baseURL,
browserName,
headless: true,
launchOptions: {
args: ['--disable-dev-shm-usage', '--no-sandbox'],
},
viewport: { width: 1440, height: 960 },
extraHTTPHeaders: { 'Accept-Encoding': 'identity' },
serviceWorkers: 'block',
...(authenticated ? { storageState: path.join(dataDirectory, 'browser-auth.json') } : {}),
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
// Unconditional CI trace suppression: Playwright traces are NEVER recorded in CI.
// Local developer tracing requires explicit PLAYWRIGHT_ENABLE_TRACE=1.
trace: traceOptIn ? 'retain-on-failure' : 'off',
},
webServer: {
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
@@ -50,7 +61,7 @@ module.exports = defineConfig({
reuseExistingServer: false,
env: {
...process.env,
AUTH_ENABLED: 'false',
AUTH_ENABLED: authenticated ? 'true' : 'false',
DATABASE_URL: `sqlite:///${databasePath}`,
ODYSSEUS_DATA_DIR: dataDirectory,
ODYSSEUS_STARTUP_WARMUPS: '0',
+16
View File
@@ -1,4 +1,6 @@
const fs = require('node:fs');
const path = require('node:path');
const { request } = require('@playwright/test');
module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
@@ -7,4 +9,18 @@ module.exports = async () => {
throw new Error('Photo editor E2E data paths were not configured');
}
fs.mkdirSync(dataDirectory, { recursive: true });
if (process.env.PHOTO_EDITOR_E2E_AUTH !== '0') {
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
const client = await request.newContext({ baseURL: `http://127.0.0.1:${port}` });
try {
const credentials = { username: 'editor-e2e', password: 'disposable-editor-e2e-password' };
const setup = await client.post('/api/auth/setup', { data: credentials });
if (!setup.ok()) throw new Error(`Disposable editor account setup failed: ${setup.status()}`);
const login = await client.post('/api/auth/login', { data: credentials });
if (!login.ok()) throw new Error(`Disposable editor account login failed: ${login.status()}`);
await client.storageState({ path: path.join(dataDirectory, 'browser-auth.json') });
} finally {
await client.dispose();
}
}
};
+127 -3
View File
@@ -1,7 +1,131 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
module.exports = async () => {
const repoRoot = path.resolve(__dirname, '..', '..');
const defaultResultsDir = path.join(repoRoot, 'test-results');
const defaultReportDir = path.join(repoRoot, 'playwright-report');
const defaultSanitizerScript = path.join(repoRoot, 'scripts', 'sanitize_trace_artifacts.py');
/**
* Confine dynamic paths to explicitly allowed test directories.
*/
function validateTestPath(targetPath, allowedBases, label) {
if (!targetPath || typeof targetPath !== 'string') return null;
const resolved = path.resolve(targetPath);
const isAllowed = allowedBases.some((base) => {
const resolvedBase = path.resolve(base);
return resolved.startsWith(resolvedBase + path.sep) || resolved === resolvedBase;
});
if (!isAllowed) {
throw new Error(`Security rejection: ${label} path "${targetPath}" is outside allowed test directories`);
}
return resolved;
}
module.exports = async (options = {}) => {
// If options is a Playwright FullConfig object, ignore it and use safe defaults
const isPlaywrightConfig = options && (options.configFile || options.rootDir);
const testOpts = (!isPlaywrightConfig && typeof options === 'object') ? options : {};
// Confine cleanup of PHOTO_EDITOR_E2E_DATA_DIR:
// Strictly require it to be within an OS temporary directory and match the expected test prefix
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
if (!dataDirectory) return;
fs.rmSync(dataDirectory, { recursive: true, force: true });
if (dataDirectory && typeof dataDirectory === 'string') {
try {
const resolvedData = path.resolve(dataDirectory);
const allowedTmpBases = [os.tmpdir(), '/var/tmp', '/tmp'].map((b) => path.resolve(b));
const isTmp = allowedTmpBases.some((base) => resolvedData.startsWith(base + path.sep));
const baseName = path.basename(resolvedData);
if (isTmp && baseName.startsWith('odysseus-photo-editor-e2e-')) {
fs.rmSync(resolvedData, { recursive: true, force: true });
}
} catch (_) {}
}
// Secure default tracing: never publish trace archives in CI or without explicit opt-in.
// When traces are not opted-in, purge all archives unconditionally.
// When local opt-in tracing is active, run the strict fail-closed structured sanitizer.
const allowedBases = [defaultResultsDir, defaultReportDir, os.tmpdir(), '/var/tmp', '/tmp'];
const resultsDir = testOpts.resultsDir
? validateTestPath(testOpts.resultsDir, allowedBases, 'resultsDir')
: defaultResultsDir;
const reportDir = testOpts.reportDir
? validateTestPath(testOpts.reportDir, allowedBases, 'reportDir')
: defaultReportDir;
if (fs.existsSync(reportDir)) {
try {
fs.rmSync(reportDir, { recursive: true, force: true });
} catch (_) {}
}
if (fs.existsSync(resultsDir)) {
const purgeSensitiveArtifacts = (dir) => {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
purgeSensitiveArtifacts(fullPath);
} else if (entry.isFile() && (
entry.name.endsWith('.zip') ||
entry.name.endsWith('.tmp') ||
entry.name.endsWith('.png') ||
entry.name.endsWith('.har')
)) {
try {
fs.rmSync(fullPath, { force: true });
} catch (_) {}
}
}
};
const purgeZipAndTmp = (dir) => {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
purgeZipAndTmp(fullPath);
} else if (entry.isFile() && (entry.name.endsWith('.zip') || entry.name.endsWith('.tmp'))) {
try {
fs.rmSync(fullPath, { force: true });
} catch (_) {}
}
}
};
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
if (isCI || !traceOptIn) {
// In CI or default local runs, purge all sensitive trace archives and artifacts
purgeSensitiveArtifacts(resultsDir);
} else {
const python = process.env.ODYSSEUS_TEST_PYTHON || 'python';
const sanitizerScript = testOpts.sanitizerScript
? validateTestPath(testOpts.sanitizerScript, [path.join(repoRoot, 'scripts'), os.tmpdir(), '/var/tmp', '/tmp'], 'sanitizerScript')
: defaultSanitizerScript;
const rawTimeout = Number(testOpts.timeout) || 30_000;
const timeout = Math.max(100, Math.min(rawTimeout, 120_000));
let sanitizerSucceeded = false;
try {
execFileSync(python, [sanitizerScript, resultsDir], {
stdio: 'ignore',
timeout,
shell: false,
});
sanitizerSucceeded = true;
} catch (_) {
sanitizerSucceeded = false;
}
if (!sanitizerSucceeded) {
// Fail closed: purge all remaining zip or tmp archives
purgeZipAndTmp(resultsDir);
}
}
}
};
+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)"
}
}
+268
View File
@@ -0,0 +1,268 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
test('Gallery and Quick Edit Model Picker: identity, privacy, and synchronization', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const mockModels = [
{
id: 'sdxl',
label: 'Stable Diffusion XL (Primary)',
kind: 'edit',
provider: 'Cluster A',
endpoint_id: 101,
// Non-admin view: base_url omitted
},
{
id: 'sdxl',
label: 'Stable Diffusion XL (Secondary)',
kind: 'edit',
provider: 'Cluster B',
endpoint_id: 202,
// Non-admin view: base_url omitted
},
{
id: 'qwen-image',
label: 'Qwen Image Edit',
kind: 'edit',
provider: 'Local Seneca',
endpoint_id: 303,
},
];
await page.route('http://gallery-sync.local/**', async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
if (path === '/') {
return route.fulfill({
contentType: 'text/html',
body: `<!DOCTYPE html>
<html>
<body>
<div id="gallery-settings-container">
<select id="gallery-image-model"></select>
</div>
<div id="editor-container">
<select class="ge-ai-command-model" id="ge-ai-command-model"></select>
<div id="ge-status-model"></div>
</div>
</body>
</html>`,
});
}
if (path === '/api/editor/image-models') {
return route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ models: mockModels }),
});
}
if (path === '/api/editor/agent-models') {
return route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ models: [] }),
});
}
try {
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
return route.fulfill({ contentType: 'text/javascript', body: source });
} catch (err) {
return route.abort();
}
});
await page.goto('http://gallery-sync.local/');
// --- Test 1: Option value encoding and distinct endpoint identities ---
const evalResult1 = await page.evaluate(async () => {
// Set legacy localStorage value that contained a private base URL
localStorage.setItem('ge-ai-command-model', 'http://private-cluster.internal/v1::qwen-image');
const { wireAIModelSelectors } = await import('/static/js/editor/ai-models.js');
wireAIModelSelectors({ container: document.body, apiBase: '' });
// Run gallery initialization
const { openGallery } = await import('/static/js/gallery.js');
try { openGallery(); } catch (_) {}
// Wait a moment for network fetch
await new Promise((r) => setTimeout(r, 80));
const gallerySel = document.getElementById('gallery-image-model');
const qeSel = document.getElementById('ge-ai-command-model');
const galleryOpts = Array.from(gallerySel.options).map((o) => ({ value: o.value, text: o.textContent }));
const qeOpts = Array.from(qeSel.options).map((o) => ({ value: o.value, text: o.textContent }));
const migratedKey = localStorage.getItem('ge-ai-command-model');
return {
galleryOpts,
qeOpts,
galleryValue: gallerySel.value,
qeValue: qeSel.value,
migratedKey,
};
});
// Check duplicate model id 'sdxl' produces distinguishable values with endpoint_id
const sdxlGalleryOpts = evalResult1.galleryOpts.filter((o) => o.value.endsWith('::sdxl'));
assert.equal(sdxlGalleryOpts.length, 2, 'Two endpoints offering sdxl produce two distinct options');
assert.equal(sdxlGalleryOpts[0].value, '101::sdxl');
assert.equal(sdxlGalleryOpts[1].value, '202::sdxl');
// Check no options contain undefined or empty prefix
for (const opt of evalResult1.galleryOpts) {
assert.equal(opt.value.startsWith('::'), false, 'Option value must not start with empty ::');
assert.equal(opt.value.includes('undefined'), false, 'Option value must not include undefined');
assert.equal(opt.value.includes('http://'), false, 'Option value must not expose raw http URL');
}
// Check legacy migration: 'http://private-cluster.internal/v1::qwen-image' was migrated to '303::qwen-image'
assert.equal(evalResult1.migratedKey, '303::qwen-image', 'Legacy key with URL migrated cleanly');
assert.equal(evalResult1.galleryValue, '303::qwen-image', 'Gallery picker selected migrated model');
assert.equal(evalResult1.qeValue, '303::qwen-image', 'Quick Edit picker selected migrated model');
// --- Test 2: Same-tab synchronization (Gallery -> Quick Edit) ---
const evalResult2 = await page.evaluate(async () => {
const gallerySel = document.getElementById('gallery-image-model');
gallerySel.value = '101::sdxl';
gallerySel.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 40));
const qeSel = document.getElementById('ge-ai-command-model');
const statusEl = document.getElementById('ge-status-model');
const savedKey = localStorage.getItem('ge-ai-command-model');
return {
qeValue: qeSel.value,
statusText: statusEl ? statusEl.textContent : '',
savedKey,
};
});
assert.equal(evalResult2.qeValue, '101::sdxl', 'Changing gallery picker updates quick-edit picker in same tab');
assert.equal(evalResult2.savedKey, '101::sdxl', 'Changing gallery picker updates localStorage');
// --- Test 3: Same-tab synchronization (Quick Edit -> Gallery) ---
const evalResult3 = await page.evaluate(async () => {
const qeSel = document.getElementById('ge-ai-command-model');
qeSel.value = '202::sdxl';
qeSel.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 40));
const gallerySel = document.getElementById('gallery-image-model');
const savedKey = localStorage.getItem('ge-ai-command-model');
return {
galleryValue: gallerySel.value,
savedKey,
};
});
assert.equal(evalResult3.galleryValue, '202::sdxl', 'Changing quick-edit picker updates gallery picker in same tab');
assert.equal(evalResult3.savedKey, '202::sdxl', 'Changing quick-edit picker updates localStorage');
// --- Test 4: Cross-tab synchronization via storage event ---
const evalResult4 = await page.evaluate(async () => {
// Simulate storage event from another tab
window.dispatchEvent(new StorageEvent('storage', {
key: 'ge-ai-command-model',
newValue: '101::sdxl',
}));
await new Promise((r) => setTimeout(r, 40));
const gallerySel = document.getElementById('gallery-image-model');
const qeSel = document.getElementById('ge-ai-command-model');
return {
galleryValue: gallerySel.value,
qeValue: qeSel.value,
};
});
assert.equal(evalResult4.galleryValue, '101::sdxl', 'Storage event updates gallery picker');
assert.equal(evalResult4.qeValue, '101::sdxl', 'Storage event updates quick-edit picker');
} finally {
await browser.close();
}
});
test('picker reconstruction owns listeners and clears invalid persisted identities', async () => {
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext();
let imageRequests = 0;
await context.route('http://picker-lifecycle.local/**', async route => {
const path = new URL(route.request().url()).pathname;
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<div id="ge-status-model"></div><div id="editor-container"><select id="ge-ai-command-model"></select><select id="ge-ai-inpaint"></select></div>' });
if (path.startsWith('/api/')) {
if (path.endsWith('/image-models')) imageRequests++;
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ models: path.endsWith('/image-models') ? [{ id: 'qwen-image', endpoint_id: 'alice', kind: 'edit', provider: 'A' }] : [], items: [], albums: [] }) });
}
try { return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') }); }
catch { return route.fulfill({ status: 404, body: '' }); }
});
const page = await context.newPage();
await page.addInitScript(() => {
window.modelListeners = {};
const add = window.addEventListener.bind(window);
window.addEventListener = (type, ...args) => { if (['storage', 'ge-ai-command-model-change', 'ge:model-endpoints-updated'].includes(type)) window.modelListeners[type] = (window.modelListeners[type] || 0) + 1; return add(type, ...args); };
window.modelEvents = 0;
add('ge-ai-command-model-change', () => window.modelEvents++);
});
const init = async p => {
await p.goto('http://picker-lifecycle.local/');
await p.evaluate(async () => {
window.ai = await import('/static/js/editor/ai-models.js');
window.gallery = await import('/static/js/gallery.js');
window.ai.wireAIModelSelectors({ container: document.getElementById('editor-container') });
window.gallery.openGallery();
});
await p.waitForFunction(() => document.getElementById('gallery-image-model')?.options.length === 2 && document.getElementById('ge-ai-command-model')?.options.length > 2);
};
await init(page);
const initial = await page.evaluate(() => ({ ...window.modelListeners }));
for (let i = 0; i < 3; i++) {
await page.evaluate(() => { window.gallery.closeGallery(); window.oldSelect = document.getElementById('ge-ai-command-model'); window.oldValue = window.oldSelect.value; document.getElementById('editor-container').replaceChildren(); });
await page.waitForFunction(() => !document.getElementById('gallery-modal'));
await page.evaluate(() => {
document.getElementById('editor-container').innerHTML = '<select id="ge-ai-command-model"></select><select id="ge-ai-inpaint"></select>';
window.ai.wireAIModelSelectors({ container: document.getElementById('editor-container') });
window.gallery.openGallery();
});
await page.waitForFunction(() => document.getElementById('gallery-image-model')?.options.length === 2 && document.getElementById('ge-ai-command-model')?.options.length > 2);
}
assert.deepEqual(await page.evaluate(() => window.modelListeners), initial, 'no new global listeners after reopen/reconstruction');
const before = imageRequests;
await page.evaluate(() => window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated')));
await page.waitForTimeout(100);
assert.equal(imageRequests - before, 1, 'only live editor refreshes; no detached control handlers');
const p2 = await context.newPage(); await init(p2);
await page.evaluate(() => { window.modelEvents = 0; window.footerUpdates = 0; new MutationObserver(records => window.footerUpdates += records.length).observe(document.getElementById('ge-status-model'), { childList: true }); const s = document.getElementById('ge-ai-command-model'); s.value = 'alice::qwen-image'; s.dispatchEvent(new Event('change')); });
await p2.waitForFunction(() => document.getElementById('gallery-image-model').value === 'alice::qwen-image');
assert.equal(await page.evaluate(() => window.modelEvents), 1);
assert.equal(await page.evaluate(() => window.footerUpdates), 1, 'exactly one footer update');
assert.match(await page.locator('#ge-status-model').textContent(), /Edit:.*qwen-image/);
assert.equal(await page.evaluate(() => window.oldSelect.value), await page.evaluate(() => window.oldValue), 'global events never update detached controls');
await init(p2);
assert.equal(await p2.locator('#gallery-image-model').inputValue(), 'alice::qwen-image');
for (const invalid of ['deleted::qwen-image', 'disabled::qwen-image', 'https://private.invalid/v1::missing']) {
await page.evaluate(value => localStorage.setItem('ge-ai-command-model', value), invalid);
await init(page);
assert.equal(await page.evaluate(() => localStorage.getItem('ge-ai-command-model')), null, invalid);
assert.equal(await page.locator('#gallery-image-model').inputValue(), '');
assert.equal(await page.locator('#ge-ai-command-model').inputValue(), '');
}
} finally { await browser.close(); }
});
+1 -1
View File
@@ -189,7 +189,7 @@ def bare_module_stubs():
test modules install them on purpose.
"""
found = set()
for name, mod in list(sys.modules.items()):
for name, mod in sys.modules.copy().items():
if not name.startswith(_GUARDED_PREFIXES):
continue
if type(mod) is not types.ModuleType:
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { chromium } from 'playwright';
const source = readFileSync(new URL('../static/js/document.js', import.meta.url), 'utf8');
const build = source.slice(source.indexOf(' function _buildAnnotation('), source.indexOf(' // Prompt user for an instruction'));
const state = source.slice(source.indexOf(' let _pdfAnnotationDragging'), source.indexOf(' // Per-doc last-used line spacing'));
const placement = source.slice(source.indexOf(' const isBlankPageTarget'), source.indexOf(' pane.appendChild(pageWrap);', source.indexOf(' const isBlankPageTarget')));
const browser = await chromium.launch({ headless: true });
try {
for (const touch of [true, false]) {
const page = await browser.newPage({ viewport: { width: 600, height: 800 }, hasTouch: touch });
await page.setContent('<div id="page" style="position:relative;width:500px;height:700px;background:white"></div>');
await page.evaluate(({ build, state, placement }) => {
new Function(`
const activeDocId = 'test', docId = activeDocId;
const _pdfPaneAnnotationsByDoc = new Map([['test', []]]);
const annotationRefs = _pdfPaneAnnotationsByDoc.get('test');
const _pdfLastLineHeight = new Map(), _pdfLastFontSize = new Map();
let _pdfDropMode = 'check', serial = 0;
const _newAnnotationId = () => 'new-' + (++serial);
const _pushPdfUndoSnapshot = () => {};
const _schedulePdfPaneSave = () => {};
const pageWrap = document.getElementById('page');
const page = {page: 1};
${state}
${build}
${placement}
for (let i = 0; i < 2; i++) {
const {ref} = _buildAnnotation(pageWrap, {id:'a'+i, page:1, x:20+i*35, y:30, w:5, h:5, kind:'check'});
annotationRefs.push(ref);
ref._setHandlesVisible(true);
}
window.refs = annotationRefs;
`)();
}, { build, state, placement });
const grip = page.locator('[data-ann-id="a0"] [title="Drag to move"]');
const box = await grip.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(300, 220);
assert(await page.evaluate(() => refs.every(ref => [...ref.wrap.querySelectorAll('[title]')].every(el => el.style.display === 'none'))));
// Proximity updates must not bring controls back while dragging.
await page.evaluate(() => refs.forEach(ref => ref._setHandlesVisible(true)));
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isHidden());
await page.mouse.up();
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isVisible());
await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').click();
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 1);
await page.locator('#page').click({ position: { x: 100, y: 450 } });
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
console.log(`PASS ${touch ? 'touch' : 'mouse'}: drag controls hidden, delete only deletes, blank page still places`);
await page.close();
}
} finally {
await browser.close();
}
+252
View File
@@ -0,0 +1,252 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
test('StreamingRenderer: rAF coalescing and lifecycle cancellation', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.route('http://streaming.test/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path === '/') {
return route.fulfill({ contentType: 'text/html', body: '<!DOCTYPE html><html><body><div id="root"></div></body></html>' });
}
try {
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
return route.fulfill({ contentType: 'text/javascript', body: source });
} catch (err) {
return route.abort();
}
});
await page.goto('http://streaming.test/');
const result = await page.evaluate(async () => {
const container = document.getElementById('root');
// Custom mock for rAF to control frame timing
const originalRaf = window.requestAnimationFrame;
const originalCancelRaf = window.cancelAnimationFrame;
let rafId = 0;
const pendingRfs = new Map();
window.requestAnimationFrame = (cb) => {
const id = ++rafId;
pendingRfs.set(id, cb);
return id;
};
window.cancelAnimationFrame = (id) => {
pendingRfs.delete(id);
};
const stepFrames = () => {
const callbacks = Array.from(pendingRfs.values());
pendingRfs.clear();
for (const cb of callbacks) cb(performance.now());
return callbacks.length;
};
const { createStreamRenderer } = await import('/static/js/streamingRenderer.js');
const options = { render: (text) => `<p>${text}</p>` };
// Test 1: Fast bursts within a frame coalesce into a single rAF render
const host1 = document.createElement('div');
container.appendChild(host1);
const renderer1 = createStreamRenderer(host1, options);
// Initial token renders synchronously to remove spinner immediately
renderer1.update('A');
const textAfterFirst = host1.textContent;
// Now stream 5 rapid tokens in the same frame
renderer1.update('AB');
renderer1.update('ABC');
renderer1.update('ABCD');
renderer1.update('ABCDE');
// Before frame fires, only the scheduled pendingTail exists
const textBeforeFrame = host1.textContent;
const frameExecuted = stepFrames();
const textAfterFrame = host1.textContent;
// Test 2: Finalize cancels pending rAF callback and freezes remaining text
const host2 = document.createElement('div');
container.appendChild(host2);
const renderer2 = createStreamRenderer(host2, options);
renderer2.update('First');
renderer2.update('First, more rapid streaming');
const pendingBeforeFinalize = pendingRfs.size;
renderer2.finalize();
const pendingAfterFinalize = pendingRfs.size;
const textAfterFinalize = host2.textContent;
// Executing any residual frames must do nothing because cancelAnimationFrame was called
const residualSteps = stepFrames();
const textAfterResidual = host2.textContent;
// Test 3: Reset clears state and cancels pending frames
const host3 = document.createElement('div');
container.appendChild(host3);
const renderer3 = createStreamRenderer(host3, options);
renderer3.update('Start');
renderer3.update('Start and streaming tokens...');
renderer3.reset();
const pendingAfterReset = pendingRfs.size;
// Test 4: Finalized blocks (\n\n) freeze synchronously
const host4 = document.createElement('div');
container.appendChild(host4);
const renderer4 = createStreamRenderer(host4, options);
renderer4.update('Paragraph 1\n\n');
renderer4.update('Paragraph 1\n\nParagraph 2');
stepFrames();
renderer4.finalize();
// Test 5: DOM marker replacement during streaming preserves text across immediate finalize
const host5 = document.createElement('div');
container.appendChild(host5);
const renderer5 = createStreamRenderer(host5, options);
renderer5.update('First chunk\n\n');
stepFrames();
// Simulate container rewrite by thinking indicator or tool card
host5.innerHTML = '<div class="thinking">Thinking...</div>';
// Stream update on replaced DOM
renderer5.update('First chunk\n\nSecond chunk following replace');
stepFrames();
// Finalize immediately
renderer5.finalize();
const host5FinalText = host5.textContent.trim();
// Test 6: Asynchronous exception inside rAF callback falls back to degraded full render safely
const host6 = document.createElement('div');
container.appendChild(host6);
let crashTailRender = false;
let tailCrashes = 0;
const unstableOptions = {
render: (text) => {
if (crashTailRender && text === 'pending burst') {
tailCrashes++;
throw new Error('Simulated partial tail rAF render crash');
}
return `<p>${text}</p>`;
}
};
const renderer6 = createStreamRenderer(host6, unstableOptions);
renderer6.update('Stable start\n\n');
stepFrames();
crashTailRender = true;
renderer6.update('Stable start\n\npending');
renderer6.update('Stable start\n\npending burst');
// Step frame: triggers renderTail inside rAF, which catches and falls back to full render
stepFrames();
crashTailRender = false;
renderer6.finalize();
const host6FinalText = host6.textContent.trim();
// Restore
window.requestAnimationFrame = originalRaf;
window.cancelAnimationFrame = originalCancelRaf;
return {
textAfterFirst,
textBeforeFrame,
frameExecuted,
textAfterFrame,
pendingBeforeFinalize,
pendingAfterFinalize,
textAfterFinalize,
residualSteps,
textAfterResidual,
pendingAfterReset,
host4Text: host4.textContent.trim(),
host5FinalText,
host6FinalText,
tailCrashes,
};
});
assert.equal(result.tailCrashes, 1, 'partial tail error path genuinely entered');
assert.equal(result.textAfterFirst, 'A', 'First token renders synchronously for immediate responsiveness');
assert.equal(result.frameExecuted, 1, 'Multiple rapid updates coalesce into a single animation frame');
assert.equal(result.textAfterFrame, 'ABCDE', 'Coalesced tail renders full updated content');
assert.equal(result.pendingBeforeFinalize > 0, true, 'Pending rAF was queued during rapid streaming');
assert.equal(result.pendingAfterFinalize, 0, 'finalize() successfully cancels pending animation frames');
assert.equal(result.textAfterFinalize, 'First, more rapid streaming', 'finalize() rendered complete final text');
assert.equal(result.residualSteps, 0, 'No orphaned rAF callbacks executed after finalize');
assert.equal(result.pendingAfterReset, 0, 'reset() cancels pending frames');
assert.equal(result.host4Text.includes('Paragraph 1') && result.host4Text.includes('Paragraph 2'), true);
assert.equal(
result.host5FinalText.includes('First chunk') && result.host5FinalText.includes('Second chunk following replace'),
true,
'finalize() after self-healing preserves full text without erasing content'
);
assert.equal(
result.host6FinalText,
'Stable start\n\npending burst',
'rAF callback error triggers degraded full render without destroying content'
);
} finally {
await browser.close();
}
});
test('combined rAF/full-render failures preserve authoritative text and recover canonically', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.route('http://recovery.test/**', async route => {
const path = new URL(route.request().url()).pathname;
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<div id="root"></div>' });
if (path.startsWith('/api/')) return route.fulfill({ contentType: 'application/json', body: '{}' });
return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') });
});
await page.goto('http://recovery.test/');
const cases = await page.evaluate(async () => {
const { createStreamRenderer } = await import('/static/js/streamingRenderer.js');
const { mdToHtml } = await import('/static/js/markdown.js');
const canonical = text => { const d = document.createElement('div'); d.innerHTML = mdToHtml(text); return d.innerHTML.replace(/>\s*\n\s*</g, '><').trim(); };
let next = 0; const pending = new Map(); const cancelled = [];
window.requestAnimationFrame = cb => { pending.set(++next, cb); return next; };
window.cancelAnimationFrame = id => { if (pending.has(id)) cancelled.push(pending.get(id)); pending.delete(id); };
const frame = () => { const callbacks = [...pending.values()]; pending.clear(); callbacks.forEach(cb => cb()); };
const results = [];
for (const recover of [false, true]) {
const el = document.createElement('div'); let broken = false; let throws = 0;
const renderer = createStreamRenderer(el, { render: text => { if (broken) { throws++; throw Error('both render paths fail'); } return mdToHtml(text); } });
renderer.update('prefix\n\ntail'); renderer.update('prefix\n\ntail plus'); broken = true;
frame(); // auditor reproduction: both tail and full fallback must actually throw
const afterFailure = el.textContent;
if (recover) broken = false;
renderer.finalize();
const expected = 'prefix\n\ntail plus';
results.push({ recover, throws, afterFailure, text: el.textContent, html: el.innerHTML, canonical: canonical(expected) });
broken = false;
renderer.update(expected + '\n\n```js\nconst a = 1;\n```\n\nEnd'); renderer.finalize();
results.at(-1).subsequentCanonical = el.innerHTML.replace(/>\s*\n\s*</g, '><').trim() === canonical(expected + '\n\n```js\nconst a = 1;\n```\n\nEnd');
}
// Broken first-token/synchronous path must also preserve text safely.
const el = document.createElement('div'); const bad = createStreamRenderer(el, { render: () => { throw Error('always broken'); } });
bad.update('<img src=x onerror=alert(1)>\n```js\nx\n'); bad.finalize();
results.push({ safeText: el.textContent, children: el.childElementCount });
// Pending callback, DOM rewrite, self-healing, then immediate finalize.
const host = document.createElement('div'); const healed = createStreamRenderer(host, { render: mdToHtml });
healed.update('A'); healed.update('AB'); host.innerHTML = 'thinking'; healed.update('ABC'); healed.update('ABCD'); healed.finalize();
const before = host.innerHTML; frame(); cancelled.forEach(cb => cb());
results.push({ healed: host.textContent, stable: before === host.innerHTML, pending: pending.size });
return results;
});
for (const result of cases.slice(0, 2)) {
assert.ok(result.throws >= 2, 'both incremental and full render actually threw');
assert.equal(result.afterFailure, 'prefix\n\ntail plus', 'complete safe text after failure');
if (result.recover) assert.equal(result.html.replace(/>\s*\n\s*</g, '><').trim(), result.canonical);
else assert.equal(result.text, 'prefix\n\ntail plus');
assert.equal(result.subsequentCanonical, true);
}
assert.equal(cases[2].safeText, '<img src=x onerror=alert(1)>\n```js\nx\n');
assert.equal(cases[2].children, 0, 'fallback never interprets HTML');
assert.deepEqual(cases[3], { healed: 'ABCD', stable: true, pending: 0 });
} finally { await browser.close(); }
});
+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
+135
View File
@@ -0,0 +1,135 @@
"""Real admin gates must run before MCP and diagnostic data access."""
import builtins
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from core.auth import AuthManager
from routes.auth_routes import SESSION_COOKIE
from tests.helpers.database import disposable_database
@pytest.fixture
def authenticated_app(tmp_path, monkeypatch):
monkeypatch.setenv("AUTH_ENABLED", "true")
auth = AuthManager(str(tmp_path / "auth.json"))
for user, admin in (("member", False), ("admin", True)):
assert auth.create_user(user, "disposable-test-password", is_admin=admin)
app = FastAPI()
app.state.auth_manager = auth
@app.middleware("http")
async def fixture_session(request, call_next):
# Supply the authenticated principal, without replacing require_admin.
request.state.current_user = auth.get_username_for_token(request.cookies.get(SESSION_COOKIE))
return await call_next(request)
def client_for(user):
client = TestClient(app)
if user != "anonymous":
token = auth.create_session(user, "disposable-test-password")
assert token
client.cookies.set(SESSION_COOKIE, token)
return client
return app, client_for
@pytest.mark.parametrize("user", ["anonymous", "member", "admin"])
@pytest.mark.parametrize("path", ["/servers", "/tools", "/servers/private-server/tools"])
def test_mcp_listing_requires_real_admin_before_data_access(
tmp_path, monkeypatch, authenticated_app, user, path,
):
from core.database import McpServer
from routes.mcp import mcp_routes
app, client_for = authenticated_app
with disposable_database(tmp_path) as factory:
with factory() as db:
db.add(McpServer(
id="private-server", name="private-config-marker", transport="stdio",
command="fixture-only", args="[]", env=json.dumps({"FIXTURE": "private-config-marker"}),
))
db.commit()
session = Mock(wraps=factory)
manager = SimpleNamespace(
get_server_status=Mock(return_value={"status": "connected", "tool_count": 1}),
get_all_tools=Mock(return_value=[{
"server_id": "private-server", "name": "private-tool", "description": "private-config-marker",
}]),
)
# setup_mcp_routes otherwise appends to a shared process-global router.
monkeypatch.setattr(mcp_routes, "router", APIRouter(prefix="/api/mcp"))
monkeypatch.setattr(mcp_routes, "SessionLocal", session)
app.include_router(mcp_routes.setup_mcp_routes(manager))
with client_for(user) as client:
response = client.get("/api/mcp" + path)
if user == "admin":
assert response.status_code == 200
assert "private-config-marker" in response.text
payload = response.json()
if path == "/servers":
assert payload[0]["env"] == {"FIXTURE": "private-config-marker"}
else:
assert payload[0]["name"] == "private-tool"
else:
assert response.status_code == 403
assert response.json() == {"detail": "Admin only"}
assert "private-config-marker" not in response.text
session.assert_not_called()
manager.get_server_status.assert_not_called()
manager.get_all_tools.assert_not_called()
@pytest.mark.parametrize("user", ["anonymous", "member", "admin"])
@pytest.mark.parametrize("method,path", [
("GET", "/api/db/stats"),
("GET", "/api/rag/stats"),
("GET", "/api/test/youtube?url=https://youtu.be/fixture"),
("POST", "/api/test-research"),
("GET", "/api/diagnostics/services"),
("GET", "/api/diagnostics/logs"),
])
def test_diagnostics_require_real_admin_before_sensitive_work(
tmp_path, monkeypatch, authenticated_app, user, method, path,
):
import core.database as database
import src.service_health as health
from routes import diagnostics_routes as routes
app, client_for = authenticated_app
marker = "private-diagnostics-marker"
stats = Mock(return_value={"marker": marker})
rag = SimpleNamespace(get_stats=Mock(return_value={"marker": marker}))
research = SimpleNamespace(call_research_service=AsyncMock(return_value=marker))
extract_id = Mock(return_value="fixture")
transcript = AsyncMock(return_value={"success": True, "transcript": marker})
collect = AsyncMock(return_value={"marker": marker})
log_dir = tmp_path / "logs"
log_dir.mkdir()
(log_dir / "app.log").write_text(marker + "\n", encoding="utf-8")
read_log = Mock(wraps=builtins.open)
monkeypatch.setattr(database, "get_detailed_stats", stats)
monkeypatch.setattr(routes, "extract_youtube_id", extract_id)
monkeypatch.setattr(routes, "extract_transcript_async", transcript)
monkeypatch.setattr(health, "collect_service_health", collect)
monkeypatch.setattr(routes, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(routes, "open", read_log, raising=False)
app.include_router(routes.setup_diagnostics_routes(rag, True, research))
with client_for(user) as client:
response = client.request(method, path)
if user == "admin":
assert response.status_code == 200
assert marker in response.text
else:
assert response.status_code == 403
assert response.json() == {"detail": "Admin only"}
assert marker not in response.text
for operation in (stats, rag.get_stats, research.call_research_service,
extract_id, transcript, collect, read_log):
operation.assert_not_called()
File diff suppressed because it is too large Load Diff
+7
View File
@@ -19,6 +19,13 @@ def test_infers_only_explicit_output_or_edit_paths():
assert requirements.verifier_required is True
def test_email_address_is_not_an_output_artifact():
requirements = infer_completion_requirements(
"Write a summary and email it to jane.doe@example.com."
)
assert requirements.required_artifacts == ()
def test_known_workspace_inputs_are_not_inferred_as_output_artifacts():
requirements = infer_completion_requirements(
"My clues are saved in /tmp_workspace/inputs/clues.md, the required answer "
+6
View File
@@ -65,6 +65,12 @@ def test_what_im_writing_targets_the_visible_document():
language="richtext",
)
assert _turn_targets_active_document(
{"domains": set()},
"can you fact check what Im writing",
active_document,
)
def test_unrelated_fact_check_does_not_target_a_stale_visible_document():
active_document = MagicMock(
+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):
+5 -3
View File
@@ -83,6 +83,7 @@ def _auth_regressions_stubs(monkeypatch):
build_chat_url=MagicMock(),
build_models_url=MagicMock(),
build_headers=MagicMock(),
resolve_owner_registered_endpoint_url=MagicMock(),
)
# _ensure_stub now registers each stub through monkeypatch itself, so the
# whole set is undone at teardown. Re-setting them here would capture the
@@ -233,9 +234,10 @@ def test_research_status_rejects_wrong_owner():
rh.get_status.return_value = {"status": "running", "progress": {}}
router = setup_research_routes(rh)
target = next(r.endpoint for r in router.routes if getattr(r, "path", "") == "/api/research/status/{session_id}")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="x", request=_fake_request(user="bob")))
assert exc.value.status_code == 404
# Another owner's research is indistinguishable from no research at all.
out = asyncio.run(target(session_id="x", request=_fake_request(user="bob")))
assert out == {"status": "none"}
rh.get_status.assert_not_called()
def test_research_cancel_rejects_anonymous():
+31
View File
@@ -0,0 +1,31 @@
"""A valid login session cookie must survive a deploy restart of the server.
AuthManager keeps tokens in memory but persists them to sessions.json on create
and reloads them at startup, so a restart must NOT log the user out (the 302 the
bench saw was the restart gap / a client-side artifact, not session loss).
"""
from core import auth
def test_session_token_survives_a_restart(tmp_path):
auth_path = str(tmp_path / "auth.json")
first = auth.AuthManager(auth_path)
assert first.create_user("pewds", "hunter2", is_admin=True)
token = first.create_session_trusted("pewds")
assert token and first.validate_token(token)
# Simulate a process restart: a brand-new AuthManager on the same data dir.
second = auth.AuthManager(auth_path)
assert second.validate_token(token) is True
assert second.get_username_for_token(token) == "pewds"
def test_revoked_token_does_not_survive_restart(tmp_path):
auth_path = str(tmp_path / "auth.json")
first = auth.AuthManager(auth_path)
first.create_user("pewds", "hunter2", is_admin=True)
token = first.create_session_trusted("pewds")
first.revoke_token(token)
second = auth.AuthManager(auth_path)
assert second.validate_token(token) is False
+248
View File
@@ -0,0 +1,248 @@
"""Offline image acceptance regressions for Debian's native Brotli backport."""
import contextlib
import ctypes
import io
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from tests.helpers.cli_loader import load_script
checker = load_script("check_brotli.py")
ROOT = Path(__file__).resolve().parents[1]
class PackageFloorTests(unittest.TestCase):
@unittest.skipUnless(shutil.which("dpkg"), "Debian version comparisons require dpkg")
def test_debian_backport_floors_use_real_dpkg_comparisons(self):
cases = [
("12", "1.0.9-2", False),
("12", "1.0.9-2+deb12u1", True),
("12", "1.0.9-2+deb12u2", True),
("13", "1.1.0-2+b7", False),
("13", "1.1.0-2+deb13u1~pre1", False),
("13", "1.1.0-2+deb13u1", True),
("13", "1.1.0-2+deb13u1+b1", True),
("13", "1.1.0-2+deb13u2", True),
]
for release, version, accepted in cases:
with self.subTest(release=release, version=version), patch.object(
checker.platform, "freedesktop_os_release", return_value={"ID": "debian", "VERSION_ID": release}
), patch.object(checker, "command", side_effect=[
f"install ok installed\n{version}", "/usr/lib/example/libbrotlidec.so.1"
]):
if accepted:
self.assertEqual(checker.check_package()[0], version)
else:
with self.assertRaisesRegex(ValueError, "fixed floor"):
checker.check_package()
def test_unknown_distro_and_release_fail_closed(self):
for release in ({"ID": "ubuntu", "VERSION_ID": "13"},
{"ID": "debian", "VERSION_ID": "14"}, {}):
with self.subTest(release=release), patch.object(
checker.platform, "freedesktop_os_release", return_value=release
), self.assertRaisesRegex(ValueError, "not defined"):
checker.check_package()
def test_incomplete_or_missing_package_records_are_rejected(self):
for record in ("deinstall ok config-files\n1.1.0-2+deb13u1", "", "install ok installed"):
with self.subTest(record=record), patch.object(
checker.platform, "freedesktop_os_release", return_value={"ID": "debian", "VERSION_ID": "13"}
), patch.object(checker, "command", return_value=record), self.assertRaises(ValueError):
checker.check_package()
@unittest.skipUnless(shutil.which("sh") and shutil.which("dpkg"), "diagnostics require sh and dpkg")
class DiagnosticTests(unittest.TestCase):
def run_checker(self, body="raise SystemExit(checker.main([]))", **overrides):
# Keep the real subprocess/CLI diagnostic boundary; only external command
# responses and distro identity are controlled by this offline fixture.
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
query = directory / "dpkg-query"
query.write_text('''#!/bin/sh
case "$1" in
-W) printf '%s' "$BROTLI_TEST_RECORD"
printf '%s' "$BROTLI_TEST_QUERY_STDERR" >&2
exit "$BROTLI_TEST_QUERY_EXIT" ;;
-L) printf '%s' '/usr/lib/example/libbrotlidec.so.1'
printf '%s' "$BROTLI_TEST_LIST_STDERR" >&2
exit "$BROTLI_TEST_LIST_EXIT" ;;
esac
exit 2
''')
query.chmod(0o755)
ldd = directory / "ldd"
ldd.write_text('#!/bin/sh\nprintf "%s" "ldd: cannot inspect executable" >&2\nexit 1\n')
ldd.chmod(0o755)
env = dict(os.environ, PATH=tmp + os.pathsep + os.environ.get("PATH", os.defpath),
BROTLI_TEST_RECORD="install ok installed\n1.1.0-2+deb13u1",
BROTLI_TEST_QUERY_STDERR="", BROTLI_TEST_QUERY_EXIT="0",
BROTLI_TEST_LIST_STDERR="", BROTLI_TEST_LIST_EXIT="0")
env.update(overrides)
program = (
"from pathlib import Path\n"
"import subprocess\n"
"from tests.helpers.cli_loader import load_script\n"
"checker = load_script('check_brotli.py')\n"
"checker.platform.freedesktop_os_release = lambda: {'ID': 'debian', 'VERSION_ID': '13'}\n"
+ body
)
return subprocess.run([sys.executable, "-c", program], cwd=ROOT, env=env,
capture_output=True, text=True)
def test_failed_package_queries_preserve_stderr(self):
for overrides, diagnostic in [
({"BROTLI_TEST_QUERY_EXIT": "1", "BROTLI_TEST_QUERY_STDERR":
"dpkg-query: no packages found matching libbrotli1"}, "no packages found"),
({"BROTLI_TEST_LIST_EXIT": "2", "BROTLI_TEST_LIST_STDERR":
"dpkg-query: cannot read package file list"}, "cannot read package file list"),
]:
with self.subTest(overrides=overrides):
result = self.run_checker(**overrides)
self.assertEqual(result.returncode, 1, result.stderr)
self.assertIn(diagnostic, result.stderr)
self.assertIn("Brotli acceptance failed:", result.stderr)
def test_ldd_failure_preserves_stderr(self):
result = self.run_checker(body=(
"try:\n"
" checker.check_node(Path('/usr/bin/node'), Path('/usr/lib/example/libbrotlidec.so.1'))\n"
"except subprocess.CalledProcessError:\n"
" raise SystemExit(1)\n"
))
self.assertEqual(result.returncode, 1)
self.assertIn("ldd: cannot inspect executable", result.stderr)
def test_package_query_warning_does_not_corrupt_record(self):
result = self.run_checker(body="print(checker.check_package()[0])",
BROTLI_TEST_QUERY_STDERR="dpkg-query: warning: package metadata warning\n")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "1.1.0-2+deb13u1")
self.assertIn("package metadata warning", result.stderr)
def test_invalid_package_status_has_its_own_diagnostic(self):
for status in ("deinstall ok config-files", "install ok unpacked", "install reinstreq half-installed"):
with self.subTest(status=status):
result = self.run_checker(BROTLI_TEST_RECORD=status + "\n1.1.0-2+deb13u1")
self.assertEqual(result.returncode, 1, result.stderr)
self.assertIn("libbrotli1 is not fully installed", result.stderr)
self.assertIn(status, result.stderr)
self.assertNotIn("fixed floor", result.stderr)
def test_malformed_package_records_have_actionable_diagnostic(self):
for record in ("", "install ok installed", "install ok installed\n", "\n1.1.0-2+deb13u1",
"install ok installed\n1.1.0-2+deb13u1\nextra"):
with self.subTest(record=record):
result = self.run_checker(BROTLI_TEST_RECORD=record)
self.assertEqual(result.returncode, 1, result.stderr)
self.assertIn("invalid libbrotli1 package record: expected status and version", result.stderr)
self.assertNotIn("unpack", result.stderr)
self.assertNotIn("fixed floor", result.stderr)
def decoder(marker=b"_ERROR_FORMAT_BLOCK_SWITCH", result=1, decoded=b"hello world"):
def decompress(encoded_size, encoded, size, output):
# Check the valid fixture as well as the caller's output handling.
assert encoded_size == len(encoded) == 15
ctypes.memmove(output, decoded, len(decoded))
ctypes.cast(size, ctypes.POINTER(ctypes.c_size_t))[0] = len(decoded)
return result
return SimpleNamespace(BrotliDecoderErrorString=lambda code: marker,
BrotliDecoderDecompress=decompress)
class DecoderTests(unittest.TestCase):
def test_fixed_debian_12_plain_marker_is_accepted(self):
checker.check_decoder(decoder(marker=b"BLOCK_SWITCH"))
def test_fixed_native_decoder_is_accepted_without_upstream_version_metadata(self):
# A Debian 1.1.0 backport has no new upstream version requirement.
checker.check_decoder(decoder())
def test_unpatched_native_error_table_is_rejected(self):
for marker in (b"INVALID", b"_ERROR_FORMAT_DISTANCE", b"BLOCK_SWITCH_EXTRA", None):
with self.subTest(marker=marker), self.assertRaisesRegex(ValueError, "BLOCK_SWITCH"):
checker.check_decoder(decoder(marker=marker))
def test_fix_marker_does_not_replace_valid_decoding_control(self):
for marker in (b"BLOCK_SWITCH", b"_ERROR_FORMAT_BLOCK_SWITCH"):
for native in (decoder(marker=marker, result=0), decoder(marker=marker, decoded=b"wrong bytes"),
decoder(marker=marker, decoded=b"hello")):
with self.subTest(marker=marker, native=native), self.assertRaisesRegex(ValueError, "valid-stream"):
checker.check_decoder(native)
def test_node_must_link_the_same_decoder(self):
native = Path("/usr/lib/example/libbrotlidec.so.1")
for linked, accepted in [
(f"libbrotlidec.so.1 => {native} (0x1234)\n", True),
("libbrotlidec.so.1 => /usr/local/lib/libbrotlidec.so.1 (0x1234)", False),
("libbrotlidec.so.1 => not found", False),
("statically linked", False),
]:
with self.subTest(linked=linked), patch.object(checker, "command", return_value=linked):
if accepted:
checker.check_node(Path("/usr/bin/node"), native)
else:
with self.assertRaisesRegex(ValueError, "does not select"):
checker.check_node(Path("/usr/bin/node"), native)
def test_a_shadow_decoder_is_rejected_even_with_a_fixed_package_installed(self):
with patch.object(checker, "check_package", return_value=("1.1.0-2+deb13u1", {Path("/usr/lib/decoder")})), \
patch.object(checker.ctypes, "CDLL"), \
patch.object(checker, "loaded_path", return_value=Path("/usr/local/lib/decoder")), \
contextlib.redirect_stderr(io.StringIO()) as stderr:
self.assertEqual(checker.main([]), 1)
self.assertIn("outside libbrotli1", stderr.getvalue())
class ImageRecipeTests(unittest.TestCase):
@unittest.skipUnless(shutil.which("sh"), "Docker RUN guard requires a POSIX shell")
def test_base_ca_guard_failure_stops_before_package_network_access(self):
base = (ROOT / "Dockerfile").read_text(encoding="utf-8").split("# ---- builder:", 1)[0]
# Execute the actual first RUN with command stubs. A failed CA check must
# stop the shell before either changing sources or invoking APT.
command = base.split("\nRUN ", 1)[1].split("\n\n", 1)[0].replace("\\\n", "")
self.assertLess(base.index("COPY scripts/check_ca_trust.py"), base.index("\nRUN "))
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
trace = directory / "commands"
for name in ("python", "sed", "apt-get"):
stub = directory / name
stub.write_text('#!/bin/sh\nprintf "%s\\n" "$0 $*" >> "$TRACE"\n' +
("exit 1\n" if name == "python" else "exit 0\n"))
stub.chmod(0o755)
result = subprocess.run([shutil.which("sh"), "-c", command],
env={"PATH": tmp, "TRACE": str(trace)}, capture_output=True, text=True)
self.assertEqual(result.returncode, 1, result.stderr)
self.assertEqual(trace.read_text().splitlines(),
[f"{directory}/python /usr/local/bin/check-ca-trust.py --system"])
def test_builder_and_runtime_inherit_the_guarded_package_layer(self):
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
# This declarative inheritance is not exercised by Python unit tests.
_, base, builder, runtime = dockerfile.split("\nFROM ")
self.assertIn("AS brotli-base", base)
self.assertIn("apt-get install -y --no-install-recommends libbrotli1", base)
self.assertIn("python /usr/local/bin/check-brotli.py", base)
self.assertTrue(builder.startswith("brotli-base AS realesrgan-wheels\n"))
self.assertTrue(runtime.startswith("brotli-base\n"))
# Recheck Node's effective decoder after system and Python dependencies.
gates = runtime.split("python /usr/local/bin/check-brotli.py --node /usr/bin/node")
self.assertEqual(len(gates), 3)
self.assertIn("nodejs", gates[0])
self.assertIn("pip install", gates[1])
if __name__ == "__main__":
unittest.main()
+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()
+12 -1
View File
@@ -77,8 +77,19 @@ def test_dav_client_does_not_follow_redirect_to_internal_host():
pass
# The request must actually have left the building — otherwise an early
# error would make "sink not hit" pass vacuously.
assert public_methods == ["PROPFIND"], "the PROPFIND must reach the public server first"
# Some caldav releases retry the original URL with GET to probe auth
# after the redirect exception. Neither request may reach the sink.
assert public_methods in (["PROPFIND"], ["PROPFIND", "GET"]), public_methods
assert sink_hits == [], "redirect toward an internal host must not be followed"
# Positive control: demonstrate that the sink would see the redirect
# if the session's redirect protection were removed.
client.session.max_redirects = 30
try:
client.request(public_url, "PROPFIND", "")
except Exception:
pass # The sink's response is intentionally not a valid DAV body.
assert sink_hits == ["/leak"], "the test must detect a followed redirect"
finally:
internal.shutdown()
public.shutdown()
+119
View File
@@ -0,0 +1,119 @@
"""The chat stream must never pass another owner's document to the model."""
import json
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from core.auth import AuthManager
from routes.auth_routes import SESSION_COOKIE
from tests.helpers.database import isolated_session_database
@pytest.fixture
def document_chat(tmp_path, monkeypatch):
from routes import chat_routes, chat_helpers, session_routes
from src.agent_tools import document_tools
from src.auth_helpers import effective_user
monkeypatch.setenv("AUTH_ENABLED", "true")
auth = AuthManager(str(tmp_path / "auth.json"))
for owner in ("alice", "bob"):
assert auth.create_user(owner, "disposable-test-password")
captured = []
async def context(sess, request, *args, **kwargs):
# Preprocessing and external model I/O are outside the owner lookup.
# The actual route, session-owner gate and document ORM queries run.
return chat_helpers.ChatContext(
preface=[], rag_sources=[], web_sources=[], used_memories=[],
messages=[{"role": "user", "content": "Summarize my writing"}],
context_length=4096, was_compacted=False, user=effective_user(request), uprefs={},
preset=chat_helpers.PresetInfo(0.2, 128, None, None),
preprocessed=chat_helpers.PreprocessedMessage("", "", "", [], []),
)
async def model(endpoint, model, messages, **kwargs):
doc = kwargs.get("active_document")
captured.append(None if doc is None else (doc.id, doc.owner, doc.current_content))
text = doc.current_content if doc is not None else "no document context"
yield f'data: {json.dumps({"delta": text})}\n\n'
yield 'data: [DONE]\n\n'
monkeypatch.setattr(chat_routes, "build_chat_context", context)
monkeypatch.setattr(chat_routes, "stream_agent_loop", model)
monkeypatch.setattr(chat_routes, "save_assistant_response", lambda *a, **kw: None)
monkeypatch.setattr(chat_routes, "run_post_response_tasks", lambda *a, **kw: None)
monkeypatch.setattr(document_tools, "_active_document_id", None)
with isolated_session_database(tmp_path) as (manager, database):
factory = database.SessionLocal
for module in (chat_routes, chat_helpers, session_routes):
monkeypatch.setattr(module, "SessionLocal", factory)
# Existing route tests may re-import session_routes after chat imported
# its owner check. Bind that real function's database too, without
# replacing the authorization logic or reloading production modules.
monkeypatch.setitem(chat_routes._verify_session_owner.__globals__, "SessionLocal", factory)
with factory() as db:
db.add_all([
database.Session(id="alice-chat", name="Alice", owner="alice",
endpoint_url="http://model.invalid/v1", model="fixture-model"),
database.Session(id="bob-chat", name="Bob", owner="bob",
endpoint_url="http://model.invalid/v1", model="fixture-model"),
database.ModelEndpoint(id="fixture-endpoint", name="Fixture",
base_url="http://model.invalid/v1", owner="alice"),
])
db.commit()
app = FastAPI()
app.state.auth_manager = auth
@app.middleware("http")
async def fixture_session(request, call_next):
request.state.current_user = auth.get_username_for_token(request.cookies.get(SESSION_COOKIE))
return await call_next(request)
app.include_router(chat_routes.setup_chat_routes(
manager, SimpleNamespace(), SimpleNamespace(), SimpleNamespace(),
SimpleNamespace(), SimpleNamespace(),
))
token = auth.create_session("alice", "disposable-test-password")
assert token
with TestClient(app) as client:
client.cookies.set(SESSION_COOKIE, token)
yield SimpleNamespace(client=client, factory=factory, database=database,
captured=captured, document_tools=document_tools)
@pytest.mark.parametrize("source", ["explicit", "session", "email-session", "memory"])
@pytest.mark.parametrize("document_owner", ["alice", "bob"])
def test_chat_document_context_is_owner_scoped(document_chat, source, document_owner):
case = document_chat
form = {"message": "Summarize my writing", "session": "alice-chat", "mode": "agent",
"compare_mode": "true", "no_memory": "true", "use_rag": "false"}
with case.factory() as db:
for owner in ("alice", "bob"):
# A stale session link is not evidence that the caller owns a doc.
linked = "alice-chat" if source in {"session", "email-session"} and owner == document_owner else None
db.add(case.database.Document(id=f"{owner}-doc", owner=owner, session_id=linked,
title=f"{owner} draft",
language="email" if source == "email-session" else "markdown",
current_content=f"{owner}-private-context", is_active=True))
db.commit()
if source == "explicit":
form.update(active_doc_id=f"{document_owner}-doc", active_doc_state="visible")
elif source == "memory":
case.document_tools.set_active_document(f"{document_owner}-doc")
response = case.client.post("/api/chat_stream", data=form)
assert response.status_code == 200, response.text
if document_owner == "alice":
assert case.captured == [("alice-doc", "alice", "alice-private-context")]
assert "alice-private-context" in response.text
else:
assert case.captured == [None]
assert "bob-private-context" not in response.text
with case.factory() as db:
assert db.get(case.database.Document, "bob-doc").owner == "bob"
assert db.get(case.database.Document, "bob-doc").session_id == (
"alice-chat" if source in {"session", "email-session"} and document_owner == "bob" else None
)
+103 -18
View File
@@ -480,9 +480,12 @@ def test_spinoff_detected_from_chatmessage_history():
assert _session_is_research_spinoff(sess) is True
def test_auto_name_session_passes_session_fallback_to_task_resolver(monkeypatch):
def test_auto_name_session_passes_session_fallback_to_task_resolver(monkeypatch, tmp_path):
import src.database as database
import src.endpoint_resolver as resolver
import src.llm_core as llm_core
import src.task_endpoint as task_endpoint
from tests.helpers.database import disposable_database
resolver_calls = []
llm_calls = []
@@ -500,24 +503,33 @@ def test_auto_name_session_passes_session_fallback_to_task_resolver(monkeypatch)
llm_calls.append((url, model, messages, kwargs))
return "Focused Fix"
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", fake_resolve_task_endpoint)
monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call)
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(database, "SessionLocal", factory)
monkeypatch.setattr(resolver, "resolve_endpoint_runtime", lambda ep, **kwargs: (ep.base_url, ep.api_key))
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", fake_resolve_task_endpoint)
monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call)
with factory() as db:
db.add(database.ModelEndpoint(
id="ep-1", name="Session EP", owner="alice",
base_url="http://session.example/v1", api_key="registered-key", is_enabled=True,
))
db.commit()
session_headers = {"Authorization": "Bearer session"}
sess = SimpleNamespace(
id="session-1",
owner="alice",
endpoint_url="http://session.example/v1/chat/completions",
model="session-model",
headers=session_headers,
history=[SimpleNamespace(role="user", content="Please fix the endpoint fallback bug.")],
)
updates = []
session_manager = SimpleNamespace(
update_session_name=lambda session_id, title: updates.append((session_id, title))
)
session_headers = {"Authorization": "Bearer session"}
sess = SimpleNamespace(
id="session-1",
owner="alice",
endpoint_url="http://session.example/v1/chat/completions",
model="session-model",
headers=session_headers,
history=[SimpleNamespace(role="user", content="Please fix the endpoint fallback bug.")],
)
updates = []
session_manager = SimpleNamespace(
update_session_name=lambda session_id, title: updates.append((session_id, title))
)
asyncio.run(auto_name_session(session_manager, sess))
asyncio.run(auto_name_session(session_manager, sess))
assert resolver_calls == [(
"http://session.example/v1/chat/completions",
@@ -527,10 +539,83 @@ def test_auto_name_session_passes_session_fallback_to_task_resolver(monkeypatch)
)]
assert llm_calls[0][0] == "http://session.example/v1/chat/completions"
assert llm_calls[0][1] == "session-model"
assert llm_calls[0][3]["headers"] == session_headers
assert llm_calls[0][3]["headers"] == {"Authorization": "Bearer registered-key"}
assert updates == [("session-1", "Focused Fix")]
@pytest.mark.parametrize("owner,enabled,expected", [
("alice", True, True), ("bob", True, False), ("alice", False, False), ("alice", None, False),
])
def test_ajax_auto_title_uses_current_owner_endpoint_authority(monkeypatch, tmp_path, owner, enabled, expected):
import src.database as database
import src.endpoint_resolver as resolver
import src.llm_core as llm_core
from tests.helpers.database import disposable_database
calls, updates = [], []
async def fake_call(url, model, messages, **kwargs):
calls.append((url, kwargs.get("headers")))
return "A useful title"
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(database, "SessionLocal", factory)
monkeypatch.setattr(resolver, "resolve_endpoint_runtime", lambda ep, **kwargs: (ep.base_url, ep.api_key))
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
if enabled is not None:
with factory() as db:
db.add(database.ModelEndpoint(id="ajax", name="Ajax", owner="alice",
base_url="https://ajax.example/v1", api_key="current", is_enabled=enabled))
db.commit()
sess = SimpleNamespace(id="s", owner=owner, model="ajax_c375",
endpoint_url="https://ajax.example/v1/chat/completions",
headers={"Authorization": "Bearer stale"},
history=[SimpleNamespace(role="user", content="Private title request")])
manager = SimpleNamespace(update_session_name=lambda sid, title: updates.append(title))
asyncio.run(auto_name_session(manager, sess))
assert bool(calls) is expected
assert updates == (["A useful title"] if expected else ["Private title request"])
if expected:
assert calls == [("https://ajax.example/v1/chat/completions", {"Authorization": "Bearer current"})]
@pytest.mark.parametrize("owner, enabled, expected", [
("alice", True, True), ("bob", True, False), ("alice", False, False), ("alice", None, False),
(None, True, False),
])
def test_non_ajax_auto_title_uses_current_owner_endpoint_authority(monkeypatch, tmp_path, owner, enabled, expected):
import src.database as database
import src.endpoint_resolver as resolver
import src.llm_core as llm_core
from tests.helpers.database import disposable_database
calls, updates = [], []
async def fake_call(url, model, messages, **kwargs):
calls.append((url, kwargs.get("headers")))
return "Non-spec title"
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(database, "SessionLocal", factory)
monkeypatch.setattr(resolver, "resolve_endpoint_runtime", lambda ep, **kwargs: (ep.base_url, ep.api_key))
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
if enabled is not None:
with factory() as db:
db.add(database.ModelEndpoint(id="claude", name="Claude", owner="alice",
base_url="https://claude.example/v1", api_key="current", is_enabled=enabled))
db.commit()
sess = SimpleNamespace(id="s2", owner=owner, model="claude-3-5-sonnet",
endpoint_url="https://claude.example/v1/chat/completions",
headers={"Authorization": "Bearer stale"},
history=[SimpleNamespace(role="user", content="Private title request")])
manager = SimpleNamespace(update_session_name=lambda sid, title: updates.append(title))
asyncio.run(auto_name_session(manager, sess))
assert bool(calls) is expected
assert updates == (["Non-spec title"] if expected else ["Private title request"])
if expected:
assert calls == [("https://claude.example/v1/chat/completions", {"Authorization": "Bearer current"})]
def test_spinoff_detected_from_dict_history():
sess = SimpleNamespace(history=[
{"role": "system", "metadata": {"research_spinoff_from": "rp-2"}},
+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")
+270
View File
@@ -3511,6 +3511,10 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
'web_fetch': ({'url': 'https://example.com'}, 'read this page'),
'web_search': ({'query': 'current AI news'}, 'search the web'),
'youtube_tool': ({'action': 'metadata', 'video_url': 'https://youtube.com/watch?v=x'}, 'read YouTube metadata'),
'plan_edit': ({'prompt': 'make it black and white'}, 'make it black and white'),
'run_editor_command': ({'op': 'document.info'}, 'inspect the document'),
'get_editor_screenshot': ({}, 'take a screenshot of the editor'),
'ai_edit_image': ({'op': 'remove-bg'}, 'remove the background'),
}
schemas = {
schema['function']['name']: schema
@@ -3537,6 +3541,9 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
}
else {'image_editing'} if name == 'edit_image'
else {'image_generation'} if name == 'generate_image'
else {'photo_editor'} if name in {
'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image',
}
else frozenset()
),
contract_required_tools={name},
@@ -3544,6 +3551,56 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
assert decision.allowed, f'{name}: {decision.reason}'
def test_photo_editor_tools_require_explicit_turn_family_authorization():
# Positive: permitted photo editor workflow with turn authorization
for tool, args, prompt in (
('run_editor_command', {'op': 'document.info'}, 'inspect the document'),
('run_editor_command', {'op': 'adjustments.apply', 'params': {'type': 'brightness-contrast'}}, 'make it brighter'),
('ai_edit_image', {'op': 'remove-bg'}, 'remove background'),
):
allowed = evaluate_preview_call(
tool, args, prompt,
turn_authorized_families={'photo_editor'},
)
assert allowed.allowed, f"Expected {tool} to be allowed with photo_editor turn authority: {allowed.reason}"
# Negative: unauthorized preview (unauthorized family, empty families, or cross-family) fails closed
for unauth_families in (frozenset(), {'notes'}, {'email'}, {'calendar'}, {'search_browser'}):
denied_info = evaluate_preview_call(
'run_editor_command', {'op': 'document.info'}, 'inspect the document',
turn_authorized_families=unauth_families,
)
assert not denied_info.allowed
assert denied_info.reason == 'write_family_not_authorized'
denied_edit = evaluate_preview_call(
'run_editor_command', {'op': 'adjustments.apply', 'params': {'type': 'brightness-contrast'}},
'make it brighter',
turn_authorized_families=unauth_families,
)
assert not denied_edit.allowed
assert denied_edit.reason == 'write_family_not_authorized'
denied_ai = evaluate_preview_call(
'ai_edit_image', {'op': 'remove-bg'}, 'remove background',
turn_authorized_families=unauth_families,
)
assert not denied_ai.allowed
assert denied_ai.reason == 'write_family_not_authorized'
for prompt in ('inspect the document', "don't edit the image"):
denied = evaluate_preview_call(
'run_editor_command', {'op': 'adjustments.apply', 'params': {'type': 'brightness-contrast'}},
prompt, turn_authorized_families={'photo_editor'},
)
assert not denied.allowed
assert denied.reason == 'write_family_not_authorized'
denied = evaluate_preview_call('manage_notes', {'action': 'add', 'content': 'x'},
'make it brighter', turn_authorized_families={'photo_editor'})
assert not denied.allowed
def test_full_compact_inventory_contains_pipeline_for_exact_session_contract():
from src.turn_contract import resolve_full_inventory_contract
@@ -7284,3 +7341,216 @@ async def test_tool_round_leadin_is_replaced_by_terminal_synthesis(monkeypatch):
"I'll browse IKEA and look at their chairs.",
'The most epic option is the DYVLINGE swivel chair.',
]
def test_spec_router_think_decision_and_reason(monkeypatch):
import sys
import types
from src.clean_agent_preview import (
spec_router_thinking_decision,
spec_router_think_reason,
)
# --- router disabled -> no decision, no reason (default OFF) -------------
fake = types.ModuleType('src.spec_router')
fake.think_router_enabled = lambda: False
fake.route_thinking = lambda text: {'think': True, 'p_think': 0.9, 'ms': 1.0}
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
assert spec_router_think_reason(1, None, '') == ''
# --- enabled but not a spec surface -> no decision ----------------------
fake.think_router_enabled = lambda: True
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'native', raising=False,
)
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
# --- enabled on spec + think=True -> reason only on round 1 when no other
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'spec', raising=False,
)
decision = spec_router_thinking_decision('u', 'm', 'o', {}, 'plan the refactor')
assert decision == {'think': True, 'p_think': 0.9, 'ms': 1.0}
assert spec_router_think_reason(1, decision, '') == 'router_think'
# existing rule wins; only round 1; think=False never fires
assert spec_router_think_reason(1, decision, 'code') == ''
assert spec_router_think_reason(2, decision, '') == ''
assert spec_router_think_reason(1, {'think': False}, '') == ''
# --- route_thinking raises -> fail open (None), rules only --------------
def _boom(text):
raise RuntimeError('router down')
fake.route_thinking = _boom
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
assert spec_router_think_reason(1, None, '') == ''
# --- wiring present in stream_preview -----------------------------------
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
assert 'spec_router_thinking_decision(' in source
assert 'spec_router_think_reason(' in source
def test_spec_direct_no_tools_decision(monkeypatch):
import sys
import types
from src.clean_agent_preview import spec_direct_no_tools_p
fake = types.ModuleType('src.spec_router')
fake.LAST_DECISIONS = [
{'message': 'Yo', 'choice': 'answer_directly', 'answer_directly': 0.971},
]
fake.latest_decision = lambda: fake.LAST_DECISIONS[-1]
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'spec', raising=False,
)
# --- default OFF -> no override, identical behaviour --------------------
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', raising=False)
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
# --- ON + answer_directly p >= 0.9 -> returns the confidence ------------
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_MIN_P', raising=False)
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) == 0.971
# --- low confidence -> no override --------------------------------------
fake.LAST_DECISIONS = [
{'message': 'Yo', 'choice': 'answer_directly', 'answer_directly': 0.5},
]
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
# --- MIN_P override lowers the bar --------------------------------------
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_MIN_P', '0.4')
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) == 0.5
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_MIN_P', raising=False)
# --- a non answer_directly choice -> no override ------------------------
fake.LAST_DECISIONS = [
{'message': 'Yo', 'choice': 'web_search', 'answer_directly': 0.971},
]
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
# --- stale decision for a different message -> no override --------------
fake.LAST_DECISIONS = [
{'message': 'something else entirely', 'choice': 'answer_directly',
'answer_directly': 0.99},
]
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
# --- non-spec surface -> no override ------------------------------------
fake.LAST_DECISIONS = [
{'message': 'Yo', 'choice': 'answer_directly', 'answer_directly': 0.971},
]
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'native', raising=False,
)
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
# --- router error -> fail open (None) -----------------------------------
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'spec', raising=False,
)
class _Boom:
def latest_decision(self):
raise RuntimeError('router down')
monkeypatch.setitem(sys.modules, 'src.spec_router', _Boom())
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
# --- wiring present in stream_preview -----------------------------------
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
assert 'spec_direct_no_tools_p(' in source
assert '[spec-harness] direct answer' in source
def test_action_promise_ignores_fenced_code_and_knows_browse_verbs():
import src.clean_agent_preview as module
gym_reply = (
'I will browse Google Maps to find the closest gym near Todoroki.\n\n'
'```python\n'
'import requests\n'
'from bs4 import BeautifulSoup\n'
'resp = requests.get("https://maps.google.com/search?q=gym+todoroki")\n'
'soup = BeautifulSoup(resp.text, "html.parser")\n'
'for row in soup.select(".section-result"):\n'
' print(row.get_text())\n'
'```\n'
)
assert module.action_promise_response(gym_reply)
assert module.action_promise_response('I will fetch the page and scrape the listing.')
assert module.action_promise_response('Let me search for the nearest gym now.')
assert module.action_promise_response("I'll go check Google Maps for you.")
# A real answer that includes a code block is not an action promise.
assert not module.action_promise_response(
'The closest gym to Todoroki is Gold\u2019s Gym, about 600 m away.\n\n'
'```python\n'
'print("done")\n'
'```\n'
)
assert not module.action_promise_response('Here is the script you asked for:\n```\nprint(1)\n```')
@pytest.mark.parametrize('prompt', [
'please explain how a student should break down work about customer feedback; this is general advice only, do not create or schedule anything.',
'Give general advice about customer feedback and task planning; do not create anything.',
'Explain feedback tasks without scheduling anything.',
])
def test_feedback_advice_does_not_claim_write_authority(prompt):
assert not requests_mutation(prompt)
def test_feedback_subject_does_not_hide_real_write_request():
assert requests_mutation('Schedule a customer feedback task for tomorrow.')
assert requests_mutation('Do not create anything; schedule a customer feedback task tomorrow.')
@pytest.mark.asyncio
async def test_direct_feedback_advice_is_not_replaced_with_preview_denial(monkeypatch):
import src.clean_agent_preview as module
answer = 'Set a goal, group the customer comments by theme, and summarize what you learned.'
class Response:
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps({'choices': [{'delta': {'content': answer}}]})
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
assert 'tools' not in kwargs['json']
return Response()
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
contract = resolve_full_inventory_contract(schemas=[], policy=ToolPolicy())
prompt = ('please explain how a student should break down work about customer feedback; '
'this is general advice only, do not create or schedule anything.')
chunks = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='ajax-ablated500-base',
messages=[{'role': 'user', 'content': prompt}], headers={},
turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(),
)]
events = [json.loads(chunk[6:]) for chunk in chunks if '[DONE]' not in chunk]
assert ''.join(event.get('delta', '') for event in events) == answer
assert not any(event.get('type') == 'final_response' and event.get('content') == denied_response()
for event in events)
+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"
+33 -3
View File
@@ -207,12 +207,42 @@ async def test_default_namespace_preserves_loopback_sidecars(namespaces):
assert "network" not in result["containment"]["enforced"]
def _assert_sandboxed_shell_output(result, expected):
# Some distributions' login profiles invoke systemd-cat via im-config.
# The sandbox intentionally has no host journal socket; retain its exact
# diagnostic in the result and reject every other stderr message.
assert result["exit_code"] == 0, result
assert result["containment"]["mechanism"] == "bubblewrap", result
assert result["containment"]["contained"] is True, result
assert result["containment"]["executed"] is True, result
assert result["teardown"]["dead"] is True, result
stdout, separator, stderr = result["output"].partition("\nSTDERR: ")
assert stdout == expected, result
if separator:
assert stderr and all(
line == "Failed to create stream fd: No such file or directory"
for line in stderr.splitlines()
), result
def test_shell_output_check_rejects_unexpected_diagnostics_and_failed_launch():
result = {
"exit_code": 0, "containment": {"mechanism": "bubblewrap", "contained": True, "executed": True},
"teardown": {"dead": True}, "output": "closed\nSTDERR: unexpected error",
}
with pytest.raises(AssertionError):
_assert_sandboxed_shell_output(result, "closed")
result["output"] = "closed"
result["containment"]["executed"] = False
with pytest.raises(AssertionError):
_assert_sandboxed_shell_output(result, "closed")
async def test_namespace_handshake_closes_model_stdin(namespaces):
result = await subprocess_tools.BashTool().execute(
"if read value; then echo unexpected; else echo closed; fi", {},
)
assert result["output"] == "closed", result
assert result["teardown"]["dead"] is True
_assert_sandboxed_shell_output(result, "closed")
@pytest.mark.parametrize("legacy_name", [True, False])
@@ -240,7 +270,7 @@ async def test_execution_environment_cannot_replace_probed_bwrap(namespaces, mon
assert "containment unavailable" in result["error"]
assert outside.read_text() == "escaped"
else:
assert result["output"] == "contained", result
_assert_sandboxed_shell_output(result, "contained")
assert not outside.exists()
+17
View File
@@ -499,3 +499,20 @@ class TestResearchPrimerPreserved:
trimmed = trim_for_context(msgs, context_length=1024, reserve_tokens=256)
joined = "\n".join(str(m.get("content", "")) for m in trimmed)
assert "You are Odysseus." in joined
def test_unterminated_image_label_with_long_whitespace_finishes_promptly():
"""Untrusted captions must not make image-history compaction quadratic."""
import subprocess
import sys
script = '''
from src.context_compactor import keep_recent_image_turns
messages = [
{'role': 'user', 'content': [{'type': 'text', 'text': '[Image:' + ' ' * 100000},
{'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,AA=='}}]},
{'role': 'user', 'content': [{'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,AA=='}}]},
]
assert '[Image]' in keep_recent_image_turns(messages, keep=1)[0]['content'][0]['text']
'''
subprocess.run([sys.executable, '-c', script], check=True, timeout=8)
@@ -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
+68
View File
@@ -0,0 +1,68 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
def test_actions_panel_module_shape():
src = read("static/js/editor/actions-panel.js")
for token in [
"export const ACTION_COMMANDS",
"export function createActionsPanel(",
"ge-panel-actions",
"localStorage",
"'ge-actions'",
]:
assert token in src
def test_actions_panel_supports_batch():
src = read("static/js/editor/actions-panel.js")
for token in [
"ge-actions-batch-row",
"ge-actions-batch-select",
"runOnAllLayers",
"getLayers",
"selectLayer",
]:
assert token in src
def test_gallery_wires_actions_batch():
src = read("static/js/galleryEditor.js")
for token in ["getLayers: () =>", "selectLayer: (id) =>"]:
assert token in src
def test_gallery_wires_actions_panel():
src = read("static/js/galleryEditor.js")
for token in [
"from './editor/actions-panel.js'",
"createActionsPanel(",
"element: actionsPanel.element",
]:
assert token in src
def test_window_menu_exposes_actions():
src = read("static/js/editor/build/topbar.js")
assert 'data-window-action="actions"' in src
def test_wire_extra_menus_dispatches_actions():
src = read("static/js/editor/wire-extra-menus.js")
assert "'actions'" in src
def test_actions_panel_css_present():
src = read("static/css/13-image-editor.css")
for token in [".ge-actions-step", ".ge-actions-name", ".ge-action-remove", ".ge-actions-batch-row"]:
assert token in src
def test_actions_panel_is_precached():
src = read("static/sw.js")
assert "/static/js/editor/actions-panel.js" in src
@@ -0,0 +1,72 @@
import json
import os
from pathlib import Path
import subprocess
ROOT = Path(__file__).resolve().parents[1]
def test_action_names_and_unknown_steps_are_literal_text():
script = r'''
const fs = require('fs');
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.route('http://actions.test/**', route => route.fulfill({
status: 200, contentType: 'text/html', body: '<html><body></body></html>',
}));
await page.goto('http://actions.test/');
const source = fs.readFileSync(process.argv[1], 'utf8');
const moduleUrl = 'data:text/javascript;base64,' + Buffer.from(source).toString('base64');
const result = await page.evaluate(async url => {
const attack = '<img src=x onerror="window.__actionsXss=true">';
localStorage.setItem('ge-actions', JSON.stringify({ [attack]: ['flip-h'] }));
const { createActionsPanel } = await import(url);
const panel = createActionsPanel();
document.body.append(panel.element);
panel.setSteps([attack]);
const input = panel.element.querySelector('.ge-actions-name');
input.value = attack + '<svg onload="window.__actionsXss=true">';
input.parentElement.querySelector('button').click();
const savedName = input.value;
const savedButton = Array.from(panel.element.querySelectorAll('.ge-actions-saved-row button'))
.find(button => button.textContent === savedName);
savedButton.click();
return {
images: panel.element.querySelectorAll('img').length,
svgs: panel.element.querySelectorAll('svg').length,
saved: panel.element.querySelector('.ge-actions-saved-row button').textContent,
step: panel.element.querySelector('.ge-actions-step-label').textContent,
option: Array.from(panel.element.querySelector('.ge-actions-batch-select').options)
.find(option => option.value === savedName)?.textContent,
persisted: JSON.parse(localStorage.getItem('ge-actions'))[savedName],
savedName,
executed: Boolean(window.__actionsXss),
attack,
};
}, moduleUrl);
console.log(JSON.stringify(result));
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exit(1); });
'''
env = os.environ.copy()
if 'PLAYWRIGHT_BROWSERS_PATH' not in env:
for candidate in [Path.home() / '.cache/ms-playwright', Path('/home/alteixeira20/.cache/ms-playwright')]:
if candidate.exists():
env['PLAYWRIGHT_BROWSERS_PATH'] = str(candidate)
break
result = subprocess.run(
['node', '-e', script, str(ROOT / 'static/js/editor/actions-panel.js')],
cwd=ROOT, capture_output=True, text=True, timeout=30, env=env,
)
assert result.returncode == 0, result.stderr
observed = json.loads(result.stdout)
assert observed['images'] == 0
assert observed['svgs'] == 0
assert observed['saved'] == observed['step'] == observed['attack']
assert observed['option'] == observed['savedName']
assert observed['persisted'] == [observed['attack']]
assert not observed['executed']
+45
View File
@@ -0,0 +1,45 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
CONTROLS = (ROOT / "static/js/editor/build/controls.js").read_text(encoding="utf-8")
AI_MISC = (ROOT / "static/js/editor/ai-tools-misc.js").read_text(encoding="utf-8")
STYLE = app_css()
def test_layer_footer_has_separate_add_layer_and_fx_buttons():
assert 'id="ge-add-layer"' in CONTROLS
assert 'id="ge-add-fx"' in CONTROLS
assert 'class="ge-add-fx-glyph"' in CONTROLS
# the fx button sits right next to the plain new-layer button
assert CONTROLS.index('id="ge-add-layer"') < CONTROLS.index('id="ge-add-fx"')
def test_plus_button_adds_a_pixel_layer_directly():
# ge-add-layer no longer opens a menu, it adds a raster layer immediately
assert "const addButton = document.getElementById('ge-add-layer');" in AI_MISC
assert "addButton?.addEventListener('click', event => {" in AI_MISC
assert "addEmptyLayer();" in AI_MISC
# the combined pixel+adjustment menu is gone
assert 'data-layer-kind="raster"' not in AI_MISC
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
assert "buildFxMenu(addFxButton);" in AI_MISC
# the fx menu still offers adjustment-layer types through the shared popup
assert 'data-adjustment-type="curves"' in AI_MISC
assert "openAdjustmentLayer?.(button.dataset.adjustmentType, anchor)" in AI_MISC
assert ".ge-add-fx-glyph" in STYLE
+29
View File
@@ -0,0 +1,29 @@
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ADJ = (ROOT / "static/js/editor/fx/adj-popup.js").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
def test_adjustment_popup_head_uses_a_close_button():
# The head control is an explicit close (x), not a minimise affordance that
# did not behave like a minimise.
assert '<button class="ge-adj-close" type="button" title="Close" aria-label="Close">&times;</button>' in ADJ
def test_head_no_longer_renders_a_minimise_button():
assert 'class="ge-adj-min"' not in ADJ
assert 'title="Minimise"' not in ADJ
assert "&minus;" not in ADJ
def test_close_button_is_wired_to_close_adj_popup():
assert "pop.querySelector('.ge-adj-close')?.addEventListener('click', closeAdjPopup);" in ADJ
assert "pop.querySelector('.ge-adj-min')" not in ADJ
def test_sw_cache_name_is_version_agnostic():
m = re.search(r"CACHE_NAME = '(odysseus-v\d+-pr40-editor-precache)'", SW)
assert m, "sw.js CACHE_NAME not found"
assert re.fullmatch(r"odysseus-v\d+-pr40-editor-precache", m.group(1))
@@ -0,0 +1,43 @@
"""Every adjustment popup that renders slider rows must store their values.
``buildAdjBody`` renders ``sliderRow`` controls per adjustment type, and
``onAdjSliderInput`` maps a moved slider onto ``layer._stagedAdj.params``. A
type with rows but no input branch shows a working slider whose value is
silently dropped on OK (Vibrance regressed exactly this way). The browser
behavior is covered by tests/e2e/photo-editor/adjustment-layers.spec.js.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
POPUP = (ROOT / "static/js/editor/fx/adj-popup.js").read_text(encoding="utf-8")
def _function_body(name: str, next_name: str) -> str:
start = POPUP.index(f"function {name}(")
return POPUP[start:POPUP.index(f"function {next_name}(", start)]
def _types_with_slider_rows() -> set[str]:
builder = _function_body("buildAdjBody", "onAdjSliderInput")
branches = re.split(r"\}\s*else if \(|if \(type === ", builder)
types = set()
for branch in branches:
names = re.findall(r"type === '([a-z-]+)'", "type === " + branch.split(")", 1)[0])
if names and "sliderRow(" in branch:
types.update(names)
return types
def test_every_slider_adjustment_type_writes_its_params():
rendered = _types_with_slider_rows()
assert {"exposure", "white-balance", "vibrance", "black-white"} <= rendered, rendered
handler = _function_body("onAdjSliderInput", "wireCurveEditor")
handled = set(re.findall(r"type === '([a-z-]+)'", handler))
missing = sorted(rendered - handled)
assert not missing, f"slider rows whose input is never stored: {missing}"
def test_vibrance_slider_stores_the_raw_value():
handler = _function_body("onAdjSliderInput", "wireCurveEditor")
assert re.search(r"else if \(type === 'vibrance'\) \{\s*p\[key\] = raw;", handler)
+36
View File
@@ -93,6 +93,42 @@ def test_color_adjustments_produce_deterministic_pixels_and_preserve_alpha():
assert len({tuple(pixel) for pixel in result}) == 7
def test_shadows_highlights_preserves_black_point_and_contrast():
source = textwrap.dedent(
f"""
import {{applyAdjustment}} from {json.dumps(PIXEL_MODULE)};
class FakeContext {{
constructor(canvas) {{ this.canvas=canvas; this.filter='none'; }}
drawImage(source) {{ this.canvas.pixels=new Uint8ClampedArray(source.pixels); }}
getImageData() {{ return {{data:new Uint8ClampedArray(this.canvas.pixels)}}; }}
putImageData(image) {{ this.canvas.pixels=new Uint8ClampedArray(image.data); }}
}}
class FakeCanvas {{
constructor(pixels=[0,0,0,0]) {{ this.width=1; this.height=1; this.pixels=new Uint8ClampedArray(pixels); this.ctx=new FakeContext(this); }}
getContext() {{ return this.ctx; }}
}}
globalThis.document={{createElement:()=>new FakeCanvas()}};
const spec={{type:'shadows-highlights',params:{{shadows:30,highlights:-15}}}};
const lift=(value)=>applyAdjustment(new FakeCanvas([value,value,value,255]),spec).pixels[0];
console.log(JSON.stringify({{
black:lift(0),
nearBlack:lift(10),
dark:lift(40),
mid:lift(128),
white:lift(255),
}}));
"""
)
result = run_node(source)
# Pure black and pure white must be untouched (no grey veil / no clipping).
assert result["black"] == 0
assert result["white"] == 255
# Near-black lifts gently (well below the old additive lift that pushed 10 -> ~78).
assert 10 < result["nearBlack"] <= 40
# The whole tone ramp stays monotonic: black < near-black < dark < mid < white.
assert result["black"] < result["nearBlack"] < result["dark"] < result["mid"] < result["white"]
def test_adjustment_worker_reuses_shared_pixel_pass_and_returns_transferable_bitmap():
worker = ADJUSTMENT_WORKER.read_text()
adjustment = (ROOT / "static/js/editor/adjustment-layer.js").read_text()
@@ -0,0 +1,54 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ADJ_LAYER = ROOT / "static/js/editor/adjustment-layer.js"
PIXEL_PASS = ROOT / "static/js/editor/fx/pixel-pass.js"
HELPERS = ROOT / "static/js/editor/layer-helpers.js"
POPUP = ROOT / "static/js/editor/fx/adj-popup.js"
ADD_MENU = ROOT / "static/js/editor/ai-tools-misc.js"
NEW_TYPES = [
"invert",
"posterize",
"threshold",
"photo-filter",
"channel-mixer",
"color-lookup",
]
def test_adjustment_types_registered():
src = ADJ_LAYER.read_text(encoding="utf-8")
for t in NEW_TYPES:
assert f"'{t}'" in src or f'"{t}"' in src, t
def test_pixel_pass_implements_new_adjustments():
src = PIXEL_PASS.read_text(encoding="utf-8")
for t in NEW_TYPES:
assert f"'{t}'" in src, t
def test_defaults_icons_and_labels():
src = HELPERS.read_text(encoding="utf-8")
for t in NEW_TYPES:
assert f"case '{t}'" in src, f"default {t}"
assert f"'{t}':" in src, f"icon/label {t}"
def test_popup_exposes_new_adjustments():
src = POPUP.read_text(encoding="utf-8")
for t in NEW_TYPES:
assert f"type: '{t}'" in src, t
assert f"type === '{t}'" in src, t
# Slider handlers for the parameterised additions.
assert "type === 'posterize'" in src
assert "type === 'channel-mixer'" in src
assert "p.constant[ch]" in src
def test_add_layer_menu_lists_new_adjustments():
src = ADD_MENU.read_text(encoding="utf-8")
for t in NEW_TYPES:
assert f'data-adjustment-type="{t}"' in src, t
+203
View File
@@ -0,0 +1,203 @@
import json
import subprocess
import textwrap
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MODULE = (ROOT / 'static/js/editor/editor-agent.js').as_uri()
def run_node(body):
script = textwrap.dedent(f"""
import {{ createEditorAgent, EDITOR_AGENT_VERSION, DEFAULT_AGENT_ENDPOINT }} from {json.dumps(MODULE)};
{body}
""")
result = subprocess.run(
['node', '--input-type=module', '-e', script],
cwd=ROOT, text=True, capture_output=True, check=True,
)
return json.loads(result.stdout)
def test_agent_run_posts_payload_streams_steps_and_collapses_history():
out = run_node("""
const calls = [];
const steps = [];
const posts = [];
const control = { invoke(op, params) {
calls.push(op);
if (op === 'document.info') return { ok: true, result: { width: 800, height: 600, activeLayerId: 'layer-1' } };
if (op === 'layers.list') return { ok: true, result: [{ id: 'layer-1', name: 'Bg' }] };
if (op === 'screenshot') return { ok: true, result: { dataUrl: 'data:image/png;base64,AA' } };
if (op === 'history.mark') return { ok: true, result: 5 };
if (op === 'history.collapse') return { ok: true, result: 1 };
return { ok: true, result: null };
} };
const fetchImpl = async (url, opts) => {
posts.push({ url, body: JSON.parse(opts.body) });
return { ok: true, status: 200, json: async () => ({
ok: true, reply: 'done',
tools_offered: ['plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image', 'generate_image'],
steps: [{ op: 'adjustments.apply', params: { type: 'brightness-contrast' } }],
}) };
};
const agent = createEditorAgent({ control, endpoint: '/api/editor/agent', fetchImpl, getSession: () => 'sess-1' });
const p = agent.run('brighten the face', { onStep: (e) => steps.push(e.op + ':' + e.ok) });
agent.reportStep({ ok: true, ms: 12 }, { op: 'selection.face' });
const out = await p;
console.log(JSON.stringify({ posts, calls, steps, out, version: EDITOR_AGENT_VERSION, def: DEFAULT_AGENT_ENDPOINT }));
""")
body = out['posts'][0]['body']
assert out['posts'][0]['url'] == '/api/editor/agent'
assert body['prompt'] == 'brighten the face'
assert body['session'] == 'sess-1' and body['editor_session'] == 'sess-1'
assert body['state']['width'] == 800 and len(body['state']['layers']) == 1
assert body['screenshot'].startswith('data:image/png')
assert 'history.mark' in out['calls'] and 'history.collapse' in out['calls']
assert out['steps'] == ['selection.face:true']
assert out['out']['ok'] is True
assert 'ai_edit_image' in out['out']['toolsOffered']
assert out['out']['trace'][0]['op'] == 'selection.face'
assert out['version'] == '1.0'
assert out['def'] == '/api/editor/agent'
def test_agent_run_includes_the_selected_edit_model_in_the_payload():
out = run_node("""
const posts = [];
const control = { invoke(op) {
if (op === 'document.info') return { ok: true, result: { width: 800, height: 600, activeLayerId: 'layer-1' } };
if (op === 'layers.list') return { ok: true, result: [{ id: 'layer-1', name: 'Bg' }] };
if (op === 'screenshot') return { ok: true, result: { dataUrl: 'data:image/png;base64,AA' } };
if (op === 'history.mark') return { ok: true, result: 5 };
if (op === 'history.collapse') return { ok: true, result: 1 };
return { ok: true, result: null };
} };
const fetchImpl = async (url, opts) => {
posts.push({ url, body: JSON.parse(opts.body) });
return { ok: true, status: 200, json: async () => ({ ok: true, reply: 'done', steps: [] }) };
};
const agent = createEditorAgent({
control, endpoint: '/api/editor/agent', fetchImpl, getSession: () => 'sess-1',
getModel: () => ({ endpoint: 'http://seneca:8188/v1', model: 'qwen-image-edit-2511-8bit', label: 'Qwen-Image-Edit-2511' }),
getAgentModel: () => ({ agent: 'Qwen3.5-9B-MLX-8bit', endpoint: 'http://seneca:8097/v1', label: 'Qwen3.5-9B VL · local' }),
});
await agent.run('brighten the face', {});
console.log(JSON.stringify({ body: posts[0].body }));
""")
body = out['body']
assert body['model'] == 'qwen-image-edit-2511-8bit'
assert body['endpoint'] == 'http://seneca:8188/v1'
assert body['model_label'] == 'Qwen-Image-Edit-2511'
assert body['agent_model'] == 'Qwen3.5-9B-MLX-8bit'
assert body['agent_endpoint'] == 'http://seneca:8097/v1'
assert body['agent_label'] == 'Qwen3.5-9B VL · local'
def test_agent_run_is_unavailable_when_endpoint_missing():
out = run_node("""
const control = { invoke(op) {
if (op === 'screenshot') return { ok: true, result: { dataUrl: 'data:image/png;base64,AA' } };
if (op === 'history.mark') return { ok: true, result: 0 };
return { ok: true, result: {} };
} };
const fetchImpl = async () => ({ ok: false, status: 404, json: async () => ({}) });
const agent = createEditorAgent({ control, fetchImpl });
const out = await agent.run('make it moody');
console.log(JSON.stringify({ out }));
""")
assert out['out']['unavailable'] is True
assert out['out']['ok'] is False
def test_agent_run_rolls_back_partial_edits_when_provider_missing():
out = run_node("""
let stack = 0;
const undos = [];
let collapses = 0;
let agentRef = null;
const control = { invoke(op) {
if (op === 'document.info') return { ok: true, result: { width: 900, height: 600 } };
if (op === 'layers.list') return { ok: true, result: [] };
if (op === 'screenshot') return { ok: true, result: { dataUrl: 'data:image/png;base64,AA' } };
if (op === 'history.mark') return { ok: true, result: stack };
if (op === 'history.undo') { undos.push(1); if (stack > 0) stack--; return { ok: true, result: true }; }
if (op === 'history.collapse') { collapses++; return { ok: true, result: 1 }; }
return { ok: true, result: null };
} };
const fetchImpl = async () => {
// Simulate the agent running two substitute ops through the bridge.
stack = 2;
agentRef.reportStep({ ok: true }, { op: 'selection.rectangle' });
agentRef.reportStep({ ok: true }, { op: 'adjustments.apply' });
return { ok: false, status: 200, json: async () => ({
ok: false, needs_provider: true,
reply: 'Removing objects needs an image generation provider.',
}) };
};
agentRef = createEditorAgent({ control, fetchImpl, getSession: () => 's' });
const out = await agentRef.run('remove the person on the left');
console.log(JSON.stringify({ out, undos: undos.length, collapses, stack }));
""")
assert out['out']['blocked'] is True
assert out['out']['needsProvider'] is True
assert out['out']['ok'] is False
assert out['out']['rolledBack'] == 2
assert out['undos'] == 2
assert out['collapses'] == 0
assert out['stack'] == 0
assert 'image generation provider' in out['out']['message'].lower()
def test_agent_module_is_precached_for_offline_use():
sw = (ROOT / 'static/sw.js').read_text(encoding='utf-8')
assert "'/static/js/editor/editor-agent.js'" in sw
def test_agent_has_skin_teeth_and_formatcrop_guards():
src = (ROOT / 'static/js/editor/editor-agent.js').read_text(encoding='utf-8')
assert "control.invoke('retouch.skin'" in src
assert "control.invoke('retouch.teeth'" in src
assert 'formatCropRect(prompt' in src
assert 'isGenericIntent' in src
assert 'dataUrlDelta' in src
def test_agent_skips_format_crop_for_extend_intent():
from pathlib import Path
src = (Path(__file__).resolve().parents[1] / 'static/js/editor/editor-agent.js').read_text(encoding='utf-8')
assert 'extendIntent' in src
assert 'extend|expand' in src
assert 'alreadyFormatted ? null : formatCropRect' in src
def test_agent_suppresses_autotone_for_portrait_intents():
from pathlib import Path
src = (Path(__file__).resolve().parents[1] / 'static/js/editor/editor-agent.js').read_text(encoding='utf-8')
# Portrait/teeth runs must not lead with a whole-image Auto Tone (it lightens
# and whitens the face) unless the photo is genuinely under/over-exposed.
assert 'dataUrlExposure' in src
assert 'portraitIntent' in src
assert 'teethIntent' in src
assert 'portrait-no-auto-tone' in src
assert 'restoreInvoke' in src
assert 'exposure.dark > 0.32' in src
def test_agent_does_not_strengthen_a_clarification():
from pathlib import Path
src = (Path(__file__).resolve().parents[1] / 'static/js/editor/editor-agent.js').read_text(encoding='utf-8')
# A zero-step plan (the planner asked a question) must skip every post-guard,
# especially the imperceptible-edit tone strengthen, and be returned blocked.
assert 'const asked = !blocked && planSteps.length === 0' in src
assert '} else if (!blocked && !asked) {' in src
assert 'if (blocked || asked) {' in src
def test_agent_strengthen_guard_only_for_generic_intents():
from pathlib import Path
src = (Path(__file__).resolve().parents[1] / 'static/js/editor/editor-agent.js').read_text(encoding='utf-8')
# Structural/action prompts must not get a spurious vibrance/brightness tweak.
assert 'if (beforeShot && generic) {' in src
@@ -0,0 +1,91 @@
"""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"
def test_unconfigured_or_untrusted_override_never_becomes_endpoint(monkeypatch):
monkeypatch.setattr(eam, "local_bases", lambda: [])
import src.endpoint_resolver as resolver
monkeypatch.setattr(resolver, "resolve_endpoint", lambda *a, **k: (None, None, None))
assert eam._candidates("alice", "chosen-model") == []
assert eam._candidates("alice", "http://169.254.169.254::model") == []
+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)
+61
View File
@@ -0,0 +1,61 @@
import json
import subprocess
import textwrap
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MODULE = (ROOT / 'static/js/editor/aspect-fit.js').as_uri()
def run_node(body):
script = textwrap.dedent(f"""
import * as A from {json.dumps(MODULE)};
{body}
""")
result = subprocess.run(
['node', '--input-type=module', '-e', script],
cwd=ROOT, text=True, capture_output=True, check=True,
)
return json.loads(result.stdout)
def test_aspect_fit_preserves_subject_ratio_within_two_percent():
# A 900x600 canvas with a subject (face) whose bbox has ratio 0.62, ask for
# a 1080x1350 (4:5) Instagram post. The crop-to-aspect step then the uniform
# scale must keep the subject bbox w/h ratio within 2%.
out = run_node("""
const docW=900, docH=600;
const subj={x:330,y:120,w:260,h:420}; // ratio 0.619
const crop=A.aspectFitCropRect({width:docW,height:docH,focusX:subj.x+subj.w/2,focusY:subj.y+subj.h/2,targetAspect:1080/1350});
// subject clipped to the crop window
const ix=Math.max(subj.x,crop.x), iy=Math.max(subj.y,crop.y);
const ix2=Math.min(subj.x+subj.w,crop.x+crop.width), iy2=Math.min(subj.y+subj.h,crop.y+crop.height);
const sx=(ix2-ix)*(1080/crop.width), sy=(iy2-iy)*(1350/crop.height); // uniform scale
console.log(JSON.stringify({ crop, subjRatio:subj.w/subj.h, afterRatio:sx/sy }));
""")
assert abs(out['afterRatio'] - out['subjRatio']) / out['subjRatio'] <= 0.02, out
# The crop really is 4:5 and not the full 3:2 canvas.
assert abs(out['crop']['width'] / out['crop']['height'] - 1080 / 1350) <= 0.01
def test_aspect_fit_no_crop_when_aspect_already_matches():
out = run_node("""
const r=A.aspectFitCropRect({width:1080,height:1350,targetAspect:1080/1350});
console.log(JSON.stringify(r));
""")
assert out == {'x': 0, 'y': 0, 'width': 1080, 'height': 1350}
def test_aspect_fit_centres_on_focus_and_clamps_into_bounds():
out = run_node("""
// Tall subject near the left edge -> crop stays inside the document.
const crop=A.aspectFitCropRect({width:900,height:600,focusX:60,focusY:300,targetAspect:4/5});
const edge=A.aspectFitCropRect({width:900,height:600,focusX:450,focusY:300,targetAspect:4/5});
console.log(JSON.stringify({crop,edge}));
""")
# focus at x=60 -> centred crop would start at -180 -> clamped to 0
assert out['crop']['x'] == 0
assert out['crop']['height'] == 600 and out['crop']['width'] == 480
# centred focus -> starts at 210
assert out['edge']['x'] == 210
+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
+92
View File
@@ -0,0 +1,92 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _editor():
return (ROOT / "static/js/galleryEditor.js").read_text()
def _slice(source, start, end):
return source[source.index(start):source.index(end)]
def test_every_edit_schedules_a_debounced_autosave():
source = _editor()
# state mutations funnel through _saveState, which schedules a persist.
save_state = _slice(source, "function _saveState", "function closeEditor")
assert "_schedulePersist()" in save_state
schedule = _slice(source, "function _schedulePersist", "function _buildDraftPayload")
assert "const PERSIST_DEBOUNCE_MS = 800;" in source
assert "if (_draftSavingDisabled()) return;" in schedule
assert "if (!state.editorOpen || !state.layers.length) return;" in schedule
# The first save (no server draft yet) establishes the recovery anchor
# immediately; later edits stay debounced.
assert "const delay = state.draftId ? PERSIST_DEBOUNCE_MS : 0;" in schedule
assert "state.persistTimer = setTimeout(() => { state.persistTimer = null; _persistDraft(); }, delay);" in schedule
def test_autosave_creates_then_updates_the_server_draft_and_coalesces():
persist = _slice(_editor(), "async function _persistDraft", "async function _loadDraftById")
# Concurrent saves coalesce instead of racing.
assert "if (state.persistInFlight) { state.persistDirty = true; _setDraftStatus('Saving…', 'saving'); return; }" in persist
assert "if (isCurrentSession()) state.persistInFlight = null;" in persist
assert "if (state.persistDirty) {" in persist
# Create on the first save, update thereafter, and recreate if the row
# was deleted (404) while the editor was open.
assert "return fetch('/api/editor-drafts', {" in persist
assert "method: 'PUT', credentials: 'same-origin'," in persist
assert "if (res.status === 404) {" in persist
assert "method: 'POST', credentials: 'same-origin'," in persist
# Oversized layered documents pause autosave instead of silently failing.
assert "if (serializedBody.length > _EDITOR_PROJECT_MAX_BYTES) {" in persist
assert "_setDraftStatus('Save paused', 'error');" in persist
def test_autosave_writes_a_session_recovery_anchor():
source = _editor()
write = _slice(source, "function _writeActiveEditorSession", "function _setDraftStatus")
assert "if (!state.editorOpen) return;" in write
assert "sessionStorage.setItem(ACTIVE_EDITOR_SESSION_KEY" in write
assert "draftId: state.draftId || null," in write
assert "draftName: state.draftName || 'Untitled'," in write
assert "width: Number(state.imgWidth) || 0," in write
assert "height: Number(state.imgHeight) || 0," in write
# The anchor is refreshed once a fresh draft id comes back from the server.
assert "state.draftId = out.id;\n _writeActiveEditorSession();" in source
def test_closing_the_editor_flushes_and_clears_the_recovery_anchor():
source = _editor()
close = source[source.index("export function closeEditor"):]
assert "if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }" in close
assert "try { _persistDraft(); } catch {}" in close
assert "state.editorSessionToken += 1;" in close
assert "_clearActiveEditorSession();" in close
def test_open_editor_resumes_a_draft_by_server_id_with_a_liveness_guard():
source = _editor()
open_fn = source[source.index("export function openEditor"):]
assert "if (!imageUrl && draftId) {" in open_fn
assert "return _loadDraftById(draftId)" in open_fn
# A late draft load must not draw into a torn-down editor.
assert "if (!state.editorOpen) return;" in open_fn
def test_gallery_restores_the_active_edit_after_refresh():
gallery = (ROOT / "static/js/gallery.js").read_text()
restore = _slice(gallery, "async function _restoreActiveEditorAfterRefresh", "if (document.readyState")
assert "const saved = _readActiveEditorSession();" in restore
assert "if (!saved) return;" in restore
# It reopens the same draft (or source image) at the same canvas size.
assert "saved.draftId ? null : (saved.imageUrl || null)," in restore
assert "saved.draftId || null," in restore
assert "saved.width > 0 && saved.height > 0 ? { w: saved.width, h: saved.height } : null," in restore
# Wiring: run the restore once the shell has mounted after a reload.
assert "document.addEventListener('DOMContentLoaded', () => setTimeout(_restoreActiveEditorAfterRefresh, 0), { once: true });" in gallery
+104
View File
@@ -0,0 +1,104 @@
import json
import re
import subprocess
import textwrap
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BATCH_MODULE = (ROOT / "static/js/editor/batch-runner.js").as_uri()
GALLERY = ROOT / "static/js/galleryEditor.js"
ACTIONS_PANEL = ROOT / "static/js/editor/actions-panel.js"
SW = ROOT / "static/sw.js"
def run_node(source: str):
result = subprocess.run(
["node", "--input-type=module", "-e", source],
cwd=ROOT,
text=True,
capture_output=True,
check=True,
)
return json.loads(result.stdout)
def test_batch_runner_processes_every_document_in_order():
source = textwrap.dedent(
f"""
import {{runBatchOverDocuments}} from {json.dumps(BATCH_MODULE)};
const seen = [];
const progress = [];
const docs = [{{id:'a',name:'A'}},{{id:'b',name:'B'}},{{id:'c',name:'C'}}];
const results = await runBatchOverDocuments({{
documents: docs,
runOne: async (doc, index) => {{ seen.push(doc.id); return index; }},
onProgress: (done, total, doc) => progress.push([done, total, doc.id]),
}});
console.log(JSON.stringify({{seen, progress, results}}));
"""
)
data = run_node(source)
assert data["seen"] == ["a", "b", "c"]
assert data["results"] == [
{"id": "a", "name": "A", "ok": True, "output": 0},
{"id": "b", "name": "B", "ok": True, "output": 1},
{"id": "c", "name": "C", "ok": True, "output": 2},
]
assert data["progress"] == [[1, 3, "a"], [2, 3, "b"], [3, 3, "c"]]
def test_batch_runner_isolates_a_failing_document_and_continues():
source = textwrap.dedent(
f"""
import {{runBatchOverDocuments}} from {json.dumps(BATCH_MODULE)};
const docs = [{{id:'a',name:'A'}},{{id:'b',name:'B'}},{{id:'c',name:'C'}}];
const results = await runBatchOverDocuments({{
documents: docs,
runOne: async (doc) => {{ if (doc.id === 'b') throw new Error('boom'); return doc.id; }},
}});
console.log(JSON.stringify(results));
"""
)
results = run_node(source)
assert len(results) == 3
assert results[0]["ok"] is True and results[0]["output"] == "a"
assert results[1]["ok"] is False and "boom" in results[1]["error"]
assert results[2]["ok"] is True and results[2]["output"] == "c"
def test_batch_runner_tolerates_an_empty_document_list():
source = textwrap.dedent(
f"""
import {{runBatchOverDocuments}} from {json.dumps(BATCH_MODULE)};
const results = await runBatchOverDocuments({{ documents: [], runOne: async () => 1 }});
console.log(JSON.stringify(results));
"""
)
assert run_node(source) == []
def test_gallery_editor_exposes_the_batch_documents_op():
src = GALLERY.read_text()
assert "'actions.batchDocuments':" in src
assert "import { runBatchOverDocuments } from './editor/batch-runner.js';" in src
assert "import { createActionsPanel, ACTION_COMMANDS } from './editor/actions-panel.js';" in src
assert "ACTION_COMMANDS.map(([id, , sel]) => [id, sel])" in src
assert "runBatchOverDocuments({ documents: _openDocuments.map((d) => ({ id: d.id, name: d.name }))" in src
assert "await invoke('document.switch', { id: doc.id });" in src
assert "JSON.parse(localStorage.getItem('ge-actions') || '{}')[name]" in src
def test_actions_panel_has_a_batch_docs_button():
src = ACTIONS_PANEL.read_text()
assert "const batchDocsButton = el('button', 'ge-btn ge-btn-sm', 'Batch docs');" in src
assert "batchRow.append(batchSelect, batchButton, batchDocsButton);" in src
assert "window.geEditorControl?.invoke?.('actions.batchDocuments', { name })" in src
assert "batchDocsButton.disabled = true;" in src
assert "batchDocsButton.disabled = false;" in src
def test_service_worker_precaches_the_batch_runner():
src = SW.read_text()
assert re.search(r"odysseus-v\d+-pr40-editor-precache", src)
assert "'/static/js/editor/batch-runner.js'," in src
+60
View File
@@ -0,0 +1,60 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BLEND = ROOT / "static/js/editor/blend-modes.js"
LAYER_CLIPPING = ROOT / "static/js/editor/layer-clipping.js"
ADJUSTMENT_LAYER = ROOT / "static/js/editor/adjustment-layer.js"
LAYER_GROUPS = ROOT / "static/js/editor/layer-groups.js"
CONTROLS = ROOT / "static/js/editor/build/controls.js"
CODEC = ROOT / "static/js/editor/document-codec.js"
SW = ROOT / "static/sw.js"
EXTRA_MODES = [
"dissolve",
"linear-burn",
"linear-dodge",
"darker-color",
"lighter-color",
"vivid-light",
"linear-light",
"pin-light",
"hard-mix",
"subtract",
"divide",
]
def test_blend_modes_module_shape():
src = BLEND.read_text(encoding="utf-8")
assert "export const NATIVE_BLEND_MODES" in src
assert "export const EXTRA_BLEND_MODES" in src
assert "export function blendDraw" in src
assert "export function isNativeBlendMode" in src
# Canvas natively supports linear-dodge via 'lighter'.
assert "linear-dodge" in src and "lighter" in src
def test_extra_blend_modes_are_declared():
src = BLEND.read_text(encoding="utf-8")
for mode in EXTRA_MODES:
assert f"'{mode}'" in src or f'"{mode}"' in src, mode
def test_blend_draw_is_wired_into_all_draw_paths():
for path in (LAYER_CLIPPING, ADJUSTMENT_LAYER, LAYER_GROUPS):
src = path.read_text(encoding="utf-8")
assert "blendDraw" in src, path
assert "blendDraw(" in src, path
def test_blend_select_and_codec_offer_extra_modes():
controls = CONTROLS.read_text(encoding="utf-8")
codec = CODEC.read_text(encoding="utf-8")
for mode in EXTRA_MODES:
assert f'value="{mode}"' in controls, mode
assert f"'{mode}'" in codec or f'"{mode}"' in codec, mode
def test_blend_modes_precached():
assert "/static/js/editor/blend-modes.js" in SW.read_text(encoding="utf-8")
+79
View File
@@ -0,0 +1,79 @@
import json
import subprocess
import textwrap
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MODULE = (ROOT / 'static/js/editor/editor-bridge.js').as_uri()
def run_node(body):
script = textwrap.dedent(f"""
import {{ startEditorBridge, BRIDGE_VERSION }} from {json.dumps(MODULE)};
{body}
""")
result = subprocess.run(
['node', '--input-type=module', '-e', script],
cwd=ROOT, text=True, capture_output=True, check=True,
)
return json.loads(result.stdout)
def test_bridge_runs_queued_command_and_posts_result():
out = run_node("""
const calls = [];
const posts = [];
class FakeES {
constructor(url) { this.url = url; FakeES.last = this; }
close() { this.closed = true; }
}
const control = { invoke(op, params) {
calls.push(op);
if (op === 'screenshot') return { ok: true, result: { dataUrl: 'data:image/png;base64,ZZ' } };
return { ok: true, result: 'r:' + op };
} };
const fetchImpl = async (url, opts) => { posts.push({ url, body: JSON.parse(opts.body) }); return { ok: true, json: async () => ({ ok: true }) }; };
const bridge = startEditorBridge({ control, session: 's1', baseUrl: '/x', EventSourceImpl: FakeES, fetchImpl });
FakeES.last.onmessage({ data: JSON.stringify({ id: 'c1', op: 'history.undo', params: {} }) });
await new Promise(r => setTimeout(r, 25));
console.log(JSON.stringify({ esUrl: FakeES.last.url, calls, posts, session: bridge.session, version: BRIDGE_VERSION }));
""")
assert out['esUrl'] == '/x/api/editor/bridge/events?session=s1'
assert 'history.undo' in out['calls']
assert 'screenshot' in out['calls']
assert out['posts'][0]['url'] == '/x/api/editor/bridge/result'
assert out['posts'][0]['body']['id'] == 'c1'
assert out['posts'][0]['body']['ok'] is True
assert out['posts'][0]['body']['screenshot'].startswith('data:image/png')
assert out['session'] == 's1'
assert out['version'] == '1.0'
def test_bridge_reports_op_failure_and_stop_closes_stream():
out = run_node("""
const posts = [];
class FakeES {
constructor(url) { this.url = url; FakeES.last = this; }
close() { this.closed = true; }
}
const control = { invoke(op) {
if (op === 'screenshot') return { ok: true, result: { dataUrl: 'data:image/png;base64,ZZ' } };
return { ok: false, error: 'nope' };
} };
const fetchImpl = async (url, opts) => { posts.push(JSON.parse(opts.body)); return { ok: true, json: async () => ({}) }; };
const bridge = startEditorBridge({ control, session: 'default', EventSourceImpl: FakeES, fetchImpl });
FakeES.last.onmessage({ data: JSON.stringify({ id: 'x', op: 'layers.delete', params: { id: 'layer-9' } }) });
await new Promise(r => setTimeout(r, 25));
bridge.stop();
console.log(JSON.stringify({ body: posts[0], closed: !!FakeES.last.closed }));
""")
assert out['body']['ok'] is False
assert out['body']['error'] == 'nope'
assert out['body']['result'] is None
assert out['closed'] is True
def test_bridge_module_is_precached_for_offline_use():
sw = (ROOT / 'static/sw.js').read_text(encoding='utf-8')
assert "'/static/js/editor/editor-bridge.js'" in sw
+102 -3
View File
@@ -20,7 +20,7 @@ def run_node(body: str):
return json.loads(result.stdout)
def test_sampler_emits_even_spacing_and_flushes_endpoint():
def test_sampler_emits_even_spacing_without_a_lone_endpoint_dab():
data = run_node(
f"""
import {{ createBrushSampler }} from {json.dumps(ENGINE)};
@@ -37,8 +37,31 @@ def test_sampler_emits_even_spacing_and_flushes_endpoint():
console.log(JSON.stringify(samples));
"""
)
assert [round(item["x"]) for item in data] == [0, 5, 10, 15, 20, 23]
assert data[-1]["pressure"] == 0.25
# The final point is within one spacing of the last dab, so it must not be
# stamped as a detached dot; the last dab stays on the even-spacing grid.
assert [round(item["x"]) for item in data] == [0, 5, 10, 15, 20]
def test_sampler_fills_a_long_final_gap_with_spaced_dabs():
data = run_node(
f"""
import {{ createBrushSampler }} from {json.dumps(ENGINE)};
const samples = [];
const sampler = createBrushSampler({{
getDiameter: () => 20,
getSpacing: () => 0.25,
getSmoothing: () => 0,
emit: sample => samples.push(sample),
}});
sampler.begin({{x:0,y:0,pressure:1}});
sampler.add({{x:20,y:0,pressure:1}});
sampler.end({{x:37,y:0,pressure:1}});
console.log(JSON.stringify(samples.map(sample => Math.round(sample.x))));
"""
)
# A far pointer-up point is reached with evenly spaced dabs (step ~5px),
# never a single 17px-away dab that would appear as a dot.
assert data == [0, 5, 10, 15, 20, 25, 30, 35]
def test_pressure_mapping_is_independent_and_mouse_falls_back_to_full():
@@ -74,3 +97,79 @@ def test_custom_presets_roundtrip_without_mutating_built_ins():
assert data["names"][-1] == "Texture"
assert data["target"]["brushSize"] == 42
assert data["target"]["brushBlendMode"] == "multiply"
def test_pixel_brush_preset_roundtrips_pixel_flag():
data = run_node(
f"""
import {{ loadBrushPresets, captureBrushPreset, applyBrushPreset }} from {json.dumps(PRESETS)};
const loaded = loadBrushPresets({{ getItem: () => null, setItem: () => {{}} }});
const pixel = loaded.find(item => item.id === 'pixel-1px');
const target = {{}};
applyBrushPreset(target, pixel);
const captured = captureBrushPreset(
{{brushSize:1,brushOpacity:100,brushFlow:100,brushSoftness:0,brushSpacing:1,brushSmoothing:0,brushBlendMode:'source-over',brushPixel:true}},
'Px',
);
console.log(JSON.stringify({{
names: loaded.map(item => item.name),
pixelSize: pixel && pixel.size,
appliedPixel: target.brushPixel,
capturedPixel: captured.pixel,
}}));
"""
)
assert "Pixel" in data["names"]
assert data["pixelSize"] == 1
assert data["appliedPixel"] is True
assert data["capturedPixel"] is True
def test_stroke_pipeline_has_pixel_stamping():
source = (ROOT / "static/js/editor/stroke-pipeline.js").read_text()
assert "function pixelDab(" in source
assert "function pixelLine(" in source
assert "state.brushPixel" in source
assert "let pixelLast = null;" in source
# Size-aware square stamping (integer, no anti-aliasing), snapped to a
# grid whose cell is the brush size so consecutive dabs connect cleanly.
assert "const pixelSize = Math.max(1, Math.round(diameter));" in source
assert "const gx = Math.floor(x / s) * s;" in source
assert "const gy = Math.floor(y / s) * s;" in source
assert "ctx.fillRect(gx, gy, s, s);" in source
# The connecting line walks whole grid cells, never half-cells.
assert "let cx = Math.floor(Math.round(x0) / s);" in source
assert "const cx1 = Math.floor(Math.round(x1) / s);" in source
assert "ctx.fillRect(cx * s, cy * s, s, s);" in source
state = (ROOT / "static/js/editor/state.js").read_text()
assert "brushPixel: false" in state
def test_pixel_brush_does_not_force_a_grid_overlay():
guides = (ROOT / "static/js/editor/precision-guides.js").read_text()
# 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():
source = (ROOT / "static/js/editor/stroke-pipeline.js").read_text()
# Direction snap helper stays.
assert "function snapDirection(dx, dy)" in source
assert "Math.round(Math.atan2(dy, dx) / step) * step" in source
assert "let pixelAnchor = null;" in source
# A previous stroke's end is remembered so Shift+click connects from it.
assert "let pixelClickAnchor = null;" in source
assert "pixelAnchor = (sample && sample.shift && pixelClickAnchor)" in source
assert "if (pixelLast) pixelClickAnchor = { x: pixelLast.x, y: pixelLast.y };" in source
# Shift held => snap to the nearest axis/diagonal.
assert "if (sample.shift) {" in source
assert "const dir = snapDirection(ddx, ddy);" in source
assert "pixelLine(target.ctx, pixelAnchor.x, pixelAnchor.y, end.x, end.y," in source
# Freehand by default (no shift).
assert "if (pixelLast) pixelLine(target.ctx, pixelLast.x, pixelLast.y, px, py," in source
# The shift modifier has to reach the pipeline from the DOM event.
stroke = (ROOT / "static/js/editor/tools/stroke.js").read_text()
assert "shift: !!e?.shiftKey" in stroke
+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)
+42
View File
@@ -0,0 +1,42 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONTROLS = ROOT / "static/js/editor/build/controls.js"
RIGHT_PANEL = ROOT / "static/js/editor/build/right-panel.js"
GALLERY = ROOT / "static/js/galleryEditor.js"
WIRE = ROOT / "static/js/editor/wire-selection-controls.js"
STATE = ROOT / "static/js/editor/state.js"
def test_bucket_options_section_exists_with_tolerance_range():
src = CONTROLS.read_text(encoding="utf-8")
assert 'id="ge-bucket-section"' in src
assert 'class="ge-section-title">Paint Bucket' in src
assert 'id="ge-bucket-tolerance"' in src
# Photoshop's Paint Bucket tolerance is 0-255 (not the wand's 0-100).
assert 'id="ge-bucket-tolerance" min="0" max="255"' in src
assert 'id="ge-bucket-tolerance-label"' in src
# Seeded from the live state value, not a hardcoded number.
assert "value=\"${bucketTolerance}\"" in src
assert "wandTolerance, bucketTolerance" in src
def test_bucket_section_toggles_for_the_bucket_tool():
src = GALLERY.read_text(encoding="utf-8")
assert "document.getElementById('ge-bucket-section')" in src
assert "bucketSection.style.display = toolId === 'bucket' ? '' : 'none';" in src
def test_bucket_tolerance_is_wired_to_state():
wire = WIRE.read_text(encoding="utf-8")
assert "document.getElementById('ge-bucket-tolerance')?.addEventListener('input'" in wire
assert "state.bucketTolerance = Math.max(0, Math.min(255" in wire
assert "document.getElementById('ge-bucket-tolerance-label')" in wire
def test_bucket_tolerance_seeded_from_state():
rp = RIGHT_PANEL.read_text(encoding="utf-8")
assert "bucketTolerance: state.bucketTolerance," in rp
# The default must still exist server/client-side and be what the fill reads.
assert "bucketTolerance: 32" in STATE.read_text(encoding="utf-8")
+120
View File
@@ -0,0 +1,120 @@
import json
import subprocess
import textwrap
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BUCKET = (ROOT / "static/js/editor/tools/bucket.js").as_uri()
def run_node(body: str):
result = subprocess.run(
["node", "--input-type=module", "-e", textwrap.dedent(body)],
cwd=ROOT,
text=True,
capture_output=True,
check=True,
)
return json.loads(result.stdout)
def _solid(w, h, rgb=None, mask=None):
data = bytearray(w * h * 4)
if rgb:
for i in range(w * h):
data[i * 4] = rgb[0]
data[i * 4 + 1] = rgb[1]
data[i * 4 + 2] = rgb[2]
data[i * 4 + 3] = 255
return data
def test_flood_fill_fills_only_the_contiguous_region():
# 6x3 white field split in the middle by a one-pixel black wall.
w, h = 6, 3
data = _solid(w, h, (255, 255, 255))
for y in range(h):
i = (y * w + 3) * 4
data[i] = data[i + 1] = data[i + 2] = 0
# Fill the left half only (start at 0,0).
body = f"""
import {{ floodFillImageData }} from {json.dumps(BUCKET)};
const w = {w}, h = {h};
const data = new Uint8ClampedArray({list(data)});
const count = floodFillImageData(data, w, h, 0, 0, [255,0,0], {{ tolerance: 0 }});
console.log(JSON.stringify({{
count,
left: [data[0], data[1], data[2]],
right: [data[(w-1)*4], data[(w-1)*4+1], data[(w-1)*4+2]],
wall: [data[3*4], data[3*4+1], data[3*4+2]],
}}));
"""
out = run_node(body)
assert out["count"] == 9 # 3 columns x 3 rows
assert out["left"] == [255, 0, 0]
assert out["right"] == [255, 255, 255] # other side untouched
assert out["wall"] == [0, 0, 0]
def test_flood_fill_tolerance_and_selection_mask():
w, h = 4, 1
# Two near-white pixels then a far pixel; tolerance decides the spill.
data = bytearray(w * h * 4)
for x, c in enumerate([250, 245, 200, 250]):
i = x * 4
data[i] = data[i + 1] = data[i + 2] = c
data[i + 3] = 255
body = f"""
import {{ floodFillImageData }} from {json.dumps(BUCKET)};
const w = {w}, h = {h};
const base = new Uint8ClampedArray({list(data)});
const tight = new Uint8ClampedArray(base);
const tightCount = floodFillImageData(tight, w, h, 0, 0, [0,0,0], {{ tolerance: 10 }});
const loose = new Uint8ClampedArray(base);
const looseCount = floodFillImageData(loose, w, h, 0, 0, [0,0,0], {{ tolerance: 60 }});
// A mask that only allows the first pixel confines the fill.
const mask = new Uint8ClampedArray(w * h * 4);
mask[3] = 255;
const masked = new Uint8ClampedArray(base);
const maskedCount = floodFillImageData(masked, w, h, 0, 0, [0,0,0], {{ tolerance: 60, mask }});
console.log(JSON.stringify({{ tightCount, looseCount, maskedCount }}));
"""
out = run_node(body)
assert out["tightCount"] == 2 # 250 and 245 within 10; 200 is not
assert out["looseCount"] == 4 # tolerance 60 reaches everything
assert out["maskedCount"] == 1 # mask confines to the single eligible pixel
def test_bucket_tool_is_registered_and_wired():
toolbar = (ROOT / "static/js/editor/build/toolbar.js").read_text()
assert "bucket: { label: 'Paint Bucket'" in toolbar
assert "['gradient', 'bucket']" in toolbar
editor = (ROOT / "static/js/galleryEditor.js").read_text()
assert "import { createBucketTool } from './editor/tools/bucket.js';" in editor
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', '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_paint_tools_require_confirmation_before_rasterizing():
editor = (ROOT / "static/js/galleryEditor.js").read_text()
assert "async function _confirmRasterizeLayer(layer) {" in editor
assert '_saveState(`Rasterize "${layer.name}"`);' in editor
assert "if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive &&" in editor
assert "void _confirmRasterizeLayer(layer);" 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
+120
View File
@@ -0,0 +1,120 @@
import json
import subprocess
import textwrap
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MODULE = (ROOT / "static/js/editor/canvas-context-menu.js").as_uri()
STATE = (ROOT / "static/js/editor/state.js").as_uri()
def run_node(body: str):
result = subprocess.run(
["node", "--input-type=module", "-e", textwrap.dedent(body)],
cwd=ROOT,
text=True,
capture_output=True,
check=True,
)
return json.loads(result.stdout)
def labels(items):
out = []
for item in items:
if item.get("type") == "sep":
out.append("—")
else:
out.append(item["label"])
return out
def menu_for(tool: str, selection: bool = False):
return run_node(
f"""
import {{ state }} from {json.dumps(STATE)};
import {{ buildCanvasMenuItems }} from {json.dumps(MODULE)};
state.editorOpen = true;
state.tool = {json.dumps(tool)};
state.wandMask = {json.dumps({"w": 1, "h": 1} if selection else None)};
state.brushPixel = false;
const items = buildCanvasMenuItems(state.tool);
const out = items.map((i) => ({{ type: i.type || 'item', label: i.label || '', disabled: !!i.disabled, hint: i.hint || '' }}));
console.log(JSON.stringify(out));
"""
)
def test_paint_tool_menu_has_brush_controls():
items = menu_for("brush")
ls = labels(items)
assert "Increase Brush Size" in ls
assert "Decrease Brush Size" in ls
assert "Brush Preset" in ls
assert "Pixel Brush" in ls
# Common edit actions are always available.
assert "Undo" in ls and "Redo" in ls
assert "Rotate 90° CW" in ls
def test_pixel_brush_entry_is_checked_when_pixel_mode_active():
items = run_node(
f"""
import {{ state }} from {json.dumps(STATE)};
import {{ buildCanvasMenuItems }} from {json.dumps(MODULE)};
state.tool = 'brush';
state.brushPixel = true;
const items = buildCanvasMenuItems('brush');
const pixel = items.find((i) => i.label === 'Pixel Brush');
console.log(JSON.stringify({{ checked: !!pixel.checked }}));
"""
)
assert items["checked"] is True
def test_selection_tool_menu_has_selection_ops():
ls = labels(menu_for("marquee", selection=True))
assert "Select All" in ls
assert "Deselect" in ls
assert "Inverse Selection" in ls
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():
items = menu_for("brush", selection=False)
by_label = {i["label"]: i for i in items if i["type"] == "item"}
assert by_label["Cut"]["disabled"] is True
assert by_label["Copy"]["disabled"] is True
assert by_label["Paste"]["disabled"] is False
def test_crop_and_transform_tools_have_context_actions():
crop = labels(menu_for("crop"))
assert "Apply Crop" in crop
assert "Cancel Crop" in crop
transform = labels(menu_for("transform"))
assert "Commit Transform" in transform
assert "Cancel Transform" in transform
def test_context_menu_is_wired_into_canvas_events():
events = (ROOT / "static/js/editor/canvas-events.js").read_text()
assert "import { wireCanvasContextMenu } from './canvas-context-menu.js';" in events
assert "wireCanvasContextMenu(canvasArea, { signal });" in events
module = (ROOT / "static/js/editor/canvas-context-menu.js").read_text()
assert "export function buildCanvasMenuItems(" in module
assert "export function openCanvasContextMenu(" in module
assert "window.geEditorControl?.invoke(" in module
assert "canvas.addEventListener('contextmenu'" in module
def test_context_menu_styles_exist():
css = (ROOT / "static/css/13-image-editor.css").read_text()
assert ".ge-canvas-menu {" in css
assert ".ge-canvas-menu-item" in css
assert ".ge-canvas-menu-sub" in css
+3 -2
View File
@@ -97,8 +97,9 @@ def test_hand_navigation_is_wired_to_toolbar_keyboard_and_offline_graph():
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
service_worker = (ROOT / "static/sw.js").read_text(encoding="utf-8")
assert "{ id: 'hand', label: 'Hand'" in toolbar
assert "t.key = TOOL_SHORTCUTS[t.id]" in toolbar
assert "hand: { label: 'Hand'" in toolbar
assert "const key = TOOL_SHORTCUTS[id]" in toolbar
assert "toolKeyMap[key.toLowerCase()] = id" in toolbar
assert "hand: 'H'" in shortcuts
assert "e.code === 'Space'" in keyboard
assert "setTemporaryPan?.(true)" in keyboard
@@ -0,0 +1,61 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "static/js/editor/clipboard-and-drop.js"
def test_editor_paste_owns_clipboard_images_while_editor_open():
"""Pasting an image with the editor open must not fall through to chat.
The editor wires a window-capture ``paste`` listener. The global chat
attach listener in ``static/app.js`` also listens on ``window`` (bubble)
and stages any ``item.kind === 'file'`` clipboard entry into the chatbox.
If the editor's listener bailed whenever the event target was *any*
input/textarea/``[role=dialog]``, then a paste whose focus sat on the chat
composer behind the editor modal went to the chatbox and never reached the
canvas. The guard must only defer to editable fields that live INSIDE the
editor container — and it must NOT include ``[role=dialog]``, because the
editor itself sits inside the gallery modal's ``role=dialog`` content.
"""
src = SRC.read_text(encoding="utf-8")
assert "wireClipboardAndDrop" in src
# The bail-out must be scoped to targets inside the editor, and must not
# throw for a paste whose target is not a Node (e.g. dispatched at window).
assert "const insideEditor = target instanceof Node && !!container && container.contains(target);" in src
assert (
"if (insideEditor && (target.isContentEditable || "
"target.closest?.('input, textarea, select'))) return;"
) in src
# ``[role="dialog"]`` must NOT appear in the paste guard (the editor lives
# inside the gallery modal's role=dialog modal-content).
assert "target.closest?.('input, textarea, select, [role=\"dialog\"]')" not in src
# Capture-phase interception must preventDefault + stop the chat listener.
assert "e.stopImmediatePropagation();" in src
assert "e.preventDefault();" in src
def test_editor_ctrl_v_pastes_the_internal_selection_clipboard():
"""Ctrl/Cmd+V must not shadow a newer OS-clipboard image.
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
# 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 "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
+140
View File
@@ -0,0 +1,140 @@
"""Color Lookup .cube import/export (work-order 3-5).
The engine (static/js/editor/fx/cube-lut.js) is pure and node-runnable, so the
parse/apply/generate maths is exercised for real. The rest is a static contract
over the wiring (pixel-pass, adj-popup, galleryEditor ops, layer-helpers, sw).
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CUBE = ROOT / "static/js/editor/fx/cube-lut.js"
PIXEL = ROOT / "static/js/editor/fx/pixel-pass.js"
POPUP = ROOT / "static/js/editor/fx/adj-popup.js"
HELPERS = ROOT / "static/js/editor/layer-helpers.js"
GE = ROOT / "static/js/galleryEditor.js"
SW = ROOT / "static/sw.js"
SWAP_ROWS = "\n".join([
"0 0 0", "0 1 0", "1 0 0", "1 1 0", "0 0 1", "0 1 1", "1 0 1", "1 1 1",
])
CUBE_TEXT = (
'TITLE "swap"\n'
"LUT_3D_SIZE 2\n"
"DOMAIN_MIN 0 0 0\n"
"DOMAIN_MAX 1 1 1\n"
f"{SWAP_ROWS}\n"
)
def _node(expr: str):
return subprocess.run(
["node", "--input-type=module", "-e", expr],
cwd=ROOT, capture_output=True, text=True,
)
def test_parse_cube_lut_numeric():
expr = (
"const m = await import('./static/js/editor/fx/cube-lut.js');"
f"const lut = m.parseCubeLut({json.dumps(CUBE_TEXT)});"
"console.log(JSON.stringify({size:lut.size, len:lut.data.length,"
" dmin:Array.from(lut.domainMin), dmax:Array.from(lut.domainMax)}));"
)
result = _node(expr)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout.strip().splitlines()[-1])
assert data["size"] == 2
assert data["len"] == 2 * 2 * 2 * 3
assert data["dmin"] == [0, 0, 0]
assert data["dmax"] == [1, 1, 1]
def test_apply_cube_identity_and_swap_numeric():
expr = (
"const m = await import('./static/js/editor/fx/cube-lut.js');"
"const id = m.identityLut(2);"
"const a = new Uint8ClampedArray([0,0,0,255, 128,64,32,255, 255,255,255,255, 10,200,90,255]);"
"const before = Array.from(a);"
"m.applyCubeLut(a, 4, 1, id, { strength: 1 });"
"const identSame = JSON.stringify(Array.from(a)) === JSON.stringify(before);"
"const swap = m.parseCubeLut(" + json.dumps(CUBE_TEXT) + ");"
"const b = new Uint8ClampedArray([200,10,90,255]);"
"m.applyCubeLut(b, 1, 1, swap, { strength: 1 });"
"const s2 = new Uint8ClampedArray([200,10,90,255]);"
"m.applyCubeLut(s2, 1, 1, swap, { strength: 0 });"
"console.log(JSON.stringify({identSame, swapped:Array.from(b), zero:Array.from(s2)}));"
)
result = _node(expr)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout.strip().splitlines()[-1])
assert data["identSame"] is True
assert data["swapped"][:3] == [10, 200, 90]
assert data["zero"][:3] == [200, 10, 90] # strength 0 -> unchanged
def test_generate_round_trip_numeric():
expr = (
"const m = await import('./static/js/editor/fx/cube-lut.js');"
"const id = m.identityLut(3);"
"const text = m.generateCubeLut(id);"
"const back = m.parseCubeLut(text);"
"console.log(JSON.stringify({hasTitle:text.includes('LUT_3D_SIZE'),"
" size:back.size, len:back.data.length, len3:m.identityLut(3).data.length}));"
)
result = _node(expr)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout.strip().splitlines()[-1])
assert data["hasTitle"] is True
assert data["size"] == 3
assert data["len"] == 3 ** 3 * 3
assert data["len3"] == 3 ** 3 * 3
def test_engine_is_offline():
src = CUBE.read_text()
assert "fetch(" not in src
assert "XMLHttpRequest" not in src
assert "export function parseCubeLut(" in src
assert "export function applyCubeLut(" in src
assert "export function generateCubeLut(" in src
assert "export function identityLut(" in src
def test_pixel_pass_applies_the_cube():
src = PIXEL.read_text()
assert "import { applyCubeLut } from './cube-lut.js';" in src
assert "if (cube && cube.size && cube.data) {" in src
assert "applyCubeLut(d, w, h, lut, { strength });" in src
assert "new Float64Array(cube.data)" in src
def test_adj_popup_loads_and_exports_and_strength():
src = POPUP.read_text()
assert "import { parseCubeLut, generateCubeLut } from './cube-lut.js';" in src
assert "ge-adj-lut-load" in src
assert 'accept=".cube,text/plain"' in src
assert "parseCubeLut(text)" in src
assert "p.cube = {" in src
assert "ge-adj-lut-export" in src
assert "generateCubeLut({" in src
assert "a.download = 'look.cube'" in src
assert "sliderRow('strength', 'Strength', 0, 100" in src
# slider + revert wiring
assert "p.strength = raw; display = raw + '%';" in src
assert "'photo-filter', 'color-lookup'" in src
def test_defaults_ops_and_sw():
assert "return { preset: 'none', strength: 100 };" in HELPERS.read_text(encoding="utf-8")
ge = GE.read_text()
assert "import { parseCubeLut as _parseCubeLut, generateCubeLut as _generateCubeLut } from './editor/fx/cube-lut.js';" in ge
assert "'colorLookup.loadCube':" in ge
assert "'colorLookup.exportCube':" in ge
assert "_parseCubeLut(text)" in ge
assert "_generateCubeLut({" in ge
sw = SW.read_text()
assert re.search(r"odysseus-v\d+-pr40-editor-precache", sw)
assert "/static/js/editor/fx/cube-lut.js" in sw

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