feat(editor): harden photo editor security and interactions

This commit is contained in:
Alexandre Teixeira
2026-10-28 17:37:10 +00:00
parent 0b8176b11e
commit c237a1cb9c
68 changed files with 3630 additions and 426 deletions
+7 -2
View File
@@ -1,7 +1,7 @@
# Browser End-to-End Tests
The photo editor release gate launches an isolated, authentication-disabled
Odysseus server on port `7013` with a temporary SQLite database.
The photo editor release gate launches an isolated Odysseus server on port
`7013` with a temporary SQLite database and disposable authenticated account.
```bash
npm install
@@ -13,3 +13,8 @@ Set `PHOTO_EDITOR_E2E_PORT` to use another port. Failure screenshots and traces
are written to `test-results/photo-editor/`. The server uses the repository
`.venv` automatically; set `ODYSSEUS_TEST_PYTHON` to another Python executable
when using a different environment layout.
The disposable account is created in the temporary data directory and exercises
owner-scoped draft and notification routes without using an existing account.
`PHOTO_EDITOR_E2E_AUTH=0` selects the legacy unauthenticated configuration for
investigating it; offline-upgrade coverage requires the authenticated mode.
@@ -1,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()) {
+11 -3
View File
@@ -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');
+3 -5
View File
@@ -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');
+1 -2
View File
@@ -10,7 +10,6 @@ test('group drag reorders the complete subtree with undo and server persistence'
const originalOrder = current.layers.map(layer => layer.id);
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page);
const looseId = current.activeLayerId;
expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
@@ -66,7 +65,7 @@ test('mobile layer drag handle supports long-press reorder', async ({ browser, b
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder');
await page.locator('.ge-layers-header').click();
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
const before = await editorState(page);
// State is bottom-to-top; the panel is top-to-bottom.
+60 -37
View File
@@ -1,40 +1,22 @@
const { expect } = require('@playwright/test');
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
let lastError = null;
for (let pageAttempt = 0; pageAttempt < 3; pageAttempt += 1) {
try {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.evaluate(async () => {
window.__photoEditorImport = async path => {
let lastImportError = null;
for (let attempt = 0; attempt < 4; attempt += 1) {
const url = attempt === 0 ? path : `${path}?e2e_retry=${attempt}-${Date.now()}`;
try { return await import(url); } catch (error) { lastImportError = error; }
await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1)));
}
throw lastImportError;
};
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}, { size, name });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
lastError = null;
break;
} catch (error) {
lastError = error;
await page.goto('about:blank');
await page.waitForTimeout(250 * (pageAttempt + 1));
}
}
if (lastError) throw lastError;
await page.goto('/', { waitUntil: 'domcontentloaded' });
// Workflow tests exercise the canvas, not the optional setup overlay.
await page.evaluate(() => localStorage.setItem('ge-preflight-dismissed', '1'));
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.evaluate(async () => {
window.__photoEditorImport = path => import(path);
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}, { size, name });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.layers.length;
@@ -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();
});
+13 -1
View File
@@ -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']]) {
+50 -3
View File
@@ -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();
}
});
+15 -4
View File
@@ -10,10 +10,12 @@ const defaultPython = process.platform === 'win32'
? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe')
: path.join(repositoryRoot, '.venv', 'bin', 'python');
const python = process.env.ODYSSEUS_TEST_PYTHON || (fs.existsSync(defaultPython) ? defaultPython : 'python');
const tmpBase = fs.existsSync('/var/tmp') ? '/var/tmp' : os.tmpdir();
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-photo-editor-e2e-'));
|| fs.mkdtempSync(path.join(tmpBase, 'odysseus-photo-editor-e2e-'));
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH
|| path.join(dataDirectory, 'app.db');
const authenticated = process.env.PHOTO_EDITOR_E2E_AUTH !== '0';
process.env.PHOTO_EDITOR_E2E_DB_PATH = databasePath;
process.env.PHOTO_EDITOR_E2E_DATA_DIR = dataDirectory;
@@ -22,6 +24,9 @@ if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
}
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
module.exports = defineConfig({
testDir: path.join(__dirname, 'photo-editor'),
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
@@ -29,18 +34,24 @@ module.exports = defineConfig({
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
reporter: isCI ? 'line' : 'line',
globalSetup: require.resolve('./setup.js'),
globalTeardown: require.resolve('./teardown.js'),
use: {
baseURL,
browserName,
headless: true,
launchOptions: {
args: ['--disable-dev-shm-usage', '--no-sandbox'],
},
viewport: { width: 1440, height: 960 },
extraHTTPHeaders: { 'Accept-Encoding': 'identity' },
serviceWorkers: 'block',
...(authenticated ? { storageState: path.join(dataDirectory, 'browser-auth.json') } : {}),
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
// Unconditional CI trace suppression: Playwright traces are NEVER recorded in CI.
// Local developer tracing requires explicit PLAYWRIGHT_ENABLE_TRACE=1.
trace: traceOptIn ? 'retain-on-failure' : 'off',
},
webServer: {
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
@@ -50,7 +61,7 @@ module.exports = defineConfig({
reuseExistingServer: false,
env: {
...process.env,
AUTH_ENABLED: 'false',
AUTH_ENABLED: authenticated ? 'true' : 'false',
DATABASE_URL: `sqlite:///${databasePath}`,
ODYSSEUS_DATA_DIR: dataDirectory,
ODYSSEUS_STARTUP_WARMUPS: '0',
+16
View File
@@ -1,4 +1,6 @@
const fs = require('node:fs');
const path = require('node:path');
const { request } = require('@playwright/test');
module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
@@ -7,4 +9,18 @@ module.exports = async () => {
throw new Error('Photo editor E2E data paths were not configured');
}
fs.mkdirSync(dataDirectory, { recursive: true });
if (process.env.PHOTO_EDITOR_E2E_AUTH !== '0') {
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
const client = await request.newContext({ baseURL: `http://127.0.0.1:${port}` });
try {
const credentials = { username: 'editor-e2e', password: 'disposable-editor-e2e-password' };
const setup = await client.post('/api/auth/setup', { data: credentials });
if (!setup.ok()) throw new Error(`Disposable editor account setup failed: ${setup.status()}`);
const login = await client.post('/api/auth/login', { data: credentials });
if (!login.ok()) throw new Error(`Disposable editor account login failed: ${login.status()}`);
await client.storageState({ path: path.join(dataDirectory, 'browser-auth.json') });
} finally {
await client.dispose();
}
}
};
+81 -2
View File
@@ -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);
}
}
}
};