mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-30 01:32:25 +01:00
feat(editor): harden photo editor security and interactions
This commit is contained in:
+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,5 +1,5 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
const { chooseFilterAction, selectTool } = require('./helpers.js');
|
||||
const {
|
||||
dragOnCanvas,
|
||||
encodedImagePixelDigest,
|
||||
@@ -11,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();
|
||||
@@ -252,8 +252,7 @@ test('retained Gaussian Blur survives flattening and draft reopen', async ({ pag
|
||||
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();
|
||||
@@ -282,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');
|
||||
@@ -308,16 +306,14 @@ test('retained overlay and shadow effects keep editable metadata', async ({ page
|
||||
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();
|
||||
@@ -348,8 +344,7 @@ test('retained Stroke uses layer alpha and persists its controls', async ({ page
|
||||
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
|
||||
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()) {
|
||||
|
||||
@@ -54,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');
|
||||
@@ -78,14 +79,17 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
|
||||
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']) {
|
||||
@@ -178,7 +182,11 @@ test('smudge carries nearby pixels along a stroke and undoes atomically', async
|
||||
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
|
||||
});
|
||||
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,6 +1,6 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = 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');
|
||||
@@ -75,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 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);
|
||||
@@ -113,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 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 {
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
const { chooseFilterAction, selectTool } = require('./helpers.js');
|
||||
const fs = require('node:fs');
|
||||
const {
|
||||
compareExportPixels,
|
||||
@@ -38,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-controls:visible .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();
|
||||
@@ -84,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-controls:visible .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');
|
||||
|
||||
@@ -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;
|
||||
@@ -368,12 +350,51 @@ async function selectTool(page, toolId) {
|
||||
await face.first().click();
|
||||
return;
|
||||
}
|
||||
// Flyout members are pre-rendered (hidden until the slot opens). Dispatch a
|
||||
// click directly so the helper does not depend on hover/flyout visibility.
|
||||
await page.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`).first().dispatchEvent('click');
|
||||
// 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,
|
||||
@@ -381,7 +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);
|
||||
});
|
||||
|
||||
@@ -3,8 +3,6 @@ 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');
|
||||
@@ -32,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');
|
||||
@@ -111,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');
|
||||
@@ -193,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);
|
||||
@@ -278,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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -121,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);
|
||||
@@ -207,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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -38,3 +38,45 @@ test('mobile footer docks flush between the canvas and the layers sheet', async
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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,8 +1,7 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
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 selectTool(page, 'text');
|
||||
const canvas = await page.locator('.ge-main-canvas').boundingBox();
|
||||
@@ -11,19 +10,39 @@ test('paint tools offer rasterization, cancel preserves text, Enter accepts and
|
||||
await text.fill('Keep editable');
|
||||
await text.press('Control+Enter');
|
||||
const retained = (await editorState(page)).layers.find(layer => layer.kind === 'text');
|
||||
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 expect(page.locator('#styled-confirm-ok')).toHaveText('Rasterize');
|
||||
await page.keyboard.press('Escape');
|
||||
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 selectTool(page, 'brush');
|
||||
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 selectTool(page, 'eraser');
|
||||
await expect(page.locator('#styled-confirm-ok')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.press('Control+z');
|
||||
expect(await kind()).toBe('text');
|
||||
});
|
||||
|
||||
@@ -106,12 +106,15 @@ 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();
|
||||
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 }) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { selectTool } = require('./helpers.js');
|
||||
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = 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 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);
|
||||
@@ -14,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');
|
||||
@@ -47,8 +50,13 @@ test('text paragraph controls remain usable in a narrow phone viewport', async (
|
||||
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 }) => {
|
||||
@@ -71,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');
|
||||
@@ -96,6 +105,7 @@ 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 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) => {
|
||||
@@ -110,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);
|
||||
@@ -143,6 +154,7 @@ test('shape gradients retain added stops through editing and reopen', async ({ p
|
||||
await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E');
|
||||
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']]) {
|
||||
|
||||
@@ -223,15 +223,23 @@ test('mobile touch input can grab every transform handle', async ({ browser, bro
|
||||
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,
|
||||
@@ -366,3 +374,42 @@ test('transform handle keeps tracking when the pointer crosses the right panel',
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+81
-2
@@ -1,7 +1,86 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execSync } = require('node:child_process');
|
||||
|
||||
module.exports = async () => {
|
||||
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
|
||||
if (!dataDirectory) return;
|
||||
fs.rmSync(dataDirectory, { recursive: true, force: true });
|
||||
if (dataDirectory) {
|
||||
try {
|
||||
fs.rmSync(dataDirectory, { 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 resultsDir = path.join(__dirname, '..', '..', 'test-results');
|
||||
const reportDir = path.join(__dirname, '..', '..', 'playwright-report');
|
||||
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 = path.join(__dirname, '..', '..', 'scripts', 'sanitize_trace_artifacts.py');
|
||||
let sanitizerSucceeded = false;
|
||||
try {
|
||||
execSync(`${JSON.stringify(python)} ${JSON.stringify(sanitizerScript)} ${JSON.stringify(resultsDir)}`, {
|
||||
stdio: 'ignore',
|
||||
timeout: 30_000,
|
||||
});
|
||||
sanitizerSucceeded = true;
|
||||
} catch (_) {
|
||||
sanitizerSucceeded = false;
|
||||
}
|
||||
|
||||
if (!sanitizerSucceeded) {
|
||||
// Fail closed: purge all remaining zip or tmp archives
|
||||
purgeZipAndTmp(resultsDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1043,8 +1043,8 @@ def test_editor_agent_model_override_is_first_candidate(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(eam, "local_bases", lambda: ["http://local1"])
|
||||
monkeypatch.setattr(eam, "local_model", lambda: "LMODEL")
|
||||
cands = eam._candidates(None, "http://ovr::OVERRIDE")
|
||||
assert cands[0] == {"base": "http://ovr", "model": "OVERRIDE", "headers": None,
|
||||
cands = eam._candidates(None, "http://local1::OVERRIDE")
|
||||
assert cands[0] == {"base": "http://local1", "model": "OVERRIDE", "headers": None,
|
||||
"source": "override"}
|
||||
assert any(c["source"] == "local" and c["base"] == "http://local1" for c in cands)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
@@ -27,10 +28,22 @@ def test_action_names_and_unknown_steps_are_literal_text():
|
||||
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,
|
||||
};
|
||||
@@ -39,12 +52,21 @@ def test_action_names_and_unknown_steps_are_literal_text():
|
||||
} 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,
|
||||
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,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)
|
||||
@@ -102,23 +102,12 @@ def test_bucket_tool_is_registered_and_wired():
|
||||
assert "bucketTolerance: 32" in state
|
||||
|
||||
|
||||
def test_bucket_rasterizes_a_pixel_layer_in_one_click():
|
||||
# A text/shape/placed layer used to bounce the user through a rasterize
|
||||
# dialog that could then sit behind the canvas, so the bucket looked broken.
|
||||
# Now the layer is rasterized silently (undoable) and filled in one click.
|
||||
def test_paint_tools_require_confirmation_before_rasterizing():
|
||||
editor = (ROOT / "static/js/galleryEditor.js").read_text()
|
||||
assert "function _rasterizeLayerNow(layer) {" in editor
|
||||
assert "async function _confirmRasterizeLayer(layer) {" in editor
|
||||
assert '_saveState(`Rasterize "${layer.name}"`);' in editor
|
||||
assert "if (!_rasterizeLayerNow(layer)) { void _offerRasterizeForTool(); return; }" in editor
|
||||
assert "if (!_pixelPaintTools.has(toolId)) void _offerRasterizeForTool(toolId);" in editor
|
||||
|
||||
|
||||
def test_all_paint_tools_rasterize_silently_instead_of_prompting():
|
||||
# Clone/heal/brush/etc. hit the same rasterize prompt on text/shape/placed
|
||||
# layers, so every paint tool now rasterizes silently and continues.
|
||||
editor = (ROOT / "static/js/galleryEditor.js").read_text()
|
||||
assert "// fall through — `layer` is a pixel layer now, so the tool runs below." in editor
|
||||
assert "if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive &&" in editor
|
||||
assert "void _confirmRasterizeLayer(layer);" in editor
|
||||
|
||||
|
||||
def test_smudge_and_dodge_icons_are_distinct():
|
||||
|
||||
@@ -20,8 +20,9 @@ def test_editor_paste_owns_clipboard_images_while_editor_open():
|
||||
"""
|
||||
src = SRC.read_text(encoding="utf-8")
|
||||
assert "wireClipboardAndDrop" in src
|
||||
# The bail-out must be scoped to targets inside the editor.
|
||||
assert "const insideEditor = target && container && container.contains(target);" 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;"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
def test_shared_picker_exports_editor_color_contract():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
script = r'''
|
||||
import assert from 'node:assert/strict';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const store = new Map();
|
||||
globalThis.localStorage = { getItem: key => store.get(key), setItem: (key, value) => store.set(key, value) };
|
||||
globalThis.HTMLInputElement = class {};
|
||||
const picker = await import(pathToFileURL(process.argv[1]));
|
||||
store.set('odysseus-recent-colors', JSON.stringify(['<img src=x onerror=alert(1)>', 123, '#ABCDEF']));
|
||||
assert.deepEqual(picker.getRecentColors(), ['#ABCDEF']);
|
||||
picker.recordRecentColor('#abcdef');
|
||||
picker.recordRecentColor('#ff0000');
|
||||
assert.deepEqual(picker.getRecentColors(), ['#ff0000', '#abcdef']);
|
||||
const suggestions = picker.suggestColorsFor('#ff0000');
|
||||
assert.equal(suggestions.length, 5);
|
||||
assert.equal(suggestions[0].hex, '#00ffff');
|
||||
assert.ok(suggestions.every(s => /^#[0-9a-f]{6}$/.test(s.hex)));
|
||||
store.set('odysseus-recent-colors', '{}');
|
||||
assert.deepEqual(picker.getRecentColors(), []);
|
||||
'''
|
||||
result = subprocess.run(['node', '--input-type=module', '-e', script,
|
||||
str(root / 'static/js/colorPicker.js')],
|
||||
cwd=root, capture_output=True, text=True, timeout=30)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -18,21 +18,10 @@ CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
MOBILE_BLOCK = """@media (max-width: 700px) {
|
||||
.ge-editor-footer {
|
||||
display: none !important;
|
||||
}
|
||||
/* The Quick Edit card lifts over the layers sheet / keyboard; keep it above
|
||||
every other editor asset (sheet headers, tool rail, popups) while lifted. */
|
||||
.ge-ai-command {
|
||||
z-index: 90;
|
||||
}
|
||||
}"""
|
||||
|
||||
|
||||
def test_mobile_footer_is_removed():
|
||||
assert MOBILE_BLOCK in CSS
|
||||
assert ".ge-editor-footer {\n display: none !important;" in CSS
|
||||
def test_mobile_footer_is_docked_flush_and_accessible():
|
||||
assert ".ge-editor-footer" in CSS
|
||||
assert "overflow-x: auto;" in CSS
|
||||
assert ".ge-editor-footer {\n display: none !important;" not in CSS
|
||||
|
||||
|
||||
def test_quick_edit_stacks_above_other_assets():
|
||||
|
||||
@@ -19,8 +19,11 @@ def test_phone_menu_bar_compaction_rules_present():
|
||||
assert ".ge-topbar-left .ge-stacked-btn {" in CSS
|
||||
assert "font-size: 11px;" in CSS
|
||||
assert "padding: 0 6px !important;" in CSS
|
||||
assert ".ge-topbar.ge-topbar-compact #ge-draft-status {" in CSS
|
||||
assert "display: none;" in CSS
|
||||
# Compaction drops the Save action label only. The draft status is the
|
||||
# button's role="status" live region (its icon is aria-hidden), so it must
|
||||
# stay rendered; interaction-contract.spec.js checks it at 390px.
|
||||
assert ".ge-topbar.ge-topbar-compact #ge-save-menu-btn .ge-save-action-label { display: none !important; }" in CSS
|
||||
assert not re.search(r"ge-topbar-compact\s+#ge-draft-status\s*\{[^}]*display:\s*none", CSS)
|
||||
|
||||
|
||||
def test_sw_cache_name_is_version_agnostic_pattern():
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from starlette.requests import Request
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from routes.editor_agent_routes import setup_editor_agent_routes
|
||||
from routes.gallery.gallery_routes import _visible_image_endpoint_for_base
|
||||
|
||||
|
||||
def create_fake_request(user: str | None = None, is_admin: bool = False):
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/api/editor/image-models",
|
||||
"headers": [],
|
||||
"app": MagicMock(),
|
||||
}
|
||||
req = Request(scope)
|
||||
# Mock auth_manager on app state
|
||||
auth_mgr = MagicMock()
|
||||
auth_mgr.is_admin.return_value = is_admin
|
||||
auth_mgr.is_configured = True
|
||||
req.app.state.auth_manager = auth_mgr
|
||||
return req
|
||||
|
||||
|
||||
class FakeEndpoint:
|
||||
def __init__(self, id, name, base_url, model_type="image", is_enabled=True, owner=None, cached_models=None):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.base_url = base_url
|
||||
self.model_type = model_type
|
||||
self.is_enabled = is_enabled
|
||||
self.owner = owner
|
||||
self.cached_models = cached_models or json.dumps(["qwen-image-edit", "qwen-generate"])
|
||||
self.pinned_models = None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_image_models_non_admin_redacts_urls():
|
||||
router = setup_editor_agent_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/editor/image-models")
|
||||
|
||||
fake_ep = FakeEndpoint(
|
||||
id=42,
|
||||
name="private-gpu-cluster",
|
||||
base_url="http://10.0.0.50:8000/v1",
|
||||
owner="testuser",
|
||||
)
|
||||
|
||||
fake_db = MagicMock()
|
||||
fake_query = MagicMock()
|
||||
fake_db.query.return_value = fake_query
|
||||
fake_query.filter.return_value = fake_query
|
||||
fake_query.all.return_value = [fake_ep]
|
||||
|
||||
req = create_fake_request(user="testuser", is_admin=False)
|
||||
|
||||
with patch("routes.editor_agent_routes._user", return_value="testuser"), \
|
||||
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False), \
|
||||
patch("core.database.SessionLocal", return_value=fake_db), \
|
||||
patch("src.auth_helpers.owner_filter", side_effect=lambda q, *a, **k: q), \
|
||||
patch("src.endpoint_resolver._endpoint_enabled_models", return_value=["qwen-image-edit"]):
|
||||
resp = await route.endpoint(req)
|
||||
|
||||
data = json.loads(resp.body.decode())
|
||||
assert "models" in data
|
||||
assert len(data["models"]) == 1
|
||||
m = data["models"][0]
|
||||
assert m["id"] == "qwen-image-edit"
|
||||
assert m["endpoint_id"] == 42
|
||||
# Non-admin must NOT see internal URL!
|
||||
assert "base_url" not in m
|
||||
assert "10.0.0.50" not in json.dumps(data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_image_models_admin_includes_urls():
|
||||
router = setup_editor_agent_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/editor/image-models")
|
||||
|
||||
fake_ep = FakeEndpoint(
|
||||
id=42,
|
||||
name="private-gpu-cluster",
|
||||
base_url="http://10.0.0.50:8000/v1",
|
||||
owner="adminuser",
|
||||
)
|
||||
|
||||
fake_db = MagicMock()
|
||||
fake_query = MagicMock()
|
||||
fake_db.query.return_value = fake_query
|
||||
fake_query.filter.return_value = fake_query
|
||||
fake_query.all.return_value = [fake_ep]
|
||||
|
||||
req = create_fake_request(user="adminuser", is_admin=True)
|
||||
|
||||
with patch("routes.editor_agent_routes._user", return_value="adminuser"), \
|
||||
patch("src.tool_security.owner_is_admin_or_single_user", return_value=True), \
|
||||
patch("core.database.SessionLocal", return_value=fake_db), \
|
||||
patch("src.auth_helpers.owner_filter", side_effect=lambda q, *a, **k: q), \
|
||||
patch("src.endpoint_resolver._endpoint_enabled_models", return_value=["qwen-image-edit"]):
|
||||
resp = await route.endpoint(req)
|
||||
|
||||
data = json.loads(resp.body.decode())
|
||||
assert len(data["models"]) == 1
|
||||
m = data["models"][0]
|
||||
assert m["endpoint_id"] == 42
|
||||
# Admin is allowed to see the endpoint base URL
|
||||
assert m.get("base_url") == "http://10.0.0.50:8000/v1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_agent_models_non_admin_redacts_urls_and_probe():
|
||||
router = setup_editor_agent_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/editor/agent-models")
|
||||
|
||||
raw_models = [
|
||||
{"id": "Qwen/Qwen3.5-9B", "label": "Qwen3.5-9B · local", "source": "local", "vision": True, "base_url": "http://100.69.120.65:8097/v1"},
|
||||
{"id": "hosted-default", "label": "hosted-default", "source": "hosted", "vision": None, "base_url": "https://api.hosted.ai/v1"},
|
||||
]
|
||||
|
||||
req = create_fake_request(user="regularuser", is_admin=False)
|
||||
|
||||
with patch("routes.editor_agent_routes._user", return_value="regularuser"), \
|
||||
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False), \
|
||||
patch("src.editor_agent_model.list_editor_agent_models", return_value=raw_models):
|
||||
resp = await route.endpoint(req)
|
||||
|
||||
data = json.loads(resp.body.decode())
|
||||
assert data.get("probe") == {}
|
||||
assert len(data["models"]) == 2
|
||||
for m in data["models"]:
|
||||
assert "base_url" not in m
|
||||
assert "100.69.120.65" not in json.dumps(data)
|
||||
|
||||
|
||||
def test_visible_image_endpoint_for_base_supports_id_and_url():
|
||||
fake_ep = FakeEndpoint(
|
||||
id=7,
|
||||
name="my-diffusion",
|
||||
base_url="http://internal-host:8080/v1",
|
||||
owner="alice",
|
||||
)
|
||||
|
||||
fake_db = MagicMock()
|
||||
fake_query = MagicMock()
|
||||
fake_db.query.return_value = fake_query
|
||||
fake_query.filter.return_value = fake_query
|
||||
fake_query.all.return_value = [fake_ep]
|
||||
|
||||
with patch("src.auth_helpers.owner_filter", side_effect=lambda q, *a, **k: q):
|
||||
# 1. Lookup by numeric string ID
|
||||
res_id = _visible_image_endpoint_for_base(fake_db, "7", "alice", "qwen-image-edit")
|
||||
assert res_id == fake_ep
|
||||
|
||||
# 2. Lookup by URL
|
||||
res_url = _visible_image_endpoint_for_base(fake_db, "http://internal-host:8080", "alice", "qwen-image-edit")
|
||||
assert res_url == fake_ep
|
||||
|
||||
# 3. Lookup non-existent
|
||||
res_none = _visible_image_endpoint_for_base(fake_db, "999", "alice", "qwen-image-edit")
|
||||
assert res_none is None
|
||||
@@ -0,0 +1,458 @@
|
||||
import asyncio
|
||||
import http.server
|
||||
import json
|
||||
import threading
|
||||
from typing import List, Tuple
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from routes.editor_agent_routes import setup_editor_agent_routes
|
||||
import src.editor_agent_model as eam
|
||||
|
||||
|
||||
class TrackingRequestHandler(http.server.BaseHTTPRequestHandler):
|
||||
received_requests: List[Tuple[str, str, dict]] = []
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("content-length", 0))
|
||||
body = self.rfile.read(length) if length > 0 else b""
|
||||
try:
|
||||
body_json = json.loads(body.decode("utf-8"))
|
||||
except Exception:
|
||||
body_json = {}
|
||||
TrackingRequestHandler.received_requests.append((self.command, self.path, body_json))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"choices": [{"message": {"content": "ATTACKER_PLAN"}}]}')
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_listener():
|
||||
TrackingRequestHandler.received_requests = []
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), TrackingRequestHandler)
|
||||
port = server.server_address[1]
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield f"http://127.0.0.1:{port}/v1"
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def create_mock_request(path: str, body: dict, user: str = "testuser", is_admin: bool = False):
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": path,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"app": MagicMock(),
|
||||
}
|
||||
req = Request(scope)
|
||||
auth_mgr = MagicMock()
|
||||
auth_mgr.is_admin.return_value = is_admin
|
||||
auth_mgr.is_configured = True
|
||||
req.app.state.auth_manager = auth_mgr
|
||||
|
||||
async def mock_json():
|
||||
return body
|
||||
|
||||
req.json = mock_json
|
||||
return req
|
||||
|
||||
|
||||
SYNTHETIC_SCREENSHOT = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
|
||||
|
||||
def test_ssrf_arbitrary_listener_receives_zero_requests(local_listener, monkeypatch):
|
||||
"""Prove attacker-supplied agent_model URL receives 0 requests on planner routes."""
|
||||
router = setup_editor_agent_routes()
|
||||
plan_route = next(r for r in router.routes if r.path == "/api/editor/plan")
|
||||
agent_route = next(r for r in router.routes if r.path == "/api/editor/agent")
|
||||
|
||||
async def _fake_wait(fut, timeout=None, **kwargs):
|
||||
return {"ok": True, "result": {"width": 100, "height": 100}}
|
||||
|
||||
monkeypatch.setattr("routes.editor_agent_routes.asyncio.wait_for", _fake_wait)
|
||||
|
||||
payload = {
|
||||
"prompt": "make it brighter",
|
||||
"screenshot": SYNTHETIC_SCREENSHOT,
|
||||
"agent_model": f"{local_listener}::attacker-model",
|
||||
}
|
||||
|
||||
# Test /api/editor/plan
|
||||
req_plan = create_mock_request("/api/editor/plan", payload, user="alice", is_admin=False)
|
||||
with patch("routes.editor_agent_routes._user", return_value="alice"), \
|
||||
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False):
|
||||
resp_plan = asyncio.run(plan_route.endpoint(req_plan))
|
||||
assert resp_plan.status_code == 200
|
||||
assert len(TrackingRequestHandler.received_requests) == 0
|
||||
|
||||
# Test /api/editor/agent
|
||||
req_agent = create_mock_request("/api/editor/agent", payload, user="alice", is_admin=False)
|
||||
with patch("routes.editor_agent_routes._user", return_value="alice"), \
|
||||
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False):
|
||||
resp_agent = asyncio.run(agent_route.endpoint(req_agent))
|
||||
assert resp_agent.status_code == 200
|
||||
assert len(TrackingRequestHandler.received_requests) == 0
|
||||
|
||||
|
||||
def test_ssrf_admin_cannot_target_arbitrary_url(local_listener):
|
||||
"""Admin access must not grant arbitrary outbound network destinations."""
|
||||
payload = {
|
||||
"prompt": "make it brighter",
|
||||
"screenshot": SYNTHETIC_SCREENSHOT,
|
||||
"agent_model": f"{local_listener}::attacker-model",
|
||||
}
|
||||
router = setup_editor_agent_routes()
|
||||
plan_route = next(r for r in router.routes if r.path == "/api/editor/plan")
|
||||
|
||||
req = create_mock_request("/api/editor/plan", payload, user="admin_user", is_admin=True)
|
||||
with patch("routes.editor_agent_routes._user", return_value="admin_user"), \
|
||||
patch("src.tool_security.owner_is_admin_or_single_user", return_value=True):
|
||||
resp = asyncio.run(plan_route.endpoint(req))
|
||||
assert resp.status_code == 200
|
||||
assert len(TrackingRequestHandler.received_requests) == 0
|
||||
|
||||
|
||||
def test_candidates_cloud_metadata_rejected():
|
||||
"""Cloud metadata destination is rejected fail-closed."""
|
||||
cands = eam._candidates("user1", "http://169.254.169.254/latest/meta-data::cloud-meta")
|
||||
assert cands == []
|
||||
|
||||
|
||||
def test_candidates_foreign_owned_endpoint_rejected():
|
||||
"""Foreign-owned endpoint ID fails closed for a different user."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id="ep-foreign-test",
|
||||
name="Foreign Cluster",
|
||||
base_url="http://192.168.1.100:8000/v1",
|
||||
owner="victim_user",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["victim-model"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
# Attacker user query
|
||||
cands = eam._candidates("attacker_user", "ep-foreign-test::victim-model")
|
||||
assert cands == []
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-foreign-test").delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidates_disabled_endpoint_rejected():
|
||||
"""Disabled endpoint ID fails closed."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id="ep-disabled-test",
|
||||
name="Disabled Cluster",
|
||||
base_url="http://192.168.1.100:8000/v1",
|
||||
owner="user1",
|
||||
is_enabled=False,
|
||||
cached_models=json.dumps(["some-model"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
cands = eam._candidates("user1", "ep-disabled-test::some-model")
|
||||
assert cands == []
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-disabled-test").delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidates_nonexistent_endpoint_rejected():
|
||||
"""Nonexistent endpoint ID fails closed."""
|
||||
cands = eam._candidates("user1", "ep-nonexistent-9999::model1")
|
||||
assert cands == []
|
||||
|
||||
|
||||
def test_candidates_forged_model_id_rejected():
|
||||
"""Forged model ID on an authorized endpoint fails closed."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id="ep-auth-test",
|
||||
name="User Cluster",
|
||||
base_url="http://127.0.0.1:9099/v1",
|
||||
owner="user1",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["real-model"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
# Legitimate model passes
|
||||
valid_cands = eam._candidates("user1", "ep-auth-test::real-model")
|
||||
assert len(valid_cands) >= 1
|
||||
assert valid_cands[0]["model"] == "real-model"
|
||||
|
||||
# Forged model fails closed
|
||||
forged_cands = eam._candidates("user1", "ep-auth-test::forged-attacker-model")
|
||||
assert forged_cands == []
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-auth-test").delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidates_duplicate_model_ids_scoped_by_endpoint():
|
||||
"""Duplicate model IDs across different endpoints do not collide or cross providers."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep1 = ModelEndpoint(
|
||||
id="ep-provider-1",
|
||||
name="Provider 1",
|
||||
base_url="http://127.0.0.1:9001/v1",
|
||||
owner="user1",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["shared-model-name"]),
|
||||
)
|
||||
ep2 = ModelEndpoint(
|
||||
id="ep-provider-2",
|
||||
name="Provider 2",
|
||||
base_url="http://127.0.0.1:9002/v1",
|
||||
owner="user1",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["shared-model-name"]),
|
||||
)
|
||||
db.merge(ep1)
|
||||
db.merge(ep2)
|
||||
db.commit()
|
||||
|
||||
cands1 = eam._candidates("user1", "ep-provider-1::shared-model-name")
|
||||
assert len(cands1) >= 1
|
||||
assert cands1[0]["endpoint_id"] == "ep-provider-1"
|
||||
assert "9001" in cands1[0]["base"]
|
||||
|
||||
cands2 = eam._candidates("user1", "ep-provider-2::shared-model-name")
|
||||
assert len(cands2) >= 1
|
||||
assert cands2[0]["endpoint_id"] == "ep-provider-2"
|
||||
assert "9002" in cands2[0]["base"]
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id.in_(["ep-provider-1", "ep-provider-2"])).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidates_authorized_shared_endpoint():
|
||||
"""Shared endpoint (owner is NULL) is accessible to authenticated user."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id="ep-shared-test",
|
||||
name="Shared Org Cluster",
|
||||
base_url="http://127.0.0.1:9005/v1",
|
||||
owner=None,
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["org-planner-model"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
cands = eam._candidates("any_user", "ep-shared-test::org-planner-model")
|
||||
assert len(cands) >= 1
|
||||
assert cands[0]["endpoint_id"] == "ep-shared-test"
|
||||
assert cands[0]["model"] == "org-planner-model"
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-shared-test").delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidates_legacy_raw_url_override_verified_against_registered_db():
|
||||
"""Legacy raw URL format resolves only when matching a registered DB endpoint."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id="ep-legacy-test",
|
||||
name="Legacy Registered",
|
||||
base_url="http://registered-server.internal:8000/v1",
|
||||
owner="user1",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["legacy-model"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
# Matching registered row resolves
|
||||
cands = eam._candidates("user1", "http://registered-server.internal:8000/v1::legacy-model")
|
||||
assert len(cands) >= 1
|
||||
assert cands[0]["endpoint_id"] == "ep-legacy-test"
|
||||
|
||||
# Unregistered raw URL fails closed
|
||||
unreg_cands = eam._candidates("user1", "http://unregistered-server.internal:8000/v1::legacy-model")
|
||||
assert unreg_cands == []
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-legacy-test").delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidates_unauthorized_override_does_not_silently_fallback(monkeypatch):
|
||||
"""Unauthorized override fails closed rather than silently falling back to defaults."""
|
||||
monkeypatch.setattr(eam, "local_bases", lambda: ["http://default-local:8000/v1"])
|
||||
monkeypatch.setattr(eam, "local_model", lambda: "default-local-model")
|
||||
|
||||
# Override is unauthorized/attacker -> MUST NOT fallback to default-local
|
||||
cands = eam._candidates("user1", "http://attacker-listener.com:9999/v1::attacker-model")
|
||||
assert cands == []
|
||||
|
||||
|
||||
def test_editor_routes_unauthenticated_returns_401_when_auth_configured():
|
||||
"""Unauthenticated requests must return 401 when authentication is enabled."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
router = setup_editor_agent_routes()
|
||||
app.include_router(router)
|
||||
|
||||
auth_mgr = MagicMock()
|
||||
auth_mgr.is_configured = True
|
||||
app.state.auth_manager = auth_mgr
|
||||
|
||||
with patch("src.auth_helpers._auth_disabled", return_value=False), \
|
||||
patch("src.auth_helpers.get_current_user", return_value=None):
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp_plan = client.post("/api/editor/plan", json={"prompt": "draw cat"})
|
||||
assert resp_plan.status_code == 401
|
||||
assert resp_plan.json().get("detail") == "Not authenticated"
|
||||
|
||||
resp_agent = client.post("/api/editor/agent", json={"prompt": "draw cat"})
|
||||
assert resp_agent.status_code == 401
|
||||
assert resp_agent.json().get("detail") == "Not authenticated"
|
||||
|
||||
|
||||
def test_candidates_empty_owner_cannot_query_foreign_private_endpoint():
|
||||
"""Empty owner in multi-user mode cannot access private endpoints of other users."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id="ep-private-victim",
|
||||
name="Victim Private Cluster",
|
||||
base_url="http://192.168.1.105:8000/v1",
|
||||
owner="victim_user",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["victim-secret-model"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
with patch("src.auth_helpers._auth_disabled", return_value=False):
|
||||
# None owner fails closed
|
||||
cands_none = eam._candidates(None, "ep-private-victim::victim-secret-model")
|
||||
assert cands_none == []
|
||||
|
||||
# Empty string owner fails closed
|
||||
cands_empty = eam._candidates("", "ep-private-victim::victim-secret-model")
|
||||
assert cands_empty == []
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-private-victim").delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_editor_routes_api_token_without_chat_scope_returns_403():
|
||||
"""Bearer API token missing 'chat' scope is rejected with 403."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
async def inject_token_middleware(request, call_next):
|
||||
request.state.api_token = True
|
||||
request.state.api_token_scopes = ["image", "read"] # Missing "chat"
|
||||
request.state.api_token_owner = "alice"
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(setup_editor_agent_routes())
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
resp_plan = client.post("/api/editor/plan", json={"prompt": "make it pop"})
|
||||
assert resp_plan.status_code == 403
|
||||
assert "chat" in resp_plan.json().get("detail", "")
|
||||
|
||||
resp_agent = client.post("/api/editor/agent", json={"prompt": "make it pop"})
|
||||
assert resp_agent.status_code == 403
|
||||
assert "chat" in resp_agent.json().get("detail", "")
|
||||
|
||||
|
||||
def test_editor_routes_api_token_with_chat_scope_resolves_owner_endpoint():
|
||||
"""Bearer API token with 'chat' scope resolves the token owner's endpoints."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
alice_ep = ModelEndpoint(
|
||||
id="ep-alice-token",
|
||||
name="Alice Cluster",
|
||||
base_url="http://127.0.0.1:9098/v1",
|
||||
owner="alice",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["alice-model"]),
|
||||
)
|
||||
bob_ep = ModelEndpoint(
|
||||
id="ep-bob-token",
|
||||
name="Bob Cluster",
|
||||
base_url="http://127.0.0.1:9099/v1",
|
||||
owner="bob",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["bob-model"]),
|
||||
)
|
||||
db.merge(alice_ep)
|
||||
db.merge(bob_ep)
|
||||
db.commit()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
async def inject_token_middleware(request, call_next):
|
||||
request.state.api_token = True
|
||||
request.state.api_token_scopes = ["chat"]
|
||||
request.state.api_token_owner = "alice"
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(setup_editor_agent_routes())
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
# Alice's token querying Alice's endpoint resolves
|
||||
cands = eam._candidates("alice", "ep-alice-token::alice-model")
|
||||
assert len(cands) == 1
|
||||
assert cands[0]["endpoint_id"] == "ep-alice-token"
|
||||
|
||||
# Alice's token querying Bob's endpoint fails closed
|
||||
cands_bob = eam._candidates("alice", "ep-bob-token::bob-model")
|
||||
assert cands_bob == []
|
||||
|
||||
# Route-level check: calling /api/editor/plan with Alice's token targeting Bob's endpoint
|
||||
# falls back and does NOT route to Bob
|
||||
resp = client.post(
|
||||
"/api/editor/plan",
|
||||
json={"prompt": "enhance", "agent_model": "ep-bob-token::bob-model"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Should report fallback or default, never bob's endpoint
|
||||
assert resp.json().get("agent_model") != "ep-bob-token::bob-model"
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.id.in_(["ep-alice-token", "ep-bob-token"])
|
||||
).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
@@ -388,11 +388,13 @@ def test_quick_edit_has_an_agent_model_picker():
|
||||
assert "Recommended, not required" in AI_SETTINGS
|
||||
assert "function _getSelectedQuickEditAgentModel()" in EDITOR
|
||||
assert "getAgentModel: () => _getSelectedQuickEditAgentModel()," in EDITOR
|
||||
# Local Qwen3.5-9B VL is the default; server default is the alternative.
|
||||
# Local/hosted agent models are dynamically discovered via /api/editor/agent-models;
|
||||
# private Tailscale/CGNAT IPs must not be hardcoded in frontend source.
|
||||
assert "export function resolveQuickEditAgentModel()" in models
|
||||
assert "export const QUICK_EDIT_AGENT_OPTIONS" in models
|
||||
assert "Qwen3.5-9B VL · local" in models
|
||||
assert "http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B" in models
|
||||
assert "Server default (hosted planner)" in models
|
||||
assert "100.69.120.65" not in models
|
||||
assert "/api/editor/agent-models" in models
|
||||
assert "localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value)" in models
|
||||
assert "localStorage.getItem('ge-ai-command-agent-model')" in models
|
||||
# The request carries the agent model.
|
||||
|
||||
@@ -40,7 +40,10 @@ def test_flyout_triangle_css_is_visible():
|
||||
def test_brush_preset_rows_have_a_tip_icon():
|
||||
assert "const soft = typeof preset.softness === 'number' ? preset.softness : 100;" in TOOLBAR
|
||||
assert "const presetIcon = preset.pixel" in TOOLBAR
|
||||
assert '<span class="ge-tool-icon">${presetIcon}</span><span class="ge-tool-flyout-label">${preset.name}</span>' in TOOLBAR
|
||||
assert "icon.className = 'ge-tool-icon';" in TOOLBAR
|
||||
assert 'icon.innerHTML = presetIcon;' in TOOLBAR
|
||||
assert "label.className = 'ge-tool-flyout-label';" in TOOLBAR
|
||||
assert 'label.textContent = preset.name;' in TOOLBAR
|
||||
|
||||
|
||||
def test_flyout_is_clamped_inside_the_viewport():
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import asyncio
|
||||
import http.server
|
||||
import json
|
||||
import threading
|
||||
from typing import List, Tuple
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from routes.gallery.gallery_routes import setup_gallery_routes, _visible_image_endpoint_for_base
|
||||
|
||||
|
||||
class TrackingPollHandler(http.server.BaseHTTPRequestHandler):
|
||||
requests_received: List[str] = []
|
||||
|
||||
def do_GET(self):
|
||||
TrackingPollHandler.requests_received.append(self.path)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"available": true, "progress": 50}')
|
||||
|
||||
def do_POST(self):
|
||||
TrackingPollHandler.requests_received.append(self.path)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"images": ["data:image/png;base64,AAAA"]}')
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider_listener():
|
||||
TrackingPollHandler.requests_received = []
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), TrackingPollHandler)
|
||||
port = server.server_address[1]
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def create_gallery_request(method: str, path: str, query_params: dict = None, body: dict = None, user: str = "alice"):
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": method,
|
||||
"path": path,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"query_string": "&".join(f"{k}={v}" for k, v in (query_params or {}).items()).encode("utf-8"),
|
||||
"app": MagicMock(),
|
||||
}
|
||||
req = Request(scope)
|
||||
|
||||
async def mock_json():
|
||||
return body or {}
|
||||
|
||||
req.json = mock_json
|
||||
return req
|
||||
|
||||
|
||||
def test_edit_progress_disabled_endpoint_causes_no_outbound_polling(mock_provider_listener):
|
||||
"""GET /api/image/edit-progress with disabled endpoint causes zero outbound requests."""
|
||||
db = SessionLocal()
|
||||
ep_id = "ep-test-disabled-progress"
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name="Disabled Endpoint",
|
||||
base_url=mock_provider_listener,
|
||||
model_type="image",
|
||||
is_enabled=False,
|
||||
owner="alice",
|
||||
cached_models=json.dumps(["test-diff"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
router = setup_gallery_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
|
||||
|
||||
req = create_gallery_request(
|
||||
"GET",
|
||||
"/api/image/edit-progress",
|
||||
query_params={"request_id": "req-123", "endpoint_id": ep_id},
|
||||
user="alice",
|
||||
)
|
||||
|
||||
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
|
||||
resp = asyncio.run(route.endpoint(req))
|
||||
|
||||
assert isinstance(resp, dict)
|
||||
assert resp["available"] is False
|
||||
assert resp.get("reason") == "endpoint not found or disabled"
|
||||
# Zero outbound polling to the provider!
|
||||
assert len(TrackingPollHandler.requests_received) == 0
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_edit_progress_foreign_owned_endpoint_causes_no_outbound_polling(mock_provider_listener):
|
||||
"""GET /api/image/edit-progress with foreign-owned endpoint causes zero outbound requests."""
|
||||
db = SessionLocal()
|
||||
ep_id = "ep-test-foreign-progress"
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name="Victim Endpoint",
|
||||
base_url=mock_provider_listener,
|
||||
model_type="image",
|
||||
is_enabled=True,
|
||||
owner="victim_user",
|
||||
cached_models=json.dumps(["test-diff"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
router = setup_gallery_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
|
||||
|
||||
# Attacker user 'bob' requests victim's endpoint
|
||||
req = create_gallery_request(
|
||||
"GET",
|
||||
"/api/image/edit-progress",
|
||||
query_params={"request_id": "req-456", "endpoint_id": ep_id},
|
||||
user="bob",
|
||||
)
|
||||
|
||||
with patch("routes.gallery.gallery_routes.require_privilege", return_value="bob"):
|
||||
resp = asyncio.run(route.endpoint(req))
|
||||
|
||||
assert isinstance(resp, dict)
|
||||
assert resp["available"] is False
|
||||
assert resp.get("reason") == "endpoint not found or disabled"
|
||||
assert len(TrackingPollHandler.requests_received) == 0
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_edit_progress_rejects_chat_only_endpoint_causes_no_polling(mock_provider_listener):
|
||||
"""GET /api/image/edit-progress rejects chat-only endpoint ID with zero outbound requests."""
|
||||
db = SessionLocal()
|
||||
ep_id = "ep-test-chat-progress"
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name="Chat Endpoint",
|
||||
base_url=mock_provider_listener,
|
||||
model_type="llm", # chat only!
|
||||
is_enabled=True,
|
||||
owner="alice",
|
||||
cached_models=json.dumps(["llama-3-8b"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
router = setup_gallery_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
|
||||
|
||||
req = create_gallery_request(
|
||||
"GET",
|
||||
"/api/image/edit-progress",
|
||||
query_params={"request_id": "req-789", "endpoint_id": ep_id},
|
||||
user="alice",
|
||||
)
|
||||
|
||||
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
|
||||
resp = asyncio.run(route.endpoint(req))
|
||||
|
||||
assert isinstance(resp, dict)
|
||||
assert resp["available"] is False
|
||||
assert resp.get("reason") == "endpoint not found or disabled"
|
||||
# Zero outbound polling to the chat provider!
|
||||
assert len(TrackingPollHandler.requests_received) == 0
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_edit_progress_blocks_private_ip_when_policy_enabled(mock_provider_listener, monkeypatch):
|
||||
"""GET /api/image/edit-progress enforces IMAGE_BLOCK_PRIVATE_IPS policy."""
|
||||
monkeypatch.setenv("IMAGE_BLOCK_PRIVATE_IPS", "true")
|
||||
db = SessionLocal()
|
||||
ep_id = "ep-test-private-progress"
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name="Private Image Endpoint",
|
||||
base_url=mock_provider_listener, # 127.0.0.1 loopback
|
||||
model_type="image",
|
||||
is_enabled=True,
|
||||
owner="alice",
|
||||
cached_models=json.dumps(["sd-v1"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
router = setup_gallery_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
|
||||
|
||||
req = create_gallery_request(
|
||||
"GET",
|
||||
"/api/image/edit-progress",
|
||||
query_params={"request_id": "req-priv", "endpoint_id": ep_id},
|
||||
user="alice",
|
||||
)
|
||||
|
||||
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
|
||||
resp = asyncio.run(route.endpoint(req))
|
||||
|
||||
assert isinstance(resp, dict)
|
||||
assert resp["available"] is False
|
||||
assert "rejected" in str(resp.get("reason"))
|
||||
# Zero outbound polling because private IP was blocked by policy!
|
||||
assert len(TrackingPollHandler.requests_received) == 0
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_harmonize_rejects_chat_only_endpoint(mock_provider_listener):
|
||||
"""POST /api/image/harmonize rejects a chat-only endpoint that has no image capability."""
|
||||
db = SessionLocal()
|
||||
ep_id = "ep-test-chat-only"
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name="Chat Only Llama",
|
||||
base_url=mock_provider_listener,
|
||||
model_type="llm", # chat only
|
||||
is_enabled=True,
|
||||
owner="alice",
|
||||
cached_models=json.dumps(["meta-llama/Llama-3-8B-Instruct", "mistral-7b"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
router = setup_gallery_routes()
|
||||
route = next(r for r in router.routes if r.path == "/api/image/harmonize")
|
||||
|
||||
payload = {
|
||||
"image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
"_endpoint": ep_id,
|
||||
"_model": "meta-llama/Llama-3-8B-Instruct",
|
||||
}
|
||||
req = create_gallery_request("POST", "/api/image/harmonize", body=payload, user="alice")
|
||||
|
||||
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(route.endpoint(req))
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Choose a registered image endpoint" in exc_info.value.detail
|
||||
|
||||
# Zero outbound requests to chat provider
|
||||
assert len(TrackingPollHandler.requests_received) == 0
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_harmonize_accepts_eligible_image_model_on_mixed_endpoint(mock_provider_listener):
|
||||
"""POST /api/image/harmonize accepts a mixed endpoint when an image-capable model is selected."""
|
||||
db = SessionLocal()
|
||||
ep_id = "ep-test-mixed-endpoint"
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name="Mixed Gateway",
|
||||
base_url=mock_provider_listener,
|
||||
model_type="llm", # mixed gateway
|
||||
is_enabled=True,
|
||||
owner="alice",
|
||||
cached_models=json.dumps(["gpt-4o", "qwen-image-edit"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
# Testing capability helper directly with image model
|
||||
ep_resolved = _visible_image_endpoint_for_base(db, ep_id, "alice", "qwen-image-edit")
|
||||
assert ep_resolved is not None
|
||||
assert ep_resolved.id == ep_id
|
||||
|
||||
# Testing with chat model fails
|
||||
ep_chat = _visible_image_endpoint_for_base(db, ep_id, "alice", "gpt-4o")
|
||||
assert ep_chat is None
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_harmonize_preserves_legitimate_image_capable_endpoint(mock_provider_listener):
|
||||
"""Legitimate image endpoint (model_type == 'image') resolves and works properly."""
|
||||
db = SessionLocal()
|
||||
ep_id = "ep-test-sd-diffusion"
|
||||
try:
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name="Forge SD WebUI",
|
||||
base_url=mock_provider_listener,
|
||||
model_type="image",
|
||||
is_enabled=True,
|
||||
owner="alice",
|
||||
cached_models=json.dumps(["sd_xl_base_1.0.safetensors"]),
|
||||
)
|
||||
db.merge(ep)
|
||||
db.commit()
|
||||
|
||||
ep_resolved = _visible_image_endpoint_for_base(db, ep_id, "alice", "sd_xl_base_1.0.safetensors")
|
||||
assert ep_resolved is not None
|
||||
assert ep_resolved.id == ep_id
|
||||
finally:
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
|
||||
db.commit()
|
||||
db.close()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Service-worker cache policy for the /static/ scope.
|
||||
|
||||
The worker is registered at /static/sw.js without a scope, so it only controls
|
||||
/static/ clients. The SPA root is outside that scope, which means an ordinary
|
||||
page load never populates the worker's cache: anything the lazy editor graph
|
||||
imports must be in the precache lists, shared modules included. The real
|
||||
browser upgrade/offline proof lives in
|
||||
tests/e2e/photo-editor/offline-upgrade.spec.js.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC = ROOT / "static"
|
||||
JS_DIR = STATIC / "js"
|
||||
SW = (STATIC / "sw.js").read_text(encoding="utf-8")
|
||||
|
||||
_STATIC_IMPORT = re.compile(
|
||||
r"""(?:^|\n)\s*(?:import\s+(?:[^;'"()]*?\s+from\s+)?|export\s+(?:\*|\{[^}]*\})\s+from\s+)['"]([^'"]+)['"]"""
|
||||
)
|
||||
|
||||
|
||||
def _list(name: str) -> set[str]:
|
||||
block = re.search(rf"const {name} = \[(.*?)\];", SW, re.S)
|
||||
assert block, f"{name} not found in static/sw.js"
|
||||
return set(re.findall(r"'([^']+)'", block.group(1)))
|
||||
|
||||
|
||||
def _fetch_handler() -> str:
|
||||
start = SW.index("self.addEventListener('fetch'")
|
||||
return SW[start:]
|
||||
|
||||
|
||||
def _lazy_editor_graph() -> set[str]:
|
||||
panels = (JS_DIR / "panels.js").read_text(encoding="utf-8")
|
||||
entry = re.search(r"import\(['\"]\./(galleryEditor\.js(?:\?[^'\"]+)?)['\"]\)", panels)
|
||||
assert entry, "panels.js must lazily import galleryEditor.js"
|
||||
seen: set[str] = set()
|
||||
stack = [entry.group(1)]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if current in seen:
|
||||
continue
|
||||
seen.add(current)
|
||||
source = (JS_DIR / current.split("?")[0]).read_text(encoding="utf-8")
|
||||
for spec in _STATIC_IMPORT.findall(source):
|
||||
if not spec.startswith("."):
|
||||
continue
|
||||
path, _, query = spec.partition("?")
|
||||
target = os.path.normpath(os.path.join(os.path.dirname(current.split("?")[0]), path))
|
||||
stack.append(f"{target}?{query}" if query else target)
|
||||
return seen
|
||||
|
||||
|
||||
def test_every_module_in_the_lazy_editor_graph_is_precached_with_its_exact_url():
|
||||
graph = _lazy_editor_graph()
|
||||
assert len(graph) > 100, len(graph)
|
||||
precached = _list("PRECACHE") | _list("PANEL_PRECACHE")
|
||||
missing = sorted(f"/static/js/{module}" for module in graph if f"/static/js/{module}" not in precached)
|
||||
assert not missing, (
|
||||
"the editor imports these modules, but the /static/-scoped worker never "
|
||||
f"sees the SPA page load that would cache them: {missing}"
|
||||
)
|
||||
|
||||
|
||||
def test_worker_keeps_the_static_scope():
|
||||
index = (STATIC / "index.html").read_text(encoding="utf-8")
|
||||
registrations = re.findall(r"serviceWorker\.register\(([^)]*)\)", index)
|
||||
assert registrations == ["'/static/sw.js?v=20260916autoscroll1'"]
|
||||
assert "Service-Worker-Allowed" not in (ROOT / "app.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_fetch_handler_ignores_cross_origin_api_and_non_get_requests():
|
||||
handler = _fetch_handler()
|
||||
api_guard = handler.index("url.pathname.startsWith('/api/') || e.request.method !== 'GET') return;")
|
||||
origin_guard = handler.index("if (url.origin !== self.location.origin) return;")
|
||||
first_respond = handler.index("e.respondWith(")
|
||||
assert api_guard < origin_guard < first_respond
|
||||
|
||||
|
||||
def test_offline_fallback_ignores_only_an_origin_vary_inside_the_current_cache():
|
||||
helper = SW[SW.index("async function cachedStaticFile(request)"):SW.index("self.addEventListener('fetch'")]
|
||||
assert "const cache = await caches.open(CACHE_NAME);" in helper
|
||||
assert "await cache.match(request);" in helper
|
||||
assert "cache.matchAll(request, { ignoreVary: true })" in helper
|
||||
assert ".every(name => name === 'origin')" in helper
|
||||
assert "|| Response.error();" in helper
|
||||
# No other lookup may relax Vary or search caches left by other versions.
|
||||
assert SW.count("ignoreVary") == 1
|
||||
assert "caches.match(" not in _fetch_handler()
|
||||
assert ".catch(() => cachedStaticFile(e.request))" in _fetch_handler()
|
||||
|
||||
|
||||
def test_activation_removes_every_other_cache_version():
|
||||
assert re.search(r"const CACHE_NAME = 'odysseus-v\d+-[a-z0-9-]+';", SW)
|
||||
activate = SW[SW.index("self.addEventListener('activate'"):SW.index("async function cachedStaticFile")]
|
||||
assert "keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))" in activate
|
||||
assert "self.clients.claim()" in activate
|
||||
@@ -0,0 +1,244 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
import pytest
|
||||
|
||||
from scripts.sanitize_trace_artifacts import (
|
||||
sanitize_structured_obj,
|
||||
sanitize_jsonl_content,
|
||||
sanitize_url,
|
||||
sanitize_body_text,
|
||||
sanitize_trace_archive,
|
||||
sanitize_directory,
|
||||
)
|
||||
|
||||
|
||||
def test_sanitize_headers_and_inverted_keys():
|
||||
"""Inverted key order and non-Bearer schemes are safely redacted."""
|
||||
# Standard order
|
||||
h1 = {"name": "Authorization", "value": "Bearer my_super_secret_jwt_12345"}
|
||||
assert sanitize_structured_obj(h1)["value"] == "[REDACTED]"
|
||||
|
||||
# Inverted order {"value": ..., "name": ...}
|
||||
h2 = {"value": "Digest username=admin, nonce=abc, response=secret123", "name": "Authorization"}
|
||||
res2 = sanitize_structured_obj(h2)
|
||||
assert res2["value"] == "[REDACTED]"
|
||||
|
||||
# ApiKey scheme
|
||||
h3 = {"name": "Authorization", "value": "ApiKey secret_api_key_xyz"}
|
||||
assert sanitize_structured_obj(h3)["value"] == "[REDACTED]"
|
||||
|
||||
# Custom token header
|
||||
h4 = {"name": "X-Auth-Token", "value": "auth_token_999"}
|
||||
assert sanitize_structured_obj(h4)["value"] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_sanitize_cookies_arbitrary_names():
|
||||
"""Arbitrary cookie names and values are completely redacted."""
|
||||
cookie_entry = {
|
||||
"cookies": [
|
||||
{"name": "odysseus_session", "value": "secret_sess_abc"},
|
||||
{"name": "remember_me_custom", "value": "remember_secret_val"},
|
||||
{"name": "sso_auth_id", "value": "sso_uuid_12345"},
|
||||
]
|
||||
}
|
||||
sanitized = sanitize_structured_obj(cookie_entry)
|
||||
for c in sanitized["cookies"]:
|
||||
assert c["value"] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_sanitize_dom_snapshots_and_nested_action_params():
|
||||
"""DOM snapshot trees and deeply nested action params are redacted."""
|
||||
# 1. Action with nested options before value
|
||||
action_record = {
|
||||
"type": "action",
|
||||
"name": "fill",
|
||||
"params": {
|
||||
"selector": "#login-password",
|
||||
"options": {"force": True, "timeout": 5000},
|
||||
"value": "escaped\"quote\"password123!",
|
||||
},
|
||||
}
|
||||
res_action = sanitize_structured_obj(action_record)
|
||||
assert res_action["params"]["value"] == "[REDACTED]"
|
||||
|
||||
# 2. DOM snapshot input element
|
||||
dom_record = {
|
||||
"nodeName": "INPUT",
|
||||
"attributes": {
|
||||
"type": "password",
|
||||
"name": "user_password",
|
||||
"value": "super_secret_in_dom",
|
||||
},
|
||||
}
|
||||
res_dom = sanitize_structured_obj(dom_record)
|
||||
assert res_dom["attributes"]["value"] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_sanitize_url_tokens_and_authority():
|
||||
"""URL query parameters and userinfo credentials are scrubbed."""
|
||||
# Authority credentials
|
||||
url1 = "https://admin:my_secret_pw@example.com/api/v1/resource"
|
||||
assert sanitize_url(url1) == "https://admin:[REDACTED]@example.com/api/v1/resource"
|
||||
|
||||
# Query tokens
|
||||
url2 = "https://example.com/oauth/callback?code=auth_code_secret&state=xyz&token=access_token_123"
|
||||
sanitized2 = sanitize_url(url2)
|
||||
assert "auth_code_secret" not in sanitized2
|
||||
assert "access_token_123" not in sanitized2
|
||||
assert "code=%5BREDACTED%5D" in sanitized2 or "code=[REDACTED]" in sanitized2
|
||||
assert "token=%5BREDACTED%5D" in sanitized2 or "token=[REDACTED]" in sanitized2
|
||||
assert "state=xyz" in sanitized2
|
||||
|
||||
|
||||
def test_sanitize_request_bodies():
|
||||
"""JSON and urlencoded form request bodies are scrubbed."""
|
||||
# JSON body
|
||||
json_body = '{"username": "admin", "password": "superSecretPassword123!", "api_key": "sk-123456789"}'
|
||||
s_json = sanitize_body_text(json_body)
|
||||
assert "superSecretPassword123!" not in s_json
|
||||
assert "sk-123456789" not in s_json
|
||||
assert '"password": "[REDACTED]"' in s_json
|
||||
assert '"api_key": "[REDACTED]"' in s_json
|
||||
assert '"username": "admin"' in s_json
|
||||
|
||||
# Form-encoded body
|
||||
form_body = "username=alice&password=mypassword999&token=abcde&action=login"
|
||||
s_form = sanitize_body_text(form_body)
|
||||
assert "mypassword999" not in s_form
|
||||
assert "abcde" not in s_form
|
||||
assert "password=%5BREDACTED%5D" in s_form or "password=[REDACTED]" in s_form
|
||||
assert "token=%5BREDACTED%5D" in s_form or "token=[REDACTED]" in s_form
|
||||
assert "username=alice" in s_form
|
||||
|
||||
|
||||
def test_sanitize_valid_archive(tmp_path):
|
||||
"""Structured archive sanitization cleans HAR, trace, and masks source code without corruption."""
|
||||
zp = str(tmp_path / "valid_trace.zip")
|
||||
with zipfile.ZipFile(zp, "w") as z:
|
||||
# JSON trace record
|
||||
trace_record = {
|
||||
"type": "action",
|
||||
"name": "fill",
|
||||
"params": {"selector": "#password", "value": 'secret"quote"pass'},
|
||||
}
|
||||
z.writestr("0-trace.trace", json.dumps(trace_record).encode() + b"\n")
|
||||
|
||||
# Network record with inverted header keys and URL query
|
||||
net_record = {
|
||||
"type": "request",
|
||||
"request": {
|
||||
"url": "https://localhost:7013/api/auth/login?token=token_abc",
|
||||
"headers": [{"value": "Digest secret_digest", "name": "Authorization"}],
|
||||
"postData": '{"password": "my_request_pw"}',
|
||||
},
|
||||
}
|
||||
z.writestr("0-trace.network", json.dumps(net_record).encode() + b"\n")
|
||||
|
||||
# Embedded test source file
|
||||
z.writestr("resources/src@test_auth.js", b'const password = "admin_password";\n')
|
||||
|
||||
# Binary image screenshot
|
||||
z.writestr("screenshot.png", b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDRdiagnostic")
|
||||
|
||||
ok = sanitize_trace_archive(zp)
|
||||
assert ok is True
|
||||
assert os.path.exists(zp)
|
||||
|
||||
with zipfile.ZipFile(zp, "r") as z:
|
||||
# Verify trace record
|
||||
trace_data = json.loads(z.read("0-trace.trace").decode().strip())
|
||||
assert trace_data["params"]["value"] == "[REDACTED]"
|
||||
|
||||
# Verify network record
|
||||
net_data = json.loads(z.read("0-trace.network").decode().strip())
|
||||
assert "token_abc" not in net_data["request"]["url"]
|
||||
assert net_data["request"]["headers"][0]["value"] == "[REDACTED]"
|
||||
assert "my_request_pw" not in net_data["request"]["postData"]
|
||||
|
||||
# Verify embedded source code was masked safely
|
||||
src_content = z.read("resources/src@test_auth.js").decode()
|
||||
assert "admin_password" not in src_content
|
||||
assert "[SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]" in src_content
|
||||
assert "const password: [REDACTED]" not in src_content # No syntax error!
|
||||
|
||||
# Verify screenshot is unmodified
|
||||
png = z.read("screenshot.png")
|
||||
assert png == b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDRdiagnostic"
|
||||
|
||||
|
||||
def test_fail_closed_malformed_zip(tmp_path):
|
||||
zp = str(tmp_path / "corrupt.zip")
|
||||
with open(zp, "wb") as f:
|
||||
f.write(b"this is completely invalid zip data PK\x03\x04 corrupt")
|
||||
|
||||
ok = sanitize_trace_archive(zp)
|
||||
assert ok is False
|
||||
# Fail-closed: corrupted zip must be deleted immediately!
|
||||
assert not os.path.exists(zp)
|
||||
|
||||
|
||||
def test_fail_closed_partial_archive(tmp_path):
|
||||
zp = str(tmp_path / "partial.zip")
|
||||
with open(zp, "wb") as f:
|
||||
f.write(b"PK\x03\x04\x14\x00\x00\x00")
|
||||
|
||||
ok = sanitize_trace_archive(zp)
|
||||
assert ok is False
|
||||
assert not os.path.exists(zp)
|
||||
|
||||
|
||||
def test_fail_closed_empty_archive(tmp_path):
|
||||
zp = str(tmp_path / "empty.zip")
|
||||
with open(zp, "wb") as f:
|
||||
pass
|
||||
|
||||
ok = sanitize_trace_archive(zp)
|
||||
assert ok is False
|
||||
assert not os.path.exists(zp)
|
||||
|
||||
|
||||
def test_fail_closed_on_unhandled_exception(tmp_path, monkeypatch):
|
||||
zp = str(tmp_path / "valid.zip")
|
||||
with zipfile.ZipFile(zp, "w") as z:
|
||||
z.writestr("test.trace", b'{"name": "fill"}\n')
|
||||
|
||||
def broken_sanitize(obj):
|
||||
raise RuntimeError("Simulated crash")
|
||||
|
||||
monkeypatch.setattr("scripts.sanitize_trace_artifacts.sanitize_structured_obj", broken_sanitize)
|
||||
|
||||
ok = sanitize_trace_archive(zp)
|
||||
assert ok is False
|
||||
assert not os.path.exists(zp)
|
||||
assert not os.path.exists(zp + ".tmp")
|
||||
|
||||
|
||||
def test_multiple_trace_archives_in_directory(tmp_path):
|
||||
sub1 = tmp_path / "test1"
|
||||
sub2 = tmp_path / "test2"
|
||||
sub1.mkdir()
|
||||
sub2.mkdir()
|
||||
|
||||
z1 = str(sub1 / "trace.zip")
|
||||
z2 = str(sub2 / "trace.zip")
|
||||
|
||||
with zipfile.ZipFile(z1, "w") as z:
|
||||
z.writestr("0.trace", b'{"type": "action", "params": {"value": "secret1"}}\n')
|
||||
with zipfile.ZipFile(z2, "w") as z:
|
||||
z.writestr("0.trace", b'{"type": "action", "params": {"value": "secret2"}}\n')
|
||||
|
||||
count = sanitize_directory(str(tmp_path))
|
||||
assert count == 2
|
||||
|
||||
assert os.path.exists(z1)
|
||||
with zipfile.ZipFile(z1, "r") as z:
|
||||
content = json.loads(z.read("0.trace").decode().strip())
|
||||
assert content["params"]["value"] == "[REDACTED]"
|
||||
|
||||
assert os.path.exists(z2)
|
||||
with zipfile.ZipFile(z2, "r") as z:
|
||||
content = json.loads(z.read("0.trace").decode().strip())
|
||||
assert content["params"]["value"] == "[REDACTED]"
|
||||
Reference in New Issue
Block a user