mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-29 17:22: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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user