mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-31 02:02:22 +01:00
merge(dev): integrate latest dev into fix/ios-safe-area-postmerge-hardening
This commit is contained in:
@@ -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(); }
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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); });
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Vendored
+17
@@ -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
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
@@ -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 "
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"}},
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
@@ -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
|
||||
@@ -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">×</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 "−" 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)
|
||||
@@ -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
|
||||
@@ -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") == []
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user