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

This commit is contained in:
Alexandre Teixeira
2026-10-30 13:25:33 +00:00
583 changed files with 75243 additions and 2164 deletions
+7 -2
View File
@@ -1,7 +1,7 @@
# Browser End-to-End Tests
The photo editor release gate launches an isolated, authentication-disabled
Odysseus server on port `7013` with a temporary SQLite database.
The photo editor release gate launches an isolated Odysseus server on port
`7013` with a temporary SQLite database and disposable authenticated account.
```bash
npm install
@@ -13,3 +13,8 @@ Set `PHOTO_EDITOR_E2E_PORT` to use another port. Failure screenshots and traces
are written to `test-results/photo-editor/`. The server uses the repository
`.venv` automatically; set `ODYSSEUS_TEST_PYTHON` to another Python executable
when using a different environment layout.
The disposable account is created in the temporary data directory and exercises
owner-scoped draft and notification routes without using an existing account.
`PHOTO_EDITOR_E2E_AUTH=0` selects the legacy unauthenticated configuration for
investigating it; offline-upgrade coverage requires the authenticated mode.
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { chooseFilterAction, selectTool } = require('./helpers.js');
const {
dragOnCanvas,
encodedImagePixelDigest,
@@ -10,7 +11,7 @@ const {
} = require('./helpers.js');
async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click();
await page.locator('#ge-add-fx').click();
const menu = page.locator('.ge-add-layer-menu');
await expect(menu).toBeVisible();
await menu.locator(`[data-adjustment-type="${type}"]`).click();
@@ -19,7 +20,7 @@ async function addAdjustment(page, type) {
test('Levels is a retained stack layer with clipping, masks, history, and persistence', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 260 }, 'Adjustment layers E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.18, y: 0.25 }, { x: 0.78, y: 0.7 });
const before = await flattenedPixelDigest(page);
@@ -36,9 +37,9 @@ test('Levels is a retained stack layer with clipping, masks, history, and persis
expect(await flattenedPixelDigest(page)).not.toEqual(before);
const row = page.locator(`.ge-layer-item[data-layer-id="${levels.id}"]`);
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-clip-btn').click();
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).clipped).toBe(true);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === levels.id).masks).toHaveLength(1);
@@ -89,7 +90,7 @@ test('Curves supports editable RGB and channel points with reset and cancel', as
expect(curves.adjustment.params.points.blue).toHaveLength(3);
const row = page.locator(`.ge-layer-item[data-layer-id="${curves.id}"]`);
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-fx-btn').click();
await page.locator('[data-adj-action="reset"]').click();
await page.locator('[data-adj-action="cancel"]').click();
current = await editorState(page);
@@ -134,7 +135,7 @@ test('color adjustment controls retain neutral reusable parameters', async ({ pa
const nextDigest = await flattenedPixelDigest(page);
expect(nextDigest).not.toBe(priorDigest);
if (item.compare) {
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-fx-btn').click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
await page.locator('[data-adj-action="compare"]').click();
expect(await flattenedPixelDigest(page)).toEqual(priorDigest);
@@ -247,12 +248,11 @@ test('all adjustment popups remain usable on a narrow phone viewport', async ({
test('retained Gaussian Blur survives flattening and draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained effects E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
const before = await flattenedPixelDigest(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await chooseFilterAction(page, 'effect-blur-gaussian');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -281,8 +281,7 @@ test('retained Gaussian Blur survives flattening and draft reopen', async ({ pag
test('retained Sharpen keeps editable amount and survives draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained sharpen E2E');
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-preset-crisp-detail"]').click();
await chooseFilterAction(page, 'effect-preset-crisp-detail');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
expect(await page.locator('.ge-filter-row input[data-key="amount"]').inputValue()).toBe('35');
await page.locator('.ge-filter-row input[data-key="amount"]').fill('75');
@@ -301,22 +300,20 @@ test('retained Sharpen keeps editable amount and survives draft reopen', async (
test('retained overlay and shadow effects keep editable metadata', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained color effects E2E');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(canvasBox.x + 24, canvasBox.y + 24);
await page.mouse.down();
await page.mouse.move(canvasBox.x + 150, canvasBox.y + 140);
await page.mouse.up();
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await chooseFilterAction(page, 'effect-color-overlay');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
await page.locator('button[title="Add effect mask from selection"]').click();
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-drop-shadow"]').click();
await chooseFilterAction(page, 'effect-drop-shadow');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="blur"]').fill('18');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -345,10 +342,9 @@ test('retained overlay and shadow effects keep editable metadata', async ({ page
test('retained Stroke uses layer alpha and persists its controls', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-stroke"]').click();
await chooseFilterAction(page, 'effect-stroke');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="width"]').fill('9');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -1,5 +1,9 @@
const { test, expect } = require('@playwright/test');
// These tests borrow /login as a lightweight page. A signed-in session would
// make the login page redirect to the app mid-test, so run them signed out.
test.use({ storageState: { cookies: [], origins: [] } });
async function addAppStyles(page) {
const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs');
for (const url of await stylesheetUrls()) {
+2 -1
View File
@@ -1,9 +1,10 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { openBlankEditor } = require('./helpers.js');
test('brush cursor matches the rendered brush diameter and replaces the crosshair', async ({ page }) => {
await openBlankEditor(page, { width: 400, height: 300 });
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
+32 -16
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
async function canvasPoint(page, xRatio, yRatio) {
@@ -8,18 +9,23 @@ async function canvasPoint(page, xRatio, yRatio) {
test('brush presets and shared stroke controls remain reusable', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
// Spacing / Smoothing / Blend and the pen-pressure toggles now live behind
// the brush caret ("v") in the options strip instead of the retired "...".
await page.locator('#ge-brush-more').click();
await expect(page.locator('#ge-brush-spacing')).toBeVisible();
await page.locator('#ge-brush-spacing').fill('28');
await page.locator('#ge-brush-smoothing').fill('52');
await page.locator('#ge-brush-blend').selectOption('multiply');
await page.locator('.ge-pressure-option').filter({ hasText: 'Opacity' }).locator('.toggle-slider').click();
await expect(page.locator('#ge-pressure-opacity')).toBeChecked();
await page.locator('.ge-pressure-option').filter({ hasText: 'Flow' }).locator('.toggle-slider').click();
await expect(page.locator('#ge-pressure-flow')).not.toBeChecked();
await page.keyboard.press('Escape');
page.once('dialog', dialog => dialog.accept('My Detail Brush'));
await page.locator('#ge-brush-preset-save').click();
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(1);
await page.locator('#ge-brush-more').click();
await page.locator('#ge-brush-spacing').fill('5');
await page.locator('#ge-brush-preset').selectOption({ label: 'My Detail Brush' });
await expect(page.locator('#ge-brush-spacing')).toHaveValue('28');
@@ -31,10 +37,12 @@ test('brush presets and shared stroke controls remain reusable', async ({ page }
test('long sampled strokes stay responsive and undo atomically', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.locator('.ge-size-slider').fill('700');
await page.locator('#ge-brush-more').click();
await page.locator('#ge-brush-spacing').fill('8');
await page.locator('#ge-brush-smoothing').fill('65');
await page.keyboard.press('Escape');
const activeLayerId = (await editorState(page)).activeLayerId;
const thumb = page.locator(`.ge-layer-item[data-layer-id="${activeLayerId}"] .ge-layer-inline-thumb`);
const thumbBefore = await thumb.evaluate(canvas => canvas.toDataURL());
@@ -46,7 +54,8 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page }
await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 240 });
await page.mouse.up();
expect(Date.now() - startedAt).toBeLessThan(5000);
const strokeDuration = Date.now() - startedAt;
expect(strokeDuration).toBeLessThan(5000);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
await expect(thumb).toHaveAttribute('width', '68');
@@ -59,7 +68,7 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page }
test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#33aa55';
input.dispatchEvent(new Event('input', { bubbles: true }));
@@ -67,22 +76,25 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
const sample = await canvasPoint(page, 0.3, 0.3);
await page.mouse.click(sample.x, sample.y);
await page.locator('.ge-tool-btn[data-tool="eyedropper"]').click();
await selectTool(page, 'eyedropper');
await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
await page.locator('#ge-eyedropper-sample').selectOption('composite');
await page.mouse.move(sample.x, sample.y);
// The options strip height differs per tool, which re-centres the canvas a
// few pixels; hover the same image point that was painted.
const painted = await canvasPoint(page, 0.3, 0.3);
await page.mouse.move(painted.x, painted.y);
await expect(page.locator('#ge-eyedropper-live-value')).toHaveText('#33aa55');
await expect(page.locator('#ge-eyedropper-live-rgb')).toHaveText('RGB 51 170 85');
await expect(page.locator('#ge-eyedropper-live-hsl')).toHaveText('HSL 137 54% 43%');
await expect(page.locator('#ge-eyedropper-live-swatch')).not.toHaveClass(/empty/);
await expect(page.locator('#ge-eyedropper-loupe')).toBeVisible();
expect((await page.locator('#ge-eyedropper-loupe').evaluate(canvas => canvas.getContext('2d').getImageData(42, 42, 1, 1).data[3]))).toBe(255);
await page.mouse.click(sample.x, sample.y);
await page.mouse.click(painted.x, painted.y);
await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55');
for (const tool of ['dodge', 'burn']) {
const before = await flattenedPixelDigest(page);
await page.locator(`.ge-tool-btn[data-tool="${tool}"]`).click();
await selectTool(page, tool);
const from = await canvasPoint(page, 0.35, tool === 'dodge' ? 0.45 : 0.6);
const to = await canvasPoint(page, 0.65, tool === 'dodge' ? 0.45 : 0.6);
await page.mouse.move(from.x, from.y);
@@ -95,13 +107,13 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
}
const blemish = await canvasPoint(page, 0.72, 0.3);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#111111';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.mouse.click(blemish.x, blemish.y);
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
await selectTool(page, 'heal');
const beforeHeal = await flattenedPixelDigest(page);
const healTarget = blemish;
await page.mouse.move(healTarget.x, healTarget.y);
@@ -115,7 +127,7 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
test('healing brush can use an optional sampled source', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Sampled healing E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.locator('.ge-size-slider').fill('420');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#d24b62';
@@ -124,7 +136,7 @@ test('healing brush can use an optional sampled source', async ({ page }) => {
const source = await canvasPoint(page, 0.28, 0.5);
await page.mouse.click(source.x, source.y);
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
await selectTool(page, 'heal');
await page.keyboard.down('Alt');
await page.mouse.click(source.x, source.y);
await page.keyboard.up('Alt');
@@ -153,7 +165,7 @@ test('healing brush can use an optional sampled source', async ({ page }) => {
test('smudge carries nearby pixels along a stroke and undoes atomically', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Smudge E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.locator('.ge-size-slider').fill('700');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#d24b62';
@@ -169,8 +181,12 @@ test('smudge carries nearby pixels along a stroke and undoes atomically', async
const y = Math.round(canvas.height * 0.5);
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
});
await page.locator('.ge-tool-btn[data-tool="smudge"]').click();
await selectTool(page, 'smudge');
// Smudge strength lives in the brush-family "More" menu with the other
// per-tool stroke modifiers.
await page.locator('#ge-brush-more').click();
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
await page.keyboard.press('Escape');
await page.mouse.move(source.x, source.y);
await page.mouse.down();
await page.mouse.move(target.x, target.y, { steps: 20 });
+2 -1
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor } = require('./helpers.js');
async function mainCanvasHash(page) {
@@ -26,7 +27,7 @@ test('before compare is visual-only and toggles back to the edited document', as
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
const baselineHash = await mainCanvasHash(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
const editedState = await editorState(page);
const editedHash = await mainCanvasHash(page);
+6 -5
View File
@@ -1,5 +1,6 @@
const fs = require('node:fs');
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const {
compareExportPixels,
dragOnCanvas,
@@ -16,7 +17,7 @@ test('layered document survives edit, mask, transform, crop, reopen, and export'
await openBlankEditor(page);
const initial = await editorState(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.20, y: 0.30 }, { x: 0.72, y: 0.60 });
const painted = await editorState(page);
const paintedLayer = painted.layers.find(layer => layer.name === 'Edit');
@@ -25,11 +26,11 @@ test('layered document survives edit, mask, transform, crop, reopen, and export'
const editItem = page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first();
await editItem.click();
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await editItem.locator('.ge-true-mask-btn').click();
let current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').masks).toHaveLength(1);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-w')).toBeVisible();
await page.locator('#ge-transform-w').fill('560');
await page.locator('#ge-transform-apply').click();
@@ -43,7 +44,7 @@ test('layered document survives edit, mask, transform, crop, reopen, and export'
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').size[0]).toBe(560);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
await expect(page.locator('#ge-text-section')).toBeVisible();
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvasBox.x + canvasBox.width * 0.28, canvasBox.y + canvasBox.height * 0.22);
@@ -52,7 +53,7 @@ test('layered document survives edit, mask, transform, crop, reopen, and export'
await page.locator('#ge-text-size').press('Enter');
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Durable photo edit' })).toBeVisible();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
await dragOnCanvas(page, { x: 0.08, y: 0.10 }, { x: 0.92, y: 0.88 });
await expect(page.locator('.ge-crop-apply-btn')).toBeVisible();
await page.locator('.ge-crop-apply-btn').click();
@@ -1,12 +1,13 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor, settledCanvasBox } = require('./helpers.js');
test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Gesture cancellation');
const canvas = page.locator('.ge-main-canvas');
const box = await canvas.boundingBox();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
await page.mouse.move(box.x + 40, box.y + 35);
await page.mouse.down();
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
@@ -14,7 +15,7 @@ test('tool switching cancels incomplete crop and selection gestures without stal
const { state } = await import('/static/js/editor/state.js');
return { cropping: state.cropping, rect: state.cropRect };
})).toMatchObject({ cropping: true, rect: { w: 150, h: 115 } });
await page.locator('.ge-tool-btn[data-tool="move"]').dispatchEvent('click');
await selectTool(page, 'move');
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { tool: state.tool, cropping: state.cropping, moving: state.cropMoving, rect: state.cropRect };
@@ -22,7 +23,7 @@ test('tool switching cancels incomplete crop and selection gestures without stal
await page.mouse.up();
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
const beforeMove = await editorState(page);
const bounds = beforeMove.selection.bounds;
@@ -33,7 +34,7 @@ test('tool switching cancels incomplete crop and selection gestures without stal
await page.mouse.down();
await page.mouse.move(box.x + bounds.x + bounds.width / 2 + 35, box.y + bounds.y + bounds.height / 2 + 20);
expect((await editorState(page)).selection.bounds).not.toEqual(bounds);
await page.locator('.ge-tool-btn[data-tool="brush"]').dispatchEvent('click');
await selectTool(page, 'brush');
const afterSwitch = await editorState(page);
expect(afterSwitch.selection.bounds).toEqual(bounds);
expect(afterSwitch.undo).toBe(beforeMove.undo);
@@ -49,7 +50,7 @@ test('Escape cancels an active crop without changing the document', async ({ pag
await openBlankEditor(page, { width: 320, height: 240 }, 'Escape crop cancellation');
const canvas = page.locator('.ge-main-canvas');
const box = await canvas.boundingBox();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
await page.mouse.move(box.x + 40, box.y + 35);
await page.mouse.down();
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
@@ -74,13 +75,15 @@ test('touch crop and selection gestures complete through the shared lifecycle',
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch gestures');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
// Picking a tool minimizes the phone Layers sheet and refits the canvas;
// aim at where the canvas settles, as a finger would.
const canvasBox = await settledCanvasBox(page);
await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30);
await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145);
await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145);
@@ -90,7 +93,7 @@ test('touch crop and selection gestures complete through the shared lifecycle',
return state.cropRect;
})).toMatchObject({ w: 155, h: 115 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await touch('touchStart', canvasBox.x + 45, canvasBox.y + 40);
await touch('touchMove', canvasBox.x + 165, canvasBox.y + 125);
await touch('touchEnd', canvasBox.x + 165, canvasBox.y + 125);
@@ -112,18 +115,24 @@ test('touch brush paints and undoes as one stroke', async ({ browser, browserNam
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch brush');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
// Re-tapping the active tool collapses its controls sheet (canvas-first
// phone editing), which also uncovers the Layers sheet grab handle.
await selectTool(page, 'brush');
const canvasBox = await settledCanvasBox(page);
const before = await flattenedPixelDigest(page);
await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5);
await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
await touch('touchEnd', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
// History buttons live in the Layers sheet, which the tool pick minimized.
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
} finally {
@@ -144,7 +153,7 @@ test('touch cancellation rolls back a partial brush stroke', async ({ browser, b
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch cancellation');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
const before = await flattenedPixelDigest(page);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
@@ -168,7 +177,7 @@ test('touch cancellation rolls back a partial brush stroke', async ({ browser, b
test('pen input completes a crop gesture', async ({ page, context, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP pen injection');
await openBlankEditor(page, { width: 320, height: 240 }, 'Pen crop gesture');
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
const box = await page.locator('.ge-main-canvas').boundingBox();
const cdp = await context.newCDPSession(page);
await cdp.send('Input.dispatchMouseEvent', {
@@ -39,7 +39,7 @@ async function downloadFormat(page, format, filename) {
}
async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click();
await page.locator('#ge-add-fx').click();
await expect(page.locator('.ge-add-layer-menu')).toBeVisible();
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${type}"]`).click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
@@ -100,7 +100,7 @@ test('composited correction exports preserve pixels and format metadata', async
await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity');
await seedGradient(page);
await page.locator('#ge-add-layer').click();
await page.locator('#ge-add-fx').click();
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('35');
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
+5 -6
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { chooseFilterAction, selectTool } = require('./helpers.js');
const fs = require('node:fs');
const {
compareExportPixels,
@@ -23,7 +24,7 @@ async function downloadPng(page, filename) {
test('group retained effects edit, toggle, reorder surface, and survive reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 360, height: 240 }, 'Group effects E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 48;
@@ -37,8 +38,7 @@ test('group retained effects edit, toggle, reorder surface, and survive reopen',
const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await chooseFilterAction(page, 'effect-blur-gaussian', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -73,7 +73,7 @@ test('group retained effects edit, toggle, reorder surface, and survive reopen',
test('group retained effects export the visible composite and survive reopen', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Group effects export E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 64;
@@ -83,8 +83,7 @@ test('group retained effects export the visible composite and survive reopen', a
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await chooseFilterAction(page, 'effect-color-overlay', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('40');
+5 -4
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function compositeAlphaAtCenter(page) {
@@ -16,17 +17,17 @@ test('group mask paints, toggles, survives history and reopen, and protects ungr
await page.locator('#ge-group-selected').click();
const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await page.locator('#ge-layer-tools .ge-group-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-group-mask-btn').click();
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
await expect(page.locator('#ge-layer-tools button[title="Delete group masks before ungrouping"]')).toBeDisabled();
await expect(page.locator('.ge-layer-controls:visible button[title="Delete group masks before ungrouping"]')).toBeDisabled();
let current = await editorState(page);
expect(current.groups[0].masks).toHaveLength(1);
expect(current.groups[0].masks[0]).toMatchObject({ mode: 'group', size: [420, 300] });
const whiteMaskHash = current.groups[0].masks[0].pixelHash;
expect(await compositeAlphaAtCenter(page)).toBe(255);
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await selectTool(page, 'eraser');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 90;
@@ -62,7 +63,7 @@ test('group mask paints, toggles, survives history and reopen, and protects ungr
await page.locator('.ge-group-mask-sub-item button[title="Delete group mask"]').click();
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(0);
await groupRow.click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
await page.locator('.ge-layer-controls:visible button[title="Ungroup layers"]').click();
expect((await editorState(page)).groups).toHaveLength(0);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
+1 -2
View File
@@ -10,7 +10,6 @@ test('group drag reorders the complete subtree with undo and server persistence'
const originalOrder = current.layers.map(layer => layer.id);
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page);
const looseId = current.activeLayerId;
expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
@@ -66,7 +65,7 @@ test('mobile layer drag handle supports long-press reorder', async ({ browser, b
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder');
await page.locator('.ge-layers-header').click();
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
const before = await editorState(page);
// State is bottom-to-top; the panel is top-to-bottom.
+69 -34
View File
@@ -1,40 +1,22 @@
const { expect } = require('@playwright/test');
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
let lastError = null;
for (let pageAttempt = 0; pageAttempt < 3; pageAttempt += 1) {
try {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.evaluate(async () => {
window.__photoEditorImport = async path => {
let lastImportError = null;
for (let attempt = 0; attempt < 4; attempt += 1) {
const url = attempt === 0 ? path : `${path}?e2e_retry=${attempt}-${Date.now()}`;
try { return await import(url); } catch (error) { lastImportError = error; }
await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1)));
}
throw lastImportError;
};
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}, { size, name });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
lastError = null;
break;
} catch (error) {
lastError = error;
await page.goto('about:blank');
await page.waitForTimeout(250 * (pageAttempt + 1));
}
}
if (lastError) throw lastError;
await page.goto('/', { waitUntil: 'domcontentloaded' });
// Workflow tests exercise the canvas, not the optional setup overlay.
await page.evaluate(() => localStorage.setItem('ge-preflight-dismissed', '1'));
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.evaluate(async () => {
window.__photoEditorImport = path => import(path);
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}, { size, name });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.layers.length;
@@ -362,7 +344,57 @@ async function compareExportPixels(page, bytes, size) {
}, { base64: bytes.toString('base64'), targetSize: size });
}
async function selectTool(page, toolId) {
const face = page.locator(`.ge-tool-slot-face[data-tool="${toolId}"]`);
if (await face.count() > 0) {
await face.first().click();
return;
}
// Open the flyout via its corner triangle, wait for item visibility, and click realistically
const slot = page.locator('.ge-tool-slot', { has: page.locator(`[data-tool-option="${toolId}"]`) }).first();
const tri = slot.locator('.ge-tool-flyout-tri');
if (await tri.count() > 0) {
await tri.click();
}
const item = slot.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`);
await expect(item).toBeVisible({ timeout: 3000 });
await item.click();
}
// On phones, picking a tool minimizes the Layers sheet and refits the canvas.
// Wait for the canvas to stop moving before aiming at it.
async function settledCanvasBox(page) {
let box = null;
await expect.poll(async () => {
const previous = box;
box = await page.locator('.ge-main-canvas').boundingBox();
return JSON.stringify(previous) === JSON.stringify(box);
}).toBe(true);
return box;
}
// Rare text/shape options live in the options strip's "More" popover, which
// closes on any outside press. Open it (idempotently) before using them.
async function openOptionsMore(page, kind) {
const menu = page.locator(`#ge-${kind}-more`);
if (await menu.isHidden()) await page.locator(`#ge-${kind}-more-toggle`).click();
await expect(menu).toBeVisible();
}
// Filter actions live in Photoshop-style flyouts (Blur, Retained effects, ...).
// Open the menu, then the item's parent row, as a user would.
async function chooseFilterAction(page, action, opener = '#ge-filter-menu-btn') {
await page.locator(opener).click();
const menu = page.locator('#ge-filter-menu');
await expect(menu).toBeVisible();
const item = menu.locator(`[data-filter-action="${action}"]`);
const panel = await item.evaluate(node => node.closest('.ge-submenu')?.dataset.submenuPanel || null);
if (panel) await menu.locator(`.ge-menu-parent[data-submenu="${panel}"]`).click();
await item.click();
}
module.exports = {
chooseFilterAction,
compareExportPixels,
dragOnCanvas,
encodedImagePixelDigest,
@@ -370,6 +402,9 @@ module.exports = {
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
openOptionsMore,
reopenDraft,
selectTool,
settledCanvasBox,
waitForDraft,
};
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, editorState, dragOnCanvas } = require('./helpers');
const { chooseFilterAction, dragOnCanvas, editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
test.beforeEach(async ({ page }) => {
// The isolated editor server has no logged-in notification owner.
@@ -35,6 +35,96 @@ test('top toolbar stays on one row and its menus remain clickable at narrow widt
}
});
test('save button icon follows the draft status through an edit and autosave', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
// Autosave can settle within a frame, so record the rendered icon at every
// status change instead of racing it with polling assertions.
await page.evaluate(() => {
const status = document.getElementById('ge-draft-status');
const visibleIcon = () => [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')]
.filter(icon => icon.getBoundingClientRect().width > 0)
.map(icon => (icon.classList.contains('ge-save-state-saved') ? 'saved' : 'dirty'));
window.__saveStates = [];
new MutationObserver(() => window.__saveStates.push({ text: status.textContent, icons: visibleIcon() }))
.observe(status, { childList: true, characterData: true, subtree: true });
});
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
await waitForDraft(page);
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
const states = await page.evaluate(() => window.__saveStates);
expect(states.some(entry => entry.text === 'Unsaved')).toBe(true);
for (const entry of states) {
expect(entry.icons).toEqual([entry.text === 'Saved' ? 'saved' : 'dirty']);
}
});
test('save-state representations cover all draft states without error normalization', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await waitForDraft(page);
const cases = [
{ label: 'Unsaved', state: 'dirty', expectedText: 'Unsaved', expectedState: 'dirty', iconClass: 'ge-save-state-dirty', isError: false },
{ label: 'Saving soon…', state: 'pending', expectedText: 'Saving soon…', expectedState: 'pending', iconClass: 'ge-save-state-dirty', isError: false },
{ label: 'Saving…', state: 'saving', expectedText: 'Saving…', expectedState: 'saving', iconClass: 'ge-save-state-dirty', isError: false },
{ label: 'Saved', state: 'saved', expectedText: 'Saved', expectedState: 'saved', iconClass: 'ge-save-state-saved', isError: false },
{ label: 'Save failed', state: 'error', expectedText: 'Save failed', expectedState: 'error', iconClass: 'ge-save-state-error', isError: true },
{ label: 'Save paused', state: 'paused', expectedText: 'Save paused', expectedState: 'paused', iconClass: 'ge-save-state-error', isError: true },
{ label: 'Save failed: network offline', state: '', expectedText: 'Save failed: network offline', expectedState: 'error', iconClass: 'ge-save-state-error', isError: true },
];
for (const c of cases) {
await page.evaluate(({ label, state }) => {
window.__geSetDraftStatus(label, state);
}, c);
const statusEl = page.locator('#ge-draft-status');
const saveBtn = page.locator('#ge-save-menu-btn');
await expect(statusEl).toHaveText(c.expectedText);
await expect(statusEl).toHaveAttribute('data-state', c.expectedState);
const visibleIcon = await page.evaluate(() => {
const icons = [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')];
const visible = icons.filter(i => !i.hasAttribute('hidden'));
return visible.map(i => i.className.baseVal || i.className);
});
expect(visibleIcon.length).toBe(1);
expect(visibleIcon[0]).toContain(c.iconClass);
if (c.isError) {
await expect(saveBtn).toHaveAttribute('aria-label', new RegExp(`Draft autosave failed: ${c.expectedText}`));
} else {
await expect(saveBtn).toHaveAttribute('aria-label', `Draft status: ${c.expectedText}`);
}
}
});
test('autosave network failure transitions to error state and shows failure indicator', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await page.route(/\/api\/editor-drafts/, route => route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ detail: 'Database error' }),
}));
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
const statusEl = page.locator('#ge-draft-status');
await expect(statusEl).toHaveText('Save failed', { timeout: 10_000 });
await expect(statusEl).toHaveAttribute('data-state', 'error');
const saveBtn = page.locator('#ge-save-menu-btn');
await expect(saveBtn).toHaveAttribute('aria-label', /Draft autosave failed/);
const visibleIcons = await page.evaluate(() => {
const icons = [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')];
return icons.filter(i => !i.hasAttribute('hidden')).map(i => i.className.baseVal || i.className);
});
expect(visibleIcons).toHaveLength(1);
expect(visibleIcons[0]).toContain('ge-save-state-error');
});
test('committed paint refreshes the layer thumbnail and flashes only the edited row', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
const activeId = (await editorState(page)).activeLayerId;
@@ -55,8 +145,7 @@ test('filter Escape restores pixels and preserves redo history', async ({ page }
await dragOnCanvas(page, { x: 0.7, y: 0.2 }, { x: 0.2, y: 0.7 });
await page.keyboard.press('Control+z');
const before = await editorState(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="blur-gaussian"]').click();
await chooseFilterAction(page, 'blur-gaussian');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.ge-filter-modal')).toHaveCount(0);
@@ -75,7 +164,10 @@ test('focused fields own undo, selection and clipboard without changing layers',
await input.fill('Editable text');
await input.press('Control+Enter');
// A tool's ordinary text input must also retain native clipboard ownership.
// Frame width lives in the text options' collapsed "More" section.
await page.locator('#ge-text-more-toggle').click();
await page.locator('#ge-text-frame-width').focus();
await expect(page.locator('#ge-text-frame-width')).toBeFocused();
const before = await editorState(page);
await page.keyboard.press('Control+z');
await page.keyboard.press('Control+j');
@@ -247,3 +339,76 @@ test('switching away from a held brush ends one stroke and desktop reselect keep
await page.keyboard.press('Control+z');
expect((await editorState(page)).layers).toEqual(before.layers);
});
test('concurrent edits during in-flight save preserve dirty unsaved status and revision sequence', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await waitForDraft(page);
let delayedRouteResolve = null;
const delayedRoutePromise = new Promise(resolve => { delayedRouteResolve = resolve; });
let saveCallCount = 0;
await page.route(/\/api\/editor-drafts/, async route => {
saveCallCount += 1;
if (saveCallCount === 1) {
await delayedRoutePromise;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'concurrent-draft-1', name: 'Untitled' }),
});
});
// Edit 1
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
// Wait until save is in flight
await expect(page.locator('#ge-draft-status')).toHaveText('Saving…', { timeout: 10_000 });
// Edit 2 while save 1 is in flight
await dragOnCanvas(page, { x: 0.5, y: 0.5 }, { x: 0.7, y: 0.7 });
const revsDuringFlight = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return {
docRev: state.documentRevision,
persistingRev: state.persistingRevision,
dirty: state.persistDirty,
};
});
expect(revsDuringFlight.docRev).toBeGreaterThan(revsDuringFlight.persistingRev);
expect(revsDuringFlight.dirty).toBe(true);
// Now let save 1 complete
delayedRouteResolve();
// Wait for the chained second save to complete
await expect.poll(async () => {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.savedRevision >= state.documentRevision && !state.persistDirty;
});
}, { timeout: 15_000 }).toBe(true);
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
});
test('failed draft save retains unsaved dirty status and failure indicator', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await waitForDraft(page);
await page.route(/\/api\/editor-drafts/, route => route.abort('failed'));
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
await expect(page.locator('#ge-draft-status')).toHaveText('Save failed', { timeout: 10_000 });
const dirtyAfterFailure = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.persistDirty;
});
expect(dirtyAfterFailure).toBe(true);
});
@@ -26,7 +26,7 @@ test('clipping mask uses base alpha and survives undo, redo, and server reopen',
});
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-clip-btn').click();
await expect(editRow).toHaveClass(/clipped/);
await expect(editRow.locator('.ge-layer-clipped-marker')).toBeVisible();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
@@ -45,13 +45,13 @@ test('clipping mask uses base alpha and survives undo, redo, and server reopen',
expect((await sampleComposite(page)).right[3]).toBe(0);
await expect(page.locator('.ge-layer-item.clipped .ge-layer-clipped-marker')).toBeVisible();
await page.locator('#ge-layer-tools button[title="Merge down into layer below"]').click();
await page.locator('.ge-layer-controls:visible button[title="Merge down into layer below"]').click();
expect((await editorState(page)).layers).toHaveLength(1);
expect((await sampleComposite(page)).right[3]).toBe(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-clip-btn').click();
expect((await sampleComposite(page)).right).toEqual([0, 0, 255, 255]);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
+3 -2
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('independent layer locks enforce operations and survive undo and reopen', async ({ page, request }) => {
@@ -23,7 +24,7 @@ test('independent layer locks enforce operations and survive undo and reopen', a
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').locks.position).toBe(true);
await expect(lockButton).toHaveClass(/active/);
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.55, y: 0.50 });
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 0, y: 0 });
@@ -35,7 +36,7 @@ test('independent layer locks enforce operations and survive undo and reopen', a
await toggleLock('pixels');
const emptyHash = (await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash;
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.58, y: 0.50 });
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash).toBe(emptyHash);
await toggleLock('pixels');
@@ -1,15 +1,16 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
test('layer mask link controls independent movement and survives reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Linked layer mask');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.2, y: 0.35 }, { x: 0.75, y: 0.6 });
let current = await editorState(page);
const layerId = current.activeLayerId;
const layerRow = page.locator(`.ge-layer-item[data-layer-id="${layerId}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
@@ -63,7 +64,7 @@ test('layer mask link controls independent movement and survives reopen', async
await maskRow.locator('.ge-mask-link-btn').click();
await expect(maskRow.locator('.ge-mask-link-btn')).toHaveAttribute('aria-pressed', 'false');
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await dragOnCanvas(page, { x: 0.45, y: 0.45 }, { x: 0.55, y: 0.5 });
current = await editorState(page);
@@ -90,7 +91,7 @@ test('layer mask link controls independent movement and survives reopen', async
}).toEqual(fixedDocumentMask);
const beforeTransform = layer;
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-w').fill('280');
await page.locator('#ge-transform-w').dispatchEvent('input');
await page.locator('#ge-transform-apply').click();
+9 -14
View File
@@ -1,9 +1,8 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden', async ({ page }) => {
// Notification polling requires login even in the auth-disabled test server.
await page.route('**/api/tasks/notification-logs*', route => route.fulfill({ json: { logs: [] } }));
await openBlankEditor(page, { width: 240, height: 160 }, 'Thumbnail selection');
const id = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
@@ -31,7 +30,6 @@ test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden
test('selected layers align to the canvas and undo as one operation', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Alignment E2E');
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const edit = state.layers.find(layer => layer.name === 'Edit');
@@ -110,7 +108,6 @@ test('group rows can be selected with the keyboard', async ({ page }) => {
test('Delete removes the selected layer and undo restores it', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard delete E2E');
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
const addedId = (await editorState(page)).activeLayerId;
await page.locator(`.ge-layer-item[data-layer-id="${addedId}"]`).click();
await page.keyboard.press('Backspace');
@@ -133,19 +130,19 @@ test('Ctrl/Cmd+J duplicates the active layer through the layer panel path', asyn
test('move tool can auto-select the topmost visible layer under the pointer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Auto-select E2E');
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await selectTool(page, 'shape');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.75 });
const shape = (await editorState(page)).layers.find(layer => layer.kind === 'shape');
expect(shape).toBeTruthy();
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await expect(page.locator('.ge-auto-select-option')).toBeVisible();
await page.locator('.ge-auto-select-option').click();
await expect(page.locator('#ge-auto-select-layer')).toBeChecked();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await expect(page.locator('.ge-auto-select-option')).toBeHidden();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await expect(page.locator('.ge-auto-select-option')).toBeVisible();
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(box.x + box.width * 0.5, box.y + box.height * 0.5);
@@ -163,7 +160,7 @@ test('multi-selected layers move together and support history-backed bulk action
expect((await editorState(page)).selectedLayerIds).toHaveLength(2);
await expect(page.locator('#ge-layer-selection-bar')).toBeVisible();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.52, y: 0.50 });
let current = await editorState(page);
const edit = current.layers.find(layer => layer.name === 'Edit');
@@ -192,7 +189,6 @@ test('multi-selected layers move together and support history-backed bulk action
await editRow.click();
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
const addedId = (await editorState(page)).activeLayerId;
await editRow.click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
@@ -236,7 +232,7 @@ test('layer groups composite, lock, collapse, undo, and survive server reopen',
await page.locator('.ge-layer-group-row .ge-layer-lock-btn').click();
await page.locator('.ge-layer-group-row').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await dragOnCanvas(page, { x: 0.40, y: 0.40 }, { x: 0.52, y: 0.48 });
current = await editorState(page);
expect(current.layers[0].offset).toEqual(current.layers[1].offset);
@@ -260,7 +256,7 @@ test('layer groups composite, lock, collapse, undo, and survive server reopen',
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(0);
await page.locator('.ge-layer-group-row').click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
await page.locator('.ge-layer-controls:visible button[title="Ungroup layers"]').click();
expect((await editorState(page)).groups).toHaveLength(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups).toHaveLength(1);
@@ -277,7 +273,6 @@ test('nested groups preserve hierarchy, ancestor locks, collapse, and server reo
const innerId = current.groups[0].id;
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page);
const looseId = current.activeLayerId;
await page.locator(`.ge-layer-group-row[data-group-id="${innerId}"]`).click();
@@ -318,7 +313,7 @@ test('nested groups preserve hierarchy, ancestor locks, collapse, and server reo
await expect(page.locator('.ge-layer-group-row')).toHaveCount(2);
await page.locator(`.ge-layer-group-row[data-group-id="${outer.id}"]`).click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
await page.locator('.ge-layer-controls:visible button[title="Ungroup layers"]').click();
current = await editorState(page);
expect(current.groups).toHaveLength(1);
expect(current.groups[0]).toMatchObject({ id: inner.id, parentId: null });
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test');
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
const { chooseFilterAction, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
async function seedVisibleLayers(page) {
await page.evaluate(async () => {
@@ -18,14 +18,13 @@ test('Merge all preserves retained effects and adjustment output', async ({ page
await openBlankEditor(page, { width: 320, height: 220 }, 'Merge fidelity E2E');
await seedVisibleLayers(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await chooseFilterAction(page, 'effect-color-overlay');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#f0c04a');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
await page.locator('#ge-add-layer').click();
await page.locator('#ge-add-fx').click();
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('45');
@@ -0,0 +1,57 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, selectTool } = require('./helpers.js');
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('minimizing layers restores fit view and keeps an explicit zoom', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
const canvas = page.locator('.ge-main-canvas');
const zoom = () => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoom);
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
await expect.poll(async () => (await canvas.boundingBox()).width).toBeGreaterThan(200);
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-zoom-in').evaluate(button => button.click());
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoomViewMode)).toBe('scale');
const manualZoom = await zoom();
await page.locator('.ge-layers-title').click();
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
await expect.poll(zoom).toBe(manualZoom);
});
test('minimizing layers keeps a pinch zoom', async ({ page, context, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP multi-touch injection');
await openBlankEditor(page, { width: 320, height: 240 });
const state = () => page.evaluate(async () => {
const { state: editor } = await import('/static/js/editor/state.js');
return { zoom: editor.zoom, mode: editor.zoomViewMode };
});
const fitted = await state();
const box = await page.locator('.ge-main-canvas').boundingBox();
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
const cdp = await context.newCDPSession(page);
const fingers = spread => [{ x: cx - spread, y: cy, id: 1 }, { x: cx + spread, y: cy, id: 2 }];
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: fingers(20) });
for (const spread of [30, 40, 50]) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: fingers(spread) });
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const pinched = await state();
expect(pinched.mode).toBe('scale');
expect(pinched.zoom).toBeGreaterThan(fitted.zoom * 2);
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
await expect.poll(async () => (await state()).zoom).toBe(pinched.zoom);
});
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const fs = require('node:fs');
const { editorState, openBlankEditor, waitForDraft } = require('./helpers');
@@ -18,7 +19,7 @@ test('mobile tool and layer sheets do not overlap mask controls', async ({ page
const layerId = (await editorState(page)).activeLayerId;
const layerRow = page.locator(`.ge-layer-item[data-layer-id="${layerId}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
const maskName = maskRow.locator('.ge-layer-name');
@@ -39,15 +40,15 @@ test('mobile tool and layer sheets do not overlap mask controls', async ({ page
await maskRow.locator('.ge-mask-link-btn').click();
await expect(maskRow.locator('.ge-mask-link-btn')).toHaveAttribute('aria-pressed', 'false');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await expect(controls).not.toHaveClass(/dismissed/);
await expect(layerSheet).toHaveClass(/minimized/);
const controlsBox = await controls.boundingBox();
const sheetBox = await layerSheet.boundingBox();
expect(controlsBox.y).toBeLessThan(sheetBox.y + sheetBox.height);
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(controls).toHaveClass(/dismissed/);
});
@@ -79,17 +80,17 @@ test('mobile touch mask editing supports undo and persists through reload', asyn
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
const before = (await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash;
// A new layer mask is fully white (revealed), so Brush would paint white
// onto white. Eraser makes the first touch edit observable by hiding pixels.
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await selectTool(page, 'eraser');
// Collapse the bottom-sheet controls so the full canvas is available to the
// touch gesture, matching the canvas-first mobile editing mode.
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await selectTool(page, 'eraser');
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const cdp = await page.context().newCDPSession(page);
const visibleCanvasPoint = await page.evaluate(() => {
@@ -120,6 +121,10 @@ test('mobile touch mask editing supports undo and persists through reload', asyn
const painted = await editorState(page);
const paintedLayer = painted.layers.find(layer => layer.masks.length);
expect(paintedLayer.masks[0].pixelHash).not.toBe(before);
// History controls live in the layers header, which is hidden while the
// sheet is minimized for painting. Reopen it as a phone user would.
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
.toBe(before);
@@ -169,12 +174,12 @@ test('mobile touch moves an unlinked layer mask without moving its layer', async
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
const layerId = (await editorState(page)).activeLayerId;
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
await maskRow.locator('.ge-mask-link-btn').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await selectTool(page, 'move');
const point = await page.evaluate(() => {
const target = document.querySelector('.ge-main-canvas');
@@ -206,6 +211,8 @@ test('mobile touch moves an unlinked layer mask without moving its layer', async
expect(movedLayer.offset).toEqual(beforeLayer.offset);
expect(movedLayer.masks[0].offset.x).not.toBe(beforeLayer.masks[0].offset.x);
expect(movedLayer.masks[0].offset.y).not.toBe(beforeLayer.masks[0].offset.y);
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.id === layerId).masks[0].offset)
.toEqual(beforeLayer.masks[0].offset);
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const fs = require('node:fs/promises');
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
@@ -14,9 +15,9 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
await openBlankEditor(page, { width: 400, height: 300 }, 'Multi-transform E2E');
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await editRow.click();
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvasBox.x + canvasBox.width * 0.35, canvasBox.y + canvasBox.height * 0.25);
await page.locator('#ge-text-content').fill('Transform together');
@@ -25,7 +26,7 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await editRow.click();
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-clip-btn').click();
await editRow.click();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
@@ -36,7 +37,7 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
expect(initial.selectedLayerIds).toHaveLength(3);
const bounds = selectionBounds(initial.layers);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await expect(page.locator('.ge-transform-popup .ge-adj-title')).toHaveText('Transform 3 layers');
expect(Number(await page.locator('#ge-transform-w').inputValue())).toBe(bounds.width);
expect(Number(await page.locator('#ge-transform-h').inputValue())).toBe(bounds.height);
@@ -71,7 +72,7 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers).toEqual(transformed.layers);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
const secondWidth = await page.locator('#ge-transform-w').inputValue();
await page.locator('#ge-transform-rot-90').click();
await expect(page.locator('#ge-transform-rot')).toHaveValue('90');
@@ -87,7 +88,7 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
expect(cancelled.groups).toEqual(transformed.groups);
expect(cancelled.redo).toBe(0);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-rot-90').click();
await expect(page.locator('#ge-transform-rot')).toHaveValue('90');
await page.locator('#ge-transform-flip-h').click();
@@ -0,0 +1,87 @@
const { test, expect } = require('@playwright/test');
const path = require('node:path');
test('static editor modules survive a cache upgrade and offline reopen', async ({ browser }, testInfo) => {
const context = await browser.newContext({
baseURL: testInfo.project.use.baseURL,
storageState: path.join(process.env.PHOTO_EDITOR_E2E_DATA_DIR, 'browser-auth.json'),
serviceWorkers: 'allow',
});
const page = await context.newPage();
const pickerUrl = '/static/js/colorPicker.js?v=20260910eyedropper1';
const cssUrl = '/static/css/13-image-editor.css?v=20260929csssplit';
const editorUrl = '/static/js/galleryEditor.js?v=20260909movepicklayer1';
try {
// The app's worker intentionally controls /static/, not the SPA root.
await page.goto('/static/js/panels.js?v=20260909movepicklayer1');
const installed = await page.evaluate(async ({ pickerUrl, cssUrl }) => {
const previous = await caches.open('odysseus-v1113-pr40-editor-precache');
await previous.put(pickerUrl, new Response('stale picker'));
await previous.put(cssUrl, new Response('stale css'));
const registration = await navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1');
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
}
return { scope: new URL(registration.scope).pathname, root: !!(await navigator.serviceWorker.getRegistration('/')) };
}, { pickerUrl, cssUrl });
expect(installed).toEqual({ scope: '/static/', root: false });
await expect.poll(() => page.evaluate(async () => (await caches.keys()).includes('odysseus-v1113-pr40-editor-precache'))).toBe(false);
for (const url of [pickerUrl, cssUrl, editorUrl, '/static/js/platform.js', '/static/js/modelSort.js']) {
expect(await page.evaluate(async value => !!(await caches.match(value)), url)).toBe(true);
}
// Simulate stale entries in the active version, then verify network-first
// refresh updates both JS and CSS before testing the offline fallback.
const currentCache = await page.evaluate(async () => (await caches.keys()).find(name => name.startsWith('odysseus-')));
expect(currentCache).toMatch(/^odysseus-v\d+-pr40-editor-precache$/);
expect(currentCache).not.toBe('odysseus-v1113-pr40-editor-precache');
await page.evaluate(async ({ currentCache, pickerUrl, cssUrl }) => {
const cache = await caches.open(currentCache);
await cache.put(pickerUrl, new Response('stale picker'));
await cache.put(cssUrl, new Response('stale css'));
for (const url of [pickerUrl, cssUrl]) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Network refresh failed: ${url} ${response.status}`);
}
}, { currentCache, pickerUrl, cssUrl });
for (const url of [pickerUrl, cssUrl]) {
await expect.poll(() => page.evaluate(async value => {
const response = await caches.match(value);
return response ? !(await response.text()).startsWith('stale ') : false;
}, url)).toBe(true);
}
// Origin is the only Vary header the offline fallback may ignore. An entry
// that varies on anything else (as a per-user response would) must miss.
const varyProbeUrl = '/static/js/e2e-vary-probe.js';
await page.evaluate(async ({ currentCache, varyProbeUrl }) => {
const cache = await caches.open(currentCache);
await cache.put(
new Request(varyProbeUrl, { headers: { 'X-Probe-Owner': 'another-user' } }),
new Response('export default "private";', { headers: { 'Content-Type': 'text/javascript', Vary: 'Origin, X-Probe-Owner' } }),
);
}, { currentCache, varyProbeUrl });
await context.setOffline(true);
for (let attempt = 0; attempt < 2; attempt += 1) {
const offline = await context.newPage();
await offline.goto('/static/js/panels.js?v=20260909movepicklayer1');
expect(await offline.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
const result = await offline.evaluate(async ({ pickerUrl, editorUrl, cssUrl, varyProbeUrl }) => {
const editor = await import(editorUrl);
const picker = await import(pickerUrl);
const css = await fetch(cssUrl);
const privateProbe = await import(varyProbeUrl).then(() => 'served', () => 'rejected');
return {
editor: typeof editor.openEditor,
picker: ['getRecentColors', 'recordRecentColor', 'suggestColorsFor'].every(key => typeof picker[key] === 'function'),
cssOk: css.ok,
privateProbe,
};
}, { pickerUrl, editorUrl, cssUrl, varyProbeUrl });
expect(result).toEqual({ editor: 'function', picker: true, cssOk: true, privateProbe: 'rejected' });
await offline.close();
}
} finally { await context.close(); }
});
@@ -0,0 +1,95 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers.js');
test('channel and path panels display hostile layer metadata as text', async ({ page }) => {
await openBlankEditor(page, { width: 64, height: 64 });
const result = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const { createColorPanels } = await import('/static/js/editor/panels/panels.js');
const { createStudioPanels } = await import('/static/js/editor/panels/studio-panels.js');
const attack = '<img src=x onerror="window.__panelXss=true">';
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.name = attack;
layer.kind = 'shape';
layer.shape = { type: attack };
const colors = createColorPanels({ state });
const studio = createStudioPanels({ state });
document.body.append(colors.channels, studio.paths);
colors.refresh();
studio.refresh();
return {
attack,
channel: colors.channels.querySelector('.ge-channel-name').textContent,
path: studio.paths.querySelector('.ge-channel-name').textContent,
images: colors.channels.querySelectorAll('img').length + studio.paths.querySelectorAll('img').length,
executed: !!window.__panelXss,
};
});
expect(result.channel).toBe(result.attack);
expect(result.path).toBe(`${result.attack} · ${result.attack}`);
expect(result.images).toBe(0);
expect(result.executed).toBe(false);
});
test('unknown effect types cannot inject markup into the layer panel', async ({ page }) => {
await openBlankEditor(page, { width: 64, height: 64 });
const attack = '<img src=x onerror="window.__effectXss=true">';
const id = await page.evaluate(async value => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.effects = [{ id: 'effect-test', type: value, visible: true, params: {} }];
return layer.id;
}, attack);
await page.locator(`.ge-layer-item[data-layer-id="${id}"] .ge-layer-vis`).first().click();
const list = page.locator('#ge-layers-list');
await expect(list).toContainText('Gaussian Blur');
await expect(list).not.toContainText(attack);
await expect(list.locator('img')).toHaveCount(0);
expect(await page.evaluate(() => !!window.__effectXss)).toBe(false);
});
test('saved brush preset names remain literal text in the flyout', async ({ page }) => {
const attack = '<img src=x onerror="window.__brushXss=true">';
await page.addInitScript(value => {
localStorage.setItem('odysseus.editor.brush-presets.v1', JSON.stringify([
{ id: 'hostile-preset', name: value, size: 8, opacity: 100, flow: 100, softness: 20 },
]));
}, attack);
await openBlankEditor(page, { width: 64, height: 64 });
const preset = page.locator('.ge-tool-flyout-preset[data-brush-preset="hostile-preset"]');
await expect(preset.locator('.ge-tool-flyout-label')).toHaveText(attack);
await expect(preset.locator('img')).toHaveCount(0);
expect(await page.evaluate(() => !!window.__brushXss)).toBe(false);
});
test('imported adjustment parameters cannot inject markup into the adjustment popup', async ({ page }) => {
await openBlankEditor(page, { width: 64, height: 64 });
const attack = '"><img src=x onerror="window.__adjXss=true">';
const cases = [
{ type: 'color-lookup', params: { cube: { size: attack, data: [0, 0, 0] }, strength: attack }, text: '.ge-adj-lut-name', expected: 'Loaded LUT · 0³' },
{ type: 'gradient-map', params: { shadows: attack, highlights: attack } },
{ type: 'levels', params: { inBlack: attack, outWhite: attack } },
];
for (const item of cases) {
await page.locator('#ge-add-fx').click();
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${item.type}"]`).click();
await page.locator('.ge-adj-popup [data-adj-action="ok"]').click();
await expect(page.locator('.ge-adj-popup')).toHaveCount(0);
// Project files and drafts reach the editor through the codec's
// normalizer, which validates the type but not every parameter.
await page.evaluate(async ({ type, params }) => {
const { state } = await import('/static/js/editor/state.js');
const { normalizeAdjustmentData } = await import('/static/js/editor/adjustment-layer.js');
const layer = state.layers.find(entry => entry.id === state.activeLayerId);
layer.adjustment = normalizeAdjustmentData({ type, params });
}, item);
await page.locator('.ge-layer-controls:visible .ge-layer-fx-btn').click();
const popup = page.locator('.ge-adj-popup');
await expect(popup).toBeVisible();
await expect(popup.locator('img')).toHaveCount(0);
if (item.text) await expect(popup.locator(item.text)).toHaveText(item.expected);
expect(await popup.evaluate(node => node.innerHTML.includes('onerror'))).toBe(false);
await popup.locator('[data-adj-action="cancel"]').click();
}
expect(await page.evaluate(() => !!window.__adjXss)).toBe(false);
});
@@ -0,0 +1,82 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers');
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
async function gaps(page) {
return page.evaluate(() => {
const rect = selector => document.querySelector(selector).getBoundingClientRect();
const canvas = rect('.ge-main-canvas');
const footer = rect('.ge-editor-footer');
const sheet = rect('.ge-right-panel');
return {
canvasToFooter: Math.abs(canvas.bottom - footer.top),
footerToSheet: Math.abs(footer.bottom - sheet.top),
footerLeft: footer.left,
footerRight: footer.right,
sheetTop: sheet.top,
};
});
}
test('mobile footer docks flush between the canvas and the layers sheet', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile footer dock');
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
await expect(page.locator('.ge-editor-footer')).toHaveClass(/ge-footer-floating/);
await expect(page.locator('.ge-layers-grab')).toBeHidden();
// Wait until the dock has settled: canvas bottom == footer top == sheet top.
await expect.poll(async () => {
const g = await gaps(page);
return g.canvasToFooter <= 2 && g.footerToSheet <= 2;
}, { timeout: 10_000 }).toBe(true);
const g = await gaps(page);
expect(g.canvasToFooter).toBeLessThanOrEqual(2);
expect(g.footerToSheet).toBeLessThanOrEqual(2);
expect(g.footerLeft).toBeLessThanOrEqual(1);
expect(Math.abs(g.footerRight - 390)).toBeLessThanOrEqual(1);
});
test('phone footer controls for zoom, setup, and AI settings are accessible via normal clicks', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile footer controls');
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
const footer = page.locator('.ge-editor-footer');
await expect(footer).toBeVisible();
const setupBtn = page.locator('#ge-setup-btn');
const aiBtn = page.locator('#ge-ai-settings-btn');
const zoomIn = page.locator('#ge-zoom-in');
const zoomOut = page.locator('#ge-zoom-out');
const zoomFit = page.locator('#ge-zoom-100');
await expect(setupBtn).toBeVisible();
await expect(aiBtn).toBeVisible();
await expect(zoomIn).toBeVisible();
await expect(zoomOut).toBeVisible();
await expect(zoomFit).toBeVisible();
// Test zoom controls via normal clicks
const zoomLabel = page.locator('.ge-zoom-label');
const initialZoom = await zoomLabel.textContent();
await zoomIn.click();
await expect(zoomLabel).not.toHaveText(initialZoom);
await zoomFit.click();
await expect(zoomLabel).toHaveText(/%/);
// Test Setup modal accessibility via normal click
await setupBtn.click();
const preflight = page.locator('.ge-preflight');
await expect(preflight).toBeVisible();
await page.locator('.ge-preflight-start').click();
await expect(preflight).toBeHidden();
// Test AI Settings modal accessibility via normal click
await aiBtn.click();
const aiSettings = page.locator('.ge-ai-settings');
await expect(aiSettings).toBeVisible();
await page.locator('.ge-ai-settings-done').click();
await expect(aiSettings).toBeHidden();
});
+13 -12
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { editorState, flattenedPixelDigest, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function patternedPng(page, width, height, colors) {
@@ -30,12 +31,12 @@ test('placed image transforms from source, replaces in place, rasterizes, and re
const sourceHash = imported.placed.sourcePixelHash;
const row = page.locator(`.ge-layer-item[data-layer-id="${imported.id}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
await selectTool(page, 'transform');
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth / 2)));
await page.locator('#ge-transform-apply').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-w').fill(String(originalWidth));
await page.locator('#ge-transform-apply').click();
@@ -48,7 +49,7 @@ test('placed image transforms from source, replaces in place, rasterizes, and re
const replacement = await patternedPng(page, 20, 40, ['#111111', '#f8f8f8', '#ff00aa']);
const replaceChooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-layer-tools button[title="Replace placed image"]').click();
await page.locator('.ge-layer-controls:visible button[title="Replace placed image"]').click();
const replaceChooser = await replaceChooserPromise;
await replaceChooser.setFiles({ name: 'replacement.png', mimeType: 'image/png', buffer: replacement });
await expect.poll(async () => {
@@ -62,7 +63,7 @@ test('placed image transforms from source, replaces in place, rasterizes, and re
expect(replaced.masks[0]).toEqual(beforeReplaceMask);
const beforeRasterize = await flattenedPixelDigest(page);
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
await page.locator('.ge-layer-controls:visible button[title="Rasterize placed layer"]').click();
const rasterized = (await editorState(page)).layers.find(layer => layer.id === imported.id);
expect(rasterized.kind).toBe('raster');
expect(rasterized.placed).toBeNull();
@@ -92,11 +93,11 @@ test('raster layers can be converted to editable sources without changing pixels
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
await page.locator('.ge-layer-controls:visible button[title="Rasterize placed layer"]').click();
const raster = (await editorState(page)).layers.find(layer => layer.id === placed.id);
const before = { pixelHash: raster.pixelHash, size: raster.size, offset: raster.offset };
await page.locator('#ge-layer-tools button[title="Convert to editable source"]').click();
await page.locator('.ge-layer-controls:visible button[title="Convert to editable source"]').click();
await expect.poll(async () => (await editorState(page)).layers.find(layer => layer.id === placed.id).kind).toBe('placed');
const converted = (await editorState(page)).layers.find(layer => layer.id === placed.id);
expect(converted.placed.sourceSize).toEqual(before.size);
@@ -139,7 +140,7 @@ test('duplicating a placed layer copies its source independently', async ({ page
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const original = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator(`.ge-layer-item[data-layer-id="${original.id}"]`).click();
await page.locator('#ge-layer-tools button[title="Duplicate layer"]').click();
await page.locator('.ge-layer-controls:visible button[title="Duplicate layer"]').click();
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(2);
const sourceOwnership = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
@@ -165,10 +166,10 @@ test('raster transforms preserve source pixels by default and can opt out', asyn
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
await page.locator('.ge-layer-controls:visible button[title="Rasterize placed layer"]').click();
const rasterBeforeTransform = (await editorState(page)).layers.find(layer => layer.id === placed.id);
const sourceHash = rasterBeforeTransform.pixelHash;
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-preserve-source')).toBeChecked();
await page.locator('#ge-transform-w').fill('84');
await page.locator('#ge-transform-apply').click();
@@ -191,8 +192,8 @@ test('raster transforms can explicitly replace source pixels', async ({ page, re
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('.ge-layer-controls:visible button[title="Rasterize placed layer"]').click();
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-preserve-source')).toBeChecked();
await page.locator('#ge-transform-preserve-source').uncheck();
await page.locator('#ge-transform-w').fill('60');
@@ -0,0 +1,17 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers.js');
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('setup checklist remains usable above the mobile layers sheet', async ({ page }) => {
await openBlankEditor(page);
await page.locator('#ge-setup-btn').click();
const checklist = page.locator('.ge-preflight');
const start = checklist.locator('[data-preflight-start]');
await expect(checklist).toBeVisible();
await start.scrollIntoViewIfNeeded();
await expect(start).toHaveCSS('white-space', 'nowrap');
expect(await start.evaluate(button => button.scrollWidth <= button.clientWidth)).toBe(true);
await start.click();
await expect(checklist).toBeHidden();
});
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test');
const { editorState, openBlankEditor } = require('./helpers.js');
const { editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
async function currentPng(page) {
return page.evaluate(async () => {
@@ -46,6 +46,7 @@ test('mixed-corrupt project recovers valid layers and remains undoable', async (
test('fully corrupt project leaves the open document unchanged', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Recovery E2E');
await waitForDraft(page);
const before = await editorState(page);
await loadProject(page, {
type: 'odysseus-gallery-editor-project',
@@ -108,13 +109,13 @@ test('new project size dialog cancels cleanly with Escape', async ({ page }) =>
await expect(page.locator('#gallery-editor-new')).toBeVisible();
});
test('editor topbar uses uppercase action labels', async ({ page }) => {
test('editor topbar uses Photoshop text menu labels', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Topbar labels');
await expect(page.locator('#ge-view-menu-btn')).toHaveText(/VIEW/);
await expect(page.locator('#ge-image-menu-btn')).toHaveText(/IMAGE/);
await expect(page.locator('#ge-selection-menu-btn')).toHaveText(/SELECT/);
await expect(page.locator('#ge-filter-menu-btn')).toHaveText(/FILTER/);
await expect(page.locator('#ge-view-menu-btn')).toHaveText(/View/);
await expect(page.locator('#ge-image-menu-btn')).toHaveText(/Image/);
await expect(page.locator('#ge-selection-menu-btn')).toHaveText(/Select/);
await expect(page.locator('#ge-filter-menu-btn')).toHaveText(/Filter/);
await expect(page.locator('#ge-import-topbar')).toHaveText(/IMPORT/);
await expect(page.locator('#ge-save-menu-btn')).toHaveText(/SAVE/);
});
@@ -0,0 +1,104 @@
#!/usr/bin/env node
// Quick Edit benchmark v2 grader (gpt-5.4 vision). Reads the runner output
// (quickedit-bench.json) plus the saved before/after PNGs, scores each case
// 0-3, and writes quickedit-bench-grades.json with per-case scores and
// per-category averages. If an eye-scores JSON is provided (id->score), it
// merges the human (eye) score, averages eye+gpt, and flags disagreements.
//
// Usage:
// OUT=/tmp/opencode/qe-bench \
// EYE=/path/eye-scores.json \
// node quickedit-bench-grade.js
const fs = require('node:fs');
const path = require('node:path');
const https = require('node:https');
const OUT = process.env.OUT || '/tmp/opencode/qe-bench';
const MANIFEST = process.env.MANIFEST || path.join(OUT, 'quickedit-bench.json');
const EYE = process.env.EYE || '';
const MODEL = process.env.GRADE_MODEL || 'gpt-5.4';
const KEY_FILE = process.env.OPENAI_KEY_FILE || '/home/pewds/.config/tiger/openai_user_20261009.key';
function getKey() {
if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY.trim();
return fs.readFileSync(KEY_FILE, 'utf8').trim();
}
const KEY = getKey();
const URL = 'https://api.openai.com/v1/chat/completions';
function b64(p) { return fs.readFileSync(p).toString('base64'); }
function call(messages) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ model: MODEL, messages, max_completion_tokens: 2000 });
const req = https.request(URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${KEY}`, 'Content-Length': Buffer.byteLength(body) },
}, (res) => {
let d = ''; res.on('data', (c) => (d += c));
res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(new Error('bad json: ' + d.slice(0, 300))); } });
});
req.on('error', reject);
req.write(body); req.end();
});
}
function rubric(prompt) {
return [
'You are a STRICT photo-editing grader. You are given the BEFORE image and the AFTER image.',
`The user asked: "${prompt}".`,
'Score the AFTER versus the BEFORE on an integer 0-3:',
'0 = worse than the original, or broken/ugly/artifacts; 1 = did something but weak, irrelevant, or barely visible; 2 = decent, clearly visible and natural; 3 = what a good retoucher would produce.',
'Judge harshly. If a face/eyes/lips/hair are smeared, distorted, blown out, plastic, or lost natural texture, score 0. If highlights are clipped or the image is washed out, cap at 1.',
'Reply with ONLY JSON: {"score": <int>, "worse": <bool>, "artifacts": "<short>", "reason": "<short>"}.',
].join('\n');
}
(async () => {
const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
const eye = EYE && fs.existsSync(EYE) ? JSON.parse(fs.readFileSync(EYE, 'utf8')) : {};
const out = [];
for (const c of manifest.results) {
const before = path.join(OUT, c.tag + '-before.png');
const after = path.join(OUT, c.tag + '-after.png');
let json = null, raw = null;
if (fs.existsSync(before) && fs.existsSync(after)) {
const content = [
{ type: 'text', text: rubric(c.prompt) },
{ type: 'text', text: 'BEFORE:' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,' + b64(before) } },
{ type: 'text', text: 'AFTER:' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,' + b64(after) } },
];
for (let attempt = 0; attempt < 3 && !json; attempt++) {
try {
const res = await call([{ role: 'user', content }]);
raw = res.choices && res.choices[0] && res.choices[0].message && res.choices[0].message.content;
if (!raw && res.error) raw = 'API error: ' + JSON.stringify(res.error);
const m = String(raw || '').match(/\{[\s\S]*\}/);
if (m) { try { json = JSON.parse(m[0]); } catch (_) {} }
} catch (e) { raw = String(e.message || e); }
}
} else {
raw = 'missing before/after images';
}
const gpt = json && typeof json.score === 'number' ? json.score : null;
const eyeScore = typeof eye[c.id] === 'number' ? eye[c.id] : null;
const combined = (gpt != null && eyeScore != null) ? (gpt + eyeScore) / 2 : (gpt != null ? gpt : eyeScore);
out.push({ id: c.id, cat: c.cat, prompt: c.prompt, ms: c.ms, ok: c.ok, gpt, eye: eyeScore, avg: combined, disagree: (gpt != null && eyeScore != null && Math.abs(gpt - eyeScore) >= 2), artifacts: json && json.artifacts, reason: json && json.reason });
console.log(`GRADE #${c.id} [${c.cat}] gpt=${gpt} eye=${eyeScore} avg=${combined} ${c.prompt}`);
}
const byCat = {};
for (const r of out) {
if (typeof r.avg !== 'number') continue;
(byCat[r.cat] = byCat[r.cat] || []).push(r.avg);
}
const cats = {};
for (const [k, v] of Object.entries(byCat)) cats[k] = +(v.reduce((a, b) => a + b, 0) / v.length).toFixed(2);
const scored = out.filter(r => typeof r.avg === 'number');
const overall = scored.length ? +(scored.reduce((a, b) => a + b.avg, 0) / scored.length).toFixed(2) : null;
const disagreements = out.filter(r => r.disagree).map(r => r.id);
fs.writeFileSync(path.join(OUT, 'quickedit-bench-grades.json'), JSON.stringify({ model: MODEL, overall, byCat, disagreements, results: out }, null, 2));
console.log(`BENCH_GRADES overall=${overall}/3 byCat=${JSON.stringify(cats)} disagreements=${JSON.stringify(disagreements)} -> ${path.join(OUT, 'quickedit-bench-grades.json')}`);
})().catch((e) => { console.error('FAIL', e); process.exit(1); });
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env node
// Quick Edit benchmark v2 runner (standalone; not a Playwright spec).
//
// For each fixed prompt: import a fixed photo, run the editor agent, and
// record the executed op trace, latency (ms), undo-group count (history.mark
// delta), new layers, and before/after screenshots. Grading (eye + gpt-5.4)
// happens separately on the saved images (see quickedit-bench-grade.js).
//
// Usage:
// BASE_URL=http://127.0.0.1:7011 SESSION_FILE=/path/sessions.json \
// NODE_PATH=/path/node_modules node quickedit-bench.js
//
// CATS=structural,looks limit to categories
// PROMPTS=1,2,3 limit to ids
// OUT=/tmp/opencode/qe-bench
// PHOTOS=/tmp/opencode where the fixed jpg fixtures live
// HEADFUL=1
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('@playwright/test');
const BASE = process.env.BASE_URL || 'http://127.0.0.1:7011';
const OUT = process.env.OUT || '/tmp/opencode/qe-bench';
const PHOTOS = process.env.PHOTOS || '/tmp/opencode';
const SESSION_FILE = process.env.SESSION_FILE || '/home/pewds/odysseus-cookbook-fresh/data/sessions.json';
// photo fixtures (in PHOTOS/): real-person.jpg pic-64.jpg pic-1015.jpg
// pic-1018.jpg real-sky.jpg real-city.jpg real-mountainlake.jpg
// real-beachsunset.jpg old-faded.jpg food.jpg night.jpg pic-1016.jpg
// pic-1027.jpg demo-street.jpg
const P = {
portrait: 'real-person.jpg',
portrait2: 'pic-64.jpg',
fjord: 'pic-1015.jpg',
hills: 'pic-1018.jpg',
sky: 'real-sky.jpg',
city: 'real-city.jpg',
lake: 'real-mountainlake.jpg',
sunset: 'real-beachsunset.jpg',
old: 'old-faded.jpg',
canyon: 'pic-1016.jpg',
face: 'pic-1027.jpg',
street: 'demo-street.jpg',
};
const CASES = [
// ---- structural (30) ----
{ id: 1, cat: 'structural', photo: P.sky, prompt: 'crop to a square' },
{ id: 2, cat: 'structural', photo: P.sky, prompt: 'make this 16:9' },
{ id: 3, cat: 'structural', photo: P.portrait, prompt: 'crop to 9:16 for a story' },
{ id: 4, cat: 'structural', photo: P.fjord, prompt: 'crop to 4:5' },
{ id: 5, cat: 'structural', photo: P.city, prompt: 'trim the empty borders' },
{ id: 6, cat: 'structural', photo: P.hills, prompt: 'resize to 1920 wide' },
{ id: 7, cat: 'structural', photo: P.fjord, prompt: 'resize to 1080 tall' },
{ id: 8, cat: 'structural', photo: P.hills, prompt: 'rotate the whole image 90 degrees' },
{ id: 9, cat: 'structural', photo: P.city, prompt: 'rotate the canvas 180' },
{ id: 10, cat: 'structural', photo: P.fjord, prompt: 'flip the canvas horizontally' },
{ id: 11, cat: 'structural', photo: P.sky, prompt: 'flip the whole image vertically' },
{ id: 12, cat: 'structural', photo: P.hills, prompt: 'rotate only this layer 90 degrees' },
{ id: 13, cat: 'structural', photo: P.city, prompt: 'flip this layer horizontally' },
{ id: 14, cat: 'structural', photo: P.sky, prompt: 'add the title "SUMMER"' },
{ id: 15, cat: 'structural', photo: P.fjord, setup: 'text', prompt: 'make the text bigger' },
{ id: 16, cat: 'structural', photo: P.city, setup: 'text', prompt: 'make the text smaller' },
{ id: 17, cat: 'structural', photo: P.hills, setup: 'text', prompt: 'make the title red' },
{ id: 18, cat: 'structural', photo: P.sky, prompt: 'align the layer to the centre', setup: 'align' },
{ id: 19, cat: 'structural', photo: P.fjord, prompt: 'align the layer to the left', setup: 'align' },
{ id: 20, cat: 'structural', photo: P.city, prompt: 'align the layer to the right', setup: 'align' },
{ id: 21, cat: 'structural', photo: P.sky, setup: 'text', prompt: 'add a drop shadow to the title' },
{ id: 22, cat: 'structural', photo: P.fjord, setup: 'text', prompt: 'add a stroke to the layer' },
{ id: 23, cat: 'structural', photo: P.city, prompt: 'add a colour overlay' },
{ id: 24, cat: 'structural', photo: P.sky, prompt: 'fill the white area with red' },
{ id: 25, cat: 'structural', photo: P.sky, prompt: 'flood fill the sky with blue' },
{ id: 26, cat: 'structural', photo: P.hills, prompt: 'add a new layer' },
{ id: 27, cat: 'structural', photo: P.fjord, prompt: 'duplicate this layer' },
{ id: 28, cat: 'structural', photo: P.city, prompt: 'flatten the image' },
{ id: 29, cat: 'structural', photo: P.sky, prompt: 'merge the layer down' },
{ id: 30, cat: 'structural', photo: P.hills, prompt: 'delete this layer' },
// ---- looks / adjustments (25) ----
{ id: 31, cat: 'looks', photo: P.fjord, prompt: 'make it brighter' },
{ id: 32, cat: 'looks', photo: P.hills, prompt: 'make it darker' },
{ id: 33, cat: 'looks', photo: P.city, prompt: 'warm it up' },
{ id: 34, cat: 'looks', photo: P.portrait, prompt: 'make it cooler' },
{ id: 35, cat: 'looks', photo: P.fjord, prompt: 'make it black and white' },
{ id: 36, cat: 'looks', photo: P.old, prompt: 'make it sepia' },
{ id: 37, cat: 'looks', photo: P.hills, prompt: 'make it punchy' },
{ id: 38, cat: 'looks', photo: P.fjord, prompt: 'make it vivid and colourful' },
{ id: 39, cat: 'looks', photo: P.city, prompt: 'make it dramatic' },
{ id: 40, cat: 'looks', photo: P.hills, prompt: 'make it moody and cinematic' },
{ id: 41, cat: 'looks', photo: P.old, prompt: 'make it vintage' },
{ id: 42, cat: 'looks', photo: P.old, prompt: 'give it a faded film look' },
{ id: 43, cat: 'looks', photo: P.city, prompt: 'increase the contrast' },
{ id: 44, cat: 'looks', photo: P.fjord, prompt: 'lower the contrast' },
{ id: 45, cat: 'looks', photo: P.hills, prompt: 'sharpen it' },
{ id: 46, cat: 'looks', photo: P.portrait, prompt: 'soften it' },
{ id: 47, cat: 'looks', photo: P.fjord, prompt: 'blur it slightly' },
{ id: 48, cat: 'looks', photo: P.city, prompt: 'add a vignette' },
{ id: 49, cat: 'looks', photo: P.hills, prompt: 'dehaze it' },
{ id: 50, cat: 'looks', photo: P.fjord, prompt: 'make it HDR' },
{ id: 51, cat: 'looks', photo: P.city, prompt: 'auto enhance' },
{ id: 52, cat: 'looks', photo: P.hills, prompt: 'give it a teal and orange look' },
{ id: 53, cat: 'looks', photo: P.fjord, prompt: 'golden hour' },
{ id: 54, cat: 'looks', photo: P.old, prompt: 'clean it up' },
{ id: 55, cat: 'looks', photo: P.city, prompt: 'make it look professional' },
// ---- portrait (10) ----
{ id: 56, cat: 'portrait', photo: P.portrait, prompt: 'smooth the skin' },
{ id: 57, cat: 'portrait', photo: P.portrait, prompt: 'whiten the teeth' },
{ id: 58, cat: 'portrait', photo: P.portrait, prompt: 'remove the blemishes' },
{ id: 59, cat: 'portrait', photo: P.portrait, prompt: 'brighten the face' },
{ id: 60, cat: 'portrait', photo: P.portrait, prompt: 'brighten the eyes' },
{ id: 61, cat: 'portrait', photo: P.portrait2, prompt: 'make her look glamorous' },
{ id: 62, cat: 'portrait', photo: P.portrait, prompt: 'give it a professional headshot look' },
{ id: 63, cat: 'portrait', photo: P.portrait, prompt: 'reduce the shine on the face' },
{ id: 64, cat: 'portrait', photo: P.portrait2, prompt: 'warm up the portrait' },
{ id: 65, cat: 'portrait', photo: P.portrait, prompt: 'soft smooth skin with a nice glow' },
// ---- generative (20) ----
{ id: 66, cat: 'generative', photo: P.city, prompt: 'remove the person on the left' },
{ id: 67, cat: 'generative', photo: P.hills, prompt: 'remove the distractions' },
{ id: 68, cat: 'generative', photo: P.portrait, prompt: 'put her on a beach' },
{ id: 69, cat: 'generative', photo: P.portrait, prompt: 'replace the background with mountains' },
{ id: 70, cat: 'generative', photo: P.fjord, prompt: 'add a full moon in the sky' },
{ id: 71, cat: 'generative', photo: P.sky, prompt: 'add dramatic clouds' },
{ id: 72, cat: 'generative', photo: P.sky, prompt: 'extend the canvas to 16:9 with generative fill' },
{ id: 73, cat: 'generative', photo: P.sky, prompt: 'extend the canvas to 9:16' },
{ id: 74, cat: 'generative', photo: P.fjord, prompt: 'add a bird flying in the sky' },
{ id: 75, cat: 'generative', photo: P.hills, prompt: 'add wildflowers in the foreground' },
{ id: 76, cat: 'generative', photo: P.fjord, prompt: 'make it look snowy' },
{ id: 77, cat: 'generative', photo: P.hills, prompt: 'make it autumn with orange leaves' },
{ id: 78, cat: 'generative', photo: P.city, prompt: 'add rain and wet streets' },
{ id: 79, cat: 'generative', photo: P.city, prompt: 'remove the car' },
{ id: 80, cat: 'generative', photo: P.city, prompt: 'remove the sign' },
{ id: 81, cat: 'generative', photo: P.fjord, prompt: 'change the sky to a sunset' },
{ id: 82, cat: 'generative', photo: P.portrait, prompt: 'put sunglasses on her' },
{ id: 83, cat: 'generative', photo: P.portrait, prompt: 'add a hat' },
{ id: 84, cat: 'generative', photo: P.fjord, prompt: 'add a rainbow' },
{ id: 85, cat: 'generative', photo: P.sky, prompt: 'generative fill the empty sky' },
// ---- multi-step (15) ----
{ id: 86, cat: 'multistep', photo: P.city, prompt: 'crop to a square, make it warmer and add the title SUMMER' },
{ id: 87, cat: 'multistep', photo: P.fjord, prompt: 'make it black and white and add a vignette' },
{ id: 88, cat: 'multistep', photo: P.hills, prompt: 'brighten it and sharpen it' },
{ id: 89, cat: 'multistep', photo: P.city, setup: 'text', prompt: 'warm it up and add a drop shadow to the title' },
{ id: 90, cat: 'multistep', photo: P.portrait, prompt: 'remove the background and make it brighter' },
{ id: 91, cat: 'multistep', photo: P.fjord, prompt: 'crop to 16:9 and give it a moody cinematic look' },
{ id: 92, cat: 'multistep', photo: P.hills, prompt: 'flip it horizontally and add a vignette' },
{ id: 93, cat: 'multistep', photo: P.city, prompt: 'sharpen it and increase the contrast' },
{ id: 94, cat: 'multistep', photo: P.fjord, prompt: 'make it punchy and warm it up' },
{ id: 95, cat: 'multistep', photo: P.hills, prompt: 'rotate the whole image 90 and add a vignette' },
{ id: 96, cat: 'multistep', photo: P.sky, prompt: 'add the title HELLO and a drop shadow' },
{ id: 97, cat: 'multistep', photo: P.fjord, prompt: 'select the subject, invert it and blur it' },
{ id: 98, cat: 'multistep', photo: P.old, prompt: 'make it black and white then add a title' },
{ id: 99, cat: 'multistep', photo: P.city, prompt: 'warm and brighten with a vignette' },
{ id: 100, cat: 'multistep', photo: P.hills, prompt: 'crop to a square and make it vintage' },
// ---- v2.1 harder prompts (30): multi-step with generative, poster/text
// design, reference looks, and targeted local recolours. ----
// hardmulti (10): compound instructions where at least one step is generative.
{ id: 101, cat: 'hardmulti', photo: P.city, prompt: 'remove the person on the left and then warm up the photo' },
{ id: 102, cat: 'hardmulti', photo: P.portrait, prompt: 'put her on a beach and add a warm sunset' },
{ id: 103, cat: 'hardmulti', photo: P.fjord, prompt: 'add a full moon and make the sky more purple' },
{ id: 104, cat: 'hardmulti', photo: P.city, prompt: 'remove the sign and add some flowers' },
{ id: 105, cat: 'hardmulti', photo: P.portrait, prompt: 'replace the background with mountains and make it cooler' },
{ id: 106, cat: 'hardmulti', photo: P.sky, prompt: 'add dramatic clouds then increase the contrast' },
{ id: 107, cat: 'hardmulti', photo: P.hills, prompt: 'make it snowy and add a warm glow' },
{ id: 108, cat: 'hardmulti', photo: P.street, prompt: 'remove the red car and add a bicycle' },
{ id: 109, cat: 'hardmulti', photo: P.sky, prompt: 'extend to 16:9 with generative fill and add a title SUMMER' },
{ id: 110, cat: 'hardmulti', photo: P.city, prompt: 'add rain and make it moody' },
// poster (8): title / tagline text design.
{ id: 111, cat: 'poster', photo: P.canyon, prompt: 'make a movie poster titled THE SUMMIT' },
{ id: 112, cat: 'poster', photo: P.fjord, prompt: 'add a bold title "ESCAPE" at the bottom and a tagline' },
{ id: 113, cat: 'poster', photo: P.canyon, prompt: 'add large centred title text in white with a drop shadow' },
{ id: 114, cat: 'poster', photo: P.fjord, prompt: 'make a travel poster titled VISIT NORWAY' },
{ id: 115, cat: 'poster', photo: P.face, prompt: 'add the title "DREAM" in a stylish font and a thin border' },
{ id: 116, cat: 'poster', photo: P.canyon, prompt: 'turn it into a vintage travel poster with the text EXPLORE' },
{ id: 117, cat: 'poster', photo: P.city, setup: 'text', prompt: 'add a subtitle under the title and align it to the centre' },
{ id: 118, cat: 'poster', photo: P.fjord, prompt: 'design a minimalist poster: add the title "SILENCE" and desaturate the background' },
// reference (20): "make it look like <style>" — any film, photographer or era.
{ id: 119, cat: 'reference', photo: P.city, prompt: 'make it look like a Wes Anderson film' },
{ id: 120, cat: 'reference', photo: P.hills, prompt: 'give it a Studio Ghibli animated look' },
{ id: 121, cat: 'reference', photo: P.face, prompt: 'make it look like a 1970s film photograph' },
{ id: 122, cat: 'reference', photo: P.city, prompt: 'give it a Blade Runner neon night look' },
{ id: 123, cat: 'reference', photo: P.canyon, prompt: 'make it look like a National Geographic photo' },
{ id: 124, cat: 'reference', photo: P.portrait, prompt: 'make it look like a vintage Polaroid' },
{ id: 131, cat: 'reference', photo: P.fjord, prompt: 'make it look like a Kodak film photograph' },
{ id: 132, cat: 'reference', photo: P.city, prompt: 'give it a cinematic teal and orange movie look' },
{ id: 133, cat: 'reference', photo: P.canyon, prompt: 'make it look like an old western film' },
{ id: 134, cat: 'reference', photo: P.hills, prompt: 'make it look like a National Geographic wildlife photo' },
{ id: 135, cat: 'reference', photo: P.city, prompt: 'give it a moody film noir look' },
{ id: 136, cat: 'reference', photo: P.street, prompt: 'make it look like a 1980s VHS still' },
{ id: 137, cat: 'reference', photo: P.lake, prompt: 'make it look like a golden hour film photograph' },
{ id: 138, cat: 'reference', photo: P.hills, prompt: 'give it a Kurosawa colour film look' },
{ id: 139, cat: 'reference', photo: P.fjord, prompt: 'make it look like a David Attenborough nature documentary' },
{ id: 140, cat: 'reference', photo: P.portrait2, prompt: 'make it look like a 1990s disposable camera photo' },
{ id: 141, cat: 'reference', photo: P.sunset, prompt: 'give it a Technicolor vintage look' },
{ id: 142, cat: 'reference', photo: P.lake, prompt: 'make it look like a black and white Ansel Adams landscape' },
{ id: 143, cat: 'reference', photo: P.city, prompt: 'make it look like a Wong Kar-wai film' },
{ id: 144, cat: 'reference', photo: P.old, prompt: 'give it a faded Kodachrome look' },
// targeted (6): recolour only a specific object/region.
{ id: 125, cat: 'targeted', photo: P.street, prompt: 'make only the red car blue' },
{ id: 126, cat: 'targeted', photo: P.street, prompt: 'change the yellow car to red' },
{ id: 127, cat: 'targeted', photo: P.canyon, prompt: 'recolour the red rock to teal' },
{ id: 128, cat: 'targeted', photo: P.fjord, prompt: 'turn the sky purple but keep the water natural' },
{ id: 129, cat: 'targeted', photo: P.city, prompt: 'make the flowers bright pink' },
{ id: 130, cat: 'targeted', photo: P.old, prompt: 'colourise the faded photo naturally' },
// bgremove (6): every common background-removal phrasing must map to the
// deterministic ai.removeBackground op, never the AI agent / a tone.
{ id: 145, cat: 'bgremove', photo: P.portrait, prompt: 'remove the background' },
{ id: 146, cat: 'bgremove', photo: P.portrait, prompt: 'bg removal' },
{ id: 147, cat: 'bgremove', photo: P.portrait, prompt: 'remove bg' },
{ id: 148, cat: 'bgremove', photo: P.portrait, prompt: 'no background' },
{ id: 149, cat: 'bgremove', photo: P.portrait, prompt: 'make the background transparent' },
{ id: 150, cat: 'bgremove', photo: P.portrait, prompt: 'cut out the subject' },
// ---- vague / misspelled (5) — the planner must not silently apply a
// default brightness/vibrance tweak; it now falls back to the image model so
// the prompt still does something meaningful.
{ id: 151, cat: 'vague', photo: P.city, prompt: 'make it better' },
{ id: 152, cat: 'vague', photo: P.fjord, prompt: 'fix it' },
{ id: 153, cat: 'vague', photo: P.portrait, prompt: 'enhace the pic' },
{ id: 154, cat: 'vague', photo: P.hills, prompt: 'do the thing' },
{ id: 155, cat: 'vague', photo: P.portrait, prompt: 'make it look proffesional' },
// ---- auto fall-through (model) — a generative prompt in Auto mode must land
// as ONE NEW layer with a single undo group; a vague/unrecognised prompt now
// falls through to the image model too (no dead-end "not sure" reply). ----
{ id: 156, cat: 'auto', photo: P.city, prompt: 'add a man to the scene' },
{ id: 157, cat: 'auto', photo: P.sky, prompt: 'add a hot air balloon' },
{ id: 158, cat: 'auto', photo: P.fjord, prompt: 'do the thing' },
// ---- restyle — must route to ai_edit_image (model edit), NOT ask and NOT
// a filter tweak; run with MODE=agent to exercise the server planner. ----
{ id: 159, cat: 'restyle', photo: P.fjord, prompt: 'turn it into an oil painting' },
{ id: 160, cat: 'restyle', photo: P.hills, prompt: 'make it look like a watercolour' },
{ id: 161, cat: 'restyle', photo: P.portrait, prompt: 'make it look like an anime' },
{ id: 162, cat: 'restyle', photo: P.city, prompt: 'make it a comic book illustration' },
];
// Cases that load a heavy in-process model on the app host (remove-background,
// ~9.5 GB) and can OOM-kill the user's service; HARNESS is moving these to the
// seneca image server. Marked pending instead of executed so a bench run never
// crashes the app. Extend with SKIP=<ids> (e.g. SAM-heavy cases) if needed.
// Heavy in-process cases to skip. Default #90 (remove-background) while it
// risked the 7011 OOM; now that heavy image ops proxy to seneca, set
// PENDING= (empty) to run it.
const PENDING = new Set(String(process.env.PENDING != null ? process.env.PENDING : '90')
.split(',').map(s => Number(s.trim())).filter(Boolean));
for (const id of String(process.env.SKIP || '').split(',').map(s => Number(s.trim())).filter(Boolean)) PENDING.add(id);
function authToken() {
const data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
const now = Date.now() / 1000;
for (const [token, info] of Object.entries(data)) {
if (info.expiry == null || info.expiry > now) return token;
}
throw new Error(`no unexpired session in ${SESSION_FILE}`);
}
function selected() {
let cases = CASES;
if (process.env.CATS) {
const cats = new Set(process.env.CATS.split(',').map(s => s.trim()));
cases = cases.filter(c => cats.has(c.cat));
}
if (process.env.PROMPTS) {
const want = new Set(process.env.PROMPTS.split(',').map(s => Number(s.trim())));
cases = cases.filter(c => want.has(c.id));
}
return cases;
}
function saveShot(url, file) {
const idx = url.indexOf(',');
fs.writeFileSync(file, Buffer.from(url.slice(idx + 1), 'base64'));
}
(async () => {
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({ headless: !process.env.HEADFUL });
const context = await browser.newContext({ viewport: { width: 1280, height: 860 }, serviceWorkers: 'block' });
await context.addCookies([{ name: 'odysseus_session', value: authToken(), domain: new URL(BASE).hostname, path: '/' }]);
const page = await context.newPage();
page.on('pageerror', e => console.log(' [pageerror]', String(e).slice(0, 240)));
// Per-case image-op HTTP statuses so a real no-op (request succeeded but the
// image is unchanged) is distinguishable from an outage (instant 5xx/000).
let netLog = [];
page.on('response', r => {
const m = r.url().match(/\/api\/image\/([a-z-]+)/);
if (m) netLog.push({ op: m[1], status: r.status() });
});
page.on('requestfailed', r => {
const m = r.url().match(/\/api\/image\/([a-z-]+)/);
if (m) netLog.push({ op: m[1], status: 'failed:' + ((r.failure() && r.failure().errorText) || '?') });
});
const results = [];
for (const c of selected()) {
const tag = String(c.id).padStart(3, '0') + '-' + c.cat + '-' + c.prompt.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
const rec = { id: c.id, cat: c.cat, prompt: c.prompt, photo: c.photo, ms: null, ok: null, blocked: null, reply: null, ops: [], newLayers: [], undoGroups: null, error: null, tag };
if (PENDING.has(c.id)) {
rec.pending = true;
results.push(rec);
console.log(`BENCH PENDING #${c.id} [${c.cat}] "${c.prompt}" (heavy in-process model; excluded)`);
fs.writeFileSync(path.join(OUT, 'quickedit-bench.json'), JSON.stringify({ base: BASE, results }, null, 2));
continue;
}
netLog = [];
try {
await page.goto(BASE + '/?nodraft=1', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 30000 });
await page.evaluate(async () => {
const gallery = await import('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').first().waitFor({ state: 'attached', timeout: 30000 });
await page.evaluate(() => document.getElementById('gallery-editor-tab')?.click());
const dataUrl = 'data:image/jpeg;base64,' + fs.readFileSync(path.join(PHOTOS, c.photo)).toString('base64');
const selfCheck = process.env.SELFCHECK !== '0';
const mode = process.env.MODE || '';
await page.evaluate(async ({ selfCheck, mode, dataUrl }) => {
const editor = await import('/static/js/galleryEditor.js?qb=' + Date.now());
window.__geNoDraft = true;
if (editor.isEditorOpen()) editor.closeEditor({ force: true });
window.geQuickEditSelfCheck = selfCheck;
try { if (mode) localStorage.setItem('ge-ai-command-mode', mode); else localStorage.removeItem('ge-ai-command-mode'); } catch (_) {}
try { localStorage.setItem('ge-preflight-dismissed', '1'); } catch (_) {}
// Size the document to the photo's aspect so importing never leaves
// white letterbox margins (which the model then paints as an edge band).
const dim = await new Promise((res) => { const im = new Image(); im.onload = () => res({ w: im.naturalWidth || 800, h: im.naturalHeight || 600 }); im.onerror = () => res({ w: 800, h: 600 }); im.src = dataUrl; });
const W = 800; const H = Math.max(1, Math.min(1600, Math.round(W * dim.h / dim.w)));
editor.openEditor(null, null, { w: W, h: H }, 'BENCH ' + Date.now());
}, { selfCheck, mode, dataUrl });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 30000 });
await page.waitForFunction(() => !!window.geEditorControl && !!window.geEditorAgent, null, { timeout: 20000 });
const out = await page.evaluate(async ({ dataUrl, prompt, setup }) => {
const invoke = (op, params) => window.geEditorControl.invoke(op, params);
await invoke('document.importImage', { dataUrl, name: 'photo', raster: true });
if (setup === 'text') await invoke('text.add', { text: 'Summer', x: 80, y: 80, size: 64 });
else if (setup === 'align') await invoke('layers.addRaster', {});
const before = (await invoke('screenshot')).result.dataUrl;
const mark0 = (await invoke('history.mark')).result;
const layers0 = (await invoke('layers.list')).result.map(l => l.name + ':' + l.kind);
const ops = [];
const control = window.geEditorControl;
const orig = control.invoke.bind(control);
control.invoke = async (op, params) => { ops.push(op); return orig(op, params); };
window.__selfCheckLog = [];
const t0 = Date.now();
let run = null, error = null;
try {
run = window.geQuickEditRun
? await window.geQuickEditRun(prompt, {})
: await window.geEditorAgent.run(prompt, {});
}
catch (err) { error = String((err && err.message) || err); }
finally { control.invoke = orig; }
const ms = Date.now() - t0;
const after = (await invoke('screenshot')).result.dataUrl;
const mark1 = (await invoke('history.mark')).result;
const layers1 = (await invoke('layers.list')).result;
const newLayers = layers1.map(l => l.name + ':' + l.kind).filter(s => !layers0.includes(s));
return { before, after, mark0, mark1, newLayers, ms, ops, reply: run && (run.reply || run.message), ok: run && run.ok, blocked: run && run.blocked, error, selfChecks: window.__selfCheckLog || [] };
}, { dataUrl, prompt: c.prompt, setup: c.setup || null });
rec.ms = out.ms; rec.ops = out.ops; rec.reply = out.reply; rec.ok = out.ok;
rec.blocked = out.blocked; rec.newLayers = out.newLayers;
rec.selfChecks = out.selfChecks || [];
rec.undoGroups = (typeof out.mark1 === 'number' && typeof out.mark0 === 'number') ? out.mark1 - out.mark0 : null;
rec.error = out.error;
rec.net = netLog.slice();
saveShot(out.before, path.join(OUT, tag + '-before.png'));
saveShot(out.after, path.join(OUT, tag + '-after.png'));
} catch (err) {
rec.error = String((err && err.message) || err);
}
results.push(rec);
for (const sc of (rec.selfChecks || [])) {
console.log(`SELFCHECK #${c.id} verdict=${JSON.stringify(sc.verdict)} retry=${sc.retry ? JSON.stringify(sc.retry) : 'null'}`);
}
console.log(`BENCH ${rec.ok === false ? 'FAIL' : 'OK'} #${c.id} [${c.cat}] "${c.prompt}" ${rec.ms}ms ops=${rec.ops.join(',') || rec.error} net=${JSON.stringify(rec.net || [])}`);
fs.writeFileSync(path.join(OUT, 'quickedit-bench.json'), JSON.stringify({ base: BASE, results }, null, 2));
}
const pending = results.filter(r => r.pending).length;
const ran = results.filter(r => !r.pending && r.ok !== false && !r.error).length;
console.log(`QUICKEDIT_BENCH ${ran}/${results.length - pending} ran without error (${pending} pending) -> ${path.join(OUT, 'quickedit-bench.json')}`);
await browser.close();
process.exit(0);
})().catch(err => { console.error('FAIL', err); process.exit(1); });
@@ -0,0 +1,157 @@
#!/usr/bin/env node
// Quick Edit coverage audit runner (standalone; not a Playwright spec).
//
// Drives the live editor agent for every prompt in CASES and grades each by
// whether an expected control op appears in the executed-step trace.
//
// Usage:
// BASE_URL=http://127.0.0.1:7011 SESSION_FILE=/path/sessions.json \
// NODE_PATH=/path/node_modules node quickedit-coverage.js
//
// PROMPTS=1,2,3 limit to a subset
// OUT=/tmp/qe output dir for the JSON report
// HEADFUL=1 show the browser
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('@playwright/test');
const BASE = process.env.BASE_URL || 'http://127.0.0.1:7011';
const OUT = process.env.OUT || '/tmp/opencode/qe-coverage';
const SESSION_FILE = process.env.SESSION_FILE || '/home/pewds/odysseus-cookbook-fresh/data/sessions.json';
const CASES = [
{ id: 1, group: 'Fill', prompt: 'fill the white area with red', expect: ['fill.flood', 'selection.fill'] },
{ id: 2, group: 'Fill', prompt: 'flood fill the sky with blue', expect: ['fill.flood', 'selection.fill', 'selection.sky'] },
{ id: 3, group: 'Text', prompt: 'make the text bigger', expect: ['text.edit'], setup: 'text' },
{ id: 4, group: 'Text', prompt: 'make the title red', expect: ['text.edit'], setup: 'text' },
{ id: 5, group: 'Text', prompt: 'add the title "Summer"', expect: ['text.add'] },
{ id: 6, group: 'Layer style', prompt: 'add a drop shadow to the title', expect: ['layers.addStyle'], setup: 'text' },
{ id: 7, group: 'Layer style', prompt: 'add a stroke to the layer', expect: ['layers.addStyle'] },
{ id: 8, group: 'Layer style', prompt: 'add a colour overlay', expect: ['layers.addStyle'] },
{ id: 9, group: 'Align', prompt: 'align the logo to the centre', expect: ['layers.align'] },
{ id: 10, group: 'Align', prompt: 'align the layer to the left', expect: ['layers.align'] },
{ id: 11, group: 'Layer transform', prompt: 'rotate only this layer 90 degrees', expect: ['layer.rotate'] },
{ id: 12, group: 'Layer transform', prompt: 'flip this layer horizontally', expect: ['layer.flip'] },
{ id: 13, group: 'Document transform', prompt: 'rotate the whole image 90 degrees', expect: ['document.rotate'] },
{ id: 14, group: 'Document transform', prompt: 'flip the canvas vertically', expect: ['document.flip'] },
{ id: 15, group: 'Crop', prompt: 'crop to a square', expect: ['document.crop'] },
{ id: 16, group: 'Crop', prompt: 'make this 16:9', expect: ['document.crop'] },
{ id: 17, group: 'Crop', prompt: 'crop to 9:16 for a story', expect: ['document.crop'] },
{ id: 18, group: 'Extend', prompt: 'extend the canvas to 16:9', expect: ['ai.outpaint'] },
{ id: 19, group: 'Clipboard', prompt: 'copy the selection', expect: ['selection.copy'], setup: 'select' },
{ id: 20, group: 'Clipboard', prompt: 'paste', expect: ['selection.paste'], setup: 'select' },
{ id: 21, group: 'Selection', prompt: 'select the subject', expect: ['selection.subject'] },
{ id: 22, group: 'Selection', prompt: 'select the sky', expect: ['selection.sky'] },
{ id: 23, group: 'Selection', prompt: 'select all', expect: ['selection.selectAll'] },
{ id: 24, group: 'Selection', prompt: 'invert the selection', expect: ['selection.invert'] },
{ id: 25, group: 'Background', prompt: 'remove the background', expect: ['ai.removeBackground', 'selection.background'] },
{ id: 26, group: 'Background', prompt: 'blur the background', expect: ['filters.applyMasked', 'filters.apply'] },
{ id: 27, group: 'Adjustment', prompt: 'make it brighter', expect: ['photo.autoTone', 'adjustments.apply', 'shadows-highlights'] },
{ id: 28, group: 'Adjustment', prompt: 'make it black and white', expect: ['adjustments.apply', 'black-white'], assert: 'black' },
{ id: 29, group: 'Adjustment', prompt: 'warm it up', expect: ['adjustments.apply', 'photo.autoTone', 'white-balance'] },
{ id: 30, group: 'Adjustment', prompt: 'make it punchy', expect: ['photo.autoTone', 'adjustments.apply', 'vibrance'] },
{ id: 31, group: 'Adjustment', prompt: 'make it moody and cinematic', expect: ['adjustments.apply', 'white-balance'] },
{ id: 32, group: 'Restore', prompt: 'restore this old photo', expect: ['photo.autoTone', 'adjustments.apply'] },
{ id: 33, group: 'Retouch', prompt: 'smooth the skin', expect: ['retouch.skin', 'adjustments.apply'] },
{ id: 34, group: 'Retouch', prompt: 'whiten the teeth', expect: ['retouch.teeth', 'adjustments.apply'] },
{ id: 35, group: 'Retouch', prompt: 'remove the blemishes', expect: ['retouch.blemish', 'retouch.skin', 'adjustments.apply'] },
{ id: 36, group: 'Filter', prompt: 'sharpen it', expect: ['filters.apply', 'adjustments.apply', 'photo.autoTone'] },
{ id: 37, group: 'Filter', prompt: 'add a vignette', expect: ['filters.apply', 'adjustments.apply'] },
{ id: 38, group: 'Layers', prompt: 'add a new layer', expect: ['layers.addRaster', 'layers.addAdjustment'] },
{ id: 39, group: 'Layers', prompt: 'duplicate this layer', expect: ['layers.duplicate'] },
{ id: 40, group: 'Layers', prompt: 'flatten the image', expect: ['layers.flatten'] },
{ id: 41, group: 'Layers', prompt: 'merge the layer down', expect: ['layers.mergeDown'] },
{ id: 42, group: 'Document', prompt: 'save as PSD', expect: ['document.savePsd'] },
{ id: 43, group: 'Document', prompt: 'resize to 1920 wide', expect: ['document.resizeCanvas'] },
{ id: 44, group: 'AI edit', prompt: 'remove the person on the left', expect: ['ai.generativeFill'] },
{ id: 45, group: 'AI generate', prompt: 'add a full moon in the sky', expect: ['ai.generativeFill'] },
];
function authToken() {
const data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
const now = Date.now() / 1000;
for (const [token, info] of Object.entries(data)) {
if (info.expiry == null || info.expiry > now) return token;
}
throw new Error(`no unexpired session in ${SESSION_FILE}`);
}
function selected() {
const raw = process.env.PROMPTS;
if (!raw) return CASES;
const want = new Set(raw.split(',').map(s => Number(s.trim())));
return CASES.filter(c => want.has(c.id));
}
(async () => {
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({ headless: !process.env.HEADFUL });
const context = await browser.newContext({ viewport: { width: 1280, height: 860 }, serviceWorkers: 'block' });
await context.addCookies([{ name: 'odysseus_session', value: authToken(), domain: new URL(BASE).hostname, path: '/' }]);
const page = await context.newPage();
page.on('pageerror', e => console.log(' [pageerror]', String(e).slice(0, 240)));
const results = [];
for (const c of selected()) {
let record = { id: c.id, group: c.group, prompt: c.prompt, expect: c.expect, ops: [], pass: false, error: null };
try {
await page.goto(BASE + '/?nodraft=1', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 30000 });
await page.evaluate(async () => {
const gallery = await import('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').first().waitFor({ state: 'attached', timeout: 30000 });
await page.evaluate(() => document.getElementById('gallery-editor-tab')?.click());
await page.evaluate(async () => {
const editor = await import('/static/js/galleryEditor.js?qe=' + Date.now());
window.__geNoDraft = true;
if (editor.isEditorOpen()) editor.closeEditor({ force: true });
editor.openEditor(null, null, { w: 640, h: 480 }, 'QE ' + Date.now());
});
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 30000 });
await page.waitForFunction(() => !!window.geEditorControl, null, { timeout: 20000 });
if (c.setup) {
await page.evaluate(async (setup) => {
const control = window.geEditorControl;
if (setup === 'text') await control.invoke('text.add', { text: 'Summer', x: 80, y: 80, size: 64 });
else if (setup === 'select') await control.invoke('selection.rectangle', { x: 100, y: 100, width: 200, height: 160 });
}, c.setup);
}
const trace = await page.evaluate(async (prompt) => {
const ops = [];
const control = window.geEditorControl;
const orig = control.invoke.bind(control);
control.invoke = async (op, params) => { ops.push({ op, params }); return orig(op, params); };
let result = null, error = null;
try {
result = window.geQuickEditRun
? await window.geQuickEditRun(prompt, {})
: await window.geEditorAgent.run(prompt, {});
}
catch (err) { error = String((err && err.message) || err); }
finally { control.invoke = orig; }
return { ops, reply: result && (result.reply || result.message), error };
}, c.prompt);
record.ops = trace.ops.map(o => o.op);
record.reply = trace.reply;
record.error = trace.error;
const opHit = c.expect.some(e => record.ops.some(o => o === e || o.startsWith(e + '.')));
const assertHit = !c.assert || trace.ops.some(o => JSON.stringify(o.params || {}).includes(c.assert));
record.pass = opHit && assertHit;
} catch (err) {
record.error = String((err && err.message) || err);
}
results.push(record);
console.log(`QE ${record.pass ? 'PASS' : 'FAIL'} #${c.id} [${c.group}] "${c.prompt}" -> ${record.ops.join(', ') || record.error}`);
}
const pass = results.filter(r => r.pass).length;
fs.writeFileSync(path.join(OUT, 'quickedit-coverage.json'), JSON.stringify({ base: BASE, pass, total: results.length, results }, null, 2));
console.log(`QUICKEDIT_COVERAGE ${pass}/${results.length} passed -> ${path.join(OUT, 'quickedit-coverage.json')}`);
await browser.close();
process.exit(0);
})().catch(err => { console.error('FAIL', err); process.exit(1); });
@@ -1,28 +1,48 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, editorState } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, selectTool } = require('./helpers.js');
test('paint tools offer rasterization, cancel preserves text, Enter accepts and undo restores it', async ({ page }) => {
test('paint tools require confirmation before rasterizing text layers, preserving text on cancel', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvas.x + 80, canvas.y + 80);
const text = page.locator('.ge-direct-text-editor');
await text.fill('Keep editable');
await text.press('Control+Enter');
const retained = (await editorState(page)).layers.find(layer => layer.kind === 'text');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await expect(page.locator('#styled-confirm-ok')).toHaveText('Rasterize');
await page.keyboard.press('Escape');
expect(retained).toBeDefined();
const kind = async () => (await editorState(page)).layers.find(layer => layer.id === retained.id)?.kind;
// Tool selection alone does not prompt and does not rasterize
for (const tool of ['brush', 'eraser']) {
await selectTool(page, tool);
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect(await kind()).toBe('text');
}
// Attempting to draw triggers confirmation
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.25, y: 0.2 });
await expect(page.locator('#styled-confirm-overlay')).toBeVisible();
// Cancelling keeps text editable with no changes
await page.locator('#styled-confirm-cancel').click();
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await expect(page.locator('#styled-confirm-ok')).toBeFocused();
await page.keyboard.press('Enter');
expect(await kind()).toBe('text');
// Second stroke prompt confirmed -> rasterizes layer
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.25, y: 0.2 });
await expect(page.locator('#styled-confirm-overlay')).toBeVisible();
await page.locator('#styled-confirm-ok').click();
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('raster');
expect(await kind()).toBe('raster');
// Subsequent strokes on the raster layer draw immediately without prompting
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.35, y: 0.3 });
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
// Undo restores editable text
await page.keyboard.press('Control+z');
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await expect(page.locator('#styled-confirm-ok')).toBeVisible();
await page.keyboard.press('Escape');
await page.keyboard.press('Control+z');
expect(await kind()).toBe('text');
});
@@ -1,9 +1,10 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
test('active editor draft reopens after a hard refresh', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Refresh recovery E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
const before = await editorState(page);
const draftId = await waitForDraft(page);
@@ -11,8 +12,15 @@ test('active editor draft reopens after a hard refresh', async ({ page }) => {
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('#gallery-editor-tab')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('.gallery-editor')).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => (await editorState(page)).draftId, { timeout: 20_000 }).toBe(draftId);
await expect.poll(async () => (await editorState(page)).documentRenderReady, { timeout: 20_000 }).toBe(true);
const readState = async (key) => {
try {
return (await editorState(page))[key];
} catch {
return null;
}
};
await expect.poll(() => readState('draftId'), { timeout: 20_000 }).toBe(draftId);
await expect.poll(() => readState('documentRenderReady'), { timeout: 20_000 }).toBe(true);
const after = await editorState(page);
expect(after.layers).toEqual(before.layers);
@@ -1,9 +1,10 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
test('selection boundary animates, moves independently, and supports Quick Mask', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection workflow');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
const initial = await editorState(page);
@@ -44,20 +45,10 @@ test('selection boundary animates, moves independently, and supports Quick Mask'
expect((await editorState(page)).quickMaskActive).toBe(false);
});
test('marquee supports exact fixed-size and fixed-ratio geometry', async ({ page }) => {
test('marquee supports exact fixed-ratio geometry', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Precise marquee');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await page.locator('#ge-marquee-constraint').selectOption('size');
await page.locator('#ge-marquee-width').fill('80');
await page.locator('#ge-marquee-width').blur();
await page.locator('#ge-marquee-height').fill('60');
await page.locator('#ge-marquee-height').blur();
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height * 0.75);
await expect.poll(async () => (await editorState(page)).selection?.bounds).toEqual({ x: 240, y: 180, width: 80, height: 60 });
await page.locator('#ge-marquee-clear').click();
await page.locator('#ge-marquee-constraint').selectOption('ratio');
await page.locator('#ge-marquee-width').fill('4');
await page.locator('#ge-marquee-width').blur();
@@ -72,14 +63,14 @@ test('marquee supports exact fixed-size and fixed-ratio geometry', async ({ page
test('transform selection moves, scales, rotates, cancels, and preserves layer pixels', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Transform selection');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.5, y: 0.55 });
const original = await editorState(page);
const originalLayer = original.layers.find(layer => layer.id === original.activeLayerId);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="transform"]').click();
await expect(page.locator('.ge-transform-popup')).toBeVisible();
await expect(page.locator('#ge-transform-section')).toBeVisible();
await expect(page.locator('.ge-transform-popup .ge-adj-title')).toHaveText('Transform Selection');
await dragOnCanvas(page, { x: 0.35, y: 0.4 }, { x: 0.45, y: 0.47 });
const moved = await editorState(page);
@@ -115,20 +106,20 @@ test('transform selection moves, scales, rotates, cancels, and preserves layer p
await page.waitForTimeout(250);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="transform"]').click();
const mobilePopup = await page.locator('.ge-transform-popup').boundingBox();
const mobileTitle = await page.locator('.ge-transform-popup .ge-adj-title').boundingBox();
expect(mobilePopup.x).toBeGreaterThanOrEqual(0);
expect(mobilePopup.y).toBeGreaterThanOrEqual(0);
expect(mobilePopup.x + mobilePopup.width).toBeLessThanOrEqual(390);
expect(mobilePopup.y + mobilePopup.height).toBeLessThanOrEqual(844);
expect(mobileTitle.x).toBeGreaterThanOrEqual(mobilePopup.x);
expect(mobileTitle.x + mobileTitle.width).toBeLessThanOrEqual(mobilePopup.x + mobilePopup.width);
await expect(page.locator('#ge-transform-section')).toBeVisible();
const mobileSection = await page.locator('#ge-transform-section').boundingBox();
expect(mobileSection.x).toBeGreaterThanOrEqual(0);
expect(mobileSection.y).toBeGreaterThanOrEqual(0);
expect(mobileSection.x + mobileSection.width).toBeLessThanOrEqual(390);
expect(mobileSection.y + mobileSection.height).toBeLessThanOrEqual(844);
await page.locator('#ge-transform-cancel-btn').click();
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.transformActive)).toBe(false);
expect((await editorState(page)).selection.pixelHash).toBe(original.selection.pixelHash);
});
test('named selections support reselect, load, delete, and server-draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Saved selections');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.5, y: 0.6 });
const original = (await editorState(page)).selection;
@@ -179,3 +170,56 @@ test('named selections support reselect, load, delete, and server-draft reopen',
expect(mobileMenu.x).toBeGreaterThanOrEqual(0);
expect(mobileMenu.x + mobileMenu.width).toBeLessThanOrEqual(390);
});
test('marquee released over the right panel commits a clamped selection', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Marquee overshoot');
await selectTool(page, 'marquee');
const setup = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const rect = state.mainCanvas.getBoundingClientRect();
const panel = document.querySelector('.ge-right-panel');
const panelBox = panel ? panel.getBoundingClientRect() : null;
return {
document: { width: state.imgWidth, height: state.imgHeight },
start: { x: rect.left + rect.width * 0.2, y: rect.top + rect.height * 0.2 },
panelPoint: panelBox
? { x: panelBox.left + panelBox.width / 2, y: rect.top + rect.height * 0.6 }
: null,
};
});
expect(setup.panelPoint, 'right panel must be visible on desktop').toBeTruthy();
await page.mouse.move(setup.start.x, setup.start.y);
await page.mouse.down();
// Drag straight onto the right panel. The window mousemove guard must
// keep the raw rect from extending off-canvas, and the release must
// commit a clamped selection rather than discard it.
await page.mouse.move(setup.panelPoint.x, setup.panelPoint.y, { steps: 12 });
await page.mouse.up();
const result = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
if (!state.wandMask) return { active: false };
const data = state.wandMask.getContext('2d')
.getImageData(0, 0, state.wandMask.width, state.wandMask.height).data;
let left = state.wandMask.width, top = state.wandMask.height, right = -1, bottom = -1;
for (let y = 0; y < state.wandMask.height; y += 1) {
for (let x = 0; x < state.wandMask.width; x += 1) {
if (data[(y * state.wandMask.width + x) * 4 + 3] < 1) continue;
left = Math.min(left, x); top = Math.min(top, y);
right = Math.max(right, x); bottom = Math.max(bottom, y);
}
}
return { active: true, left, top, right, bottom, width: state.wandMask.width, height: state.wandMask.height };
});
expect(result.active).toBe(true);
expect(result.right).toBeGreaterThanOrEqual(result.left);
expect(result.bottom).toBeGreaterThanOrEqual(result.top);
// Clamped: never beyond the document edge on any side.
expect(result.left).toBeGreaterThanOrEqual(0);
expect(result.top).toBeGreaterThanOrEqual(0);
expect(result.right).toBeLessThan(result.width);
expect(result.bottom).toBeLessThan(result.height);
expect(result.right - result.left).toBeGreaterThan(0);
expect(result.bottom - result.top).toBeGreaterThan(0);
});
+15 -12
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
async function openRefine(page) {
@@ -23,9 +24,9 @@ async function drawLasso(page, points) {
test('selection clipboard copy and paste creates an undoable independent layer', async ({ page, request }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection clipboard');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
const before = await editorState(page);
@@ -55,11 +56,11 @@ test('selection clipboard copy and paste creates an undoable independent layer',
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('cut selection is one undoable move from source to new layer', async ({ page }) => {
test('cut selection removes source pixels in one undoable step', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection cut');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
const before = await editorState(page);
@@ -76,9 +77,11 @@ test('cut selection is one undoable move from source to new layer', async ({ pag
});
const beforeSource = await sourceSignature();
await page.keyboard.press('Control+x');
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Wand copy' })).toBeVisible();
// Cut copies to the clipboard and erases the selected pixels in place —
// it does not create a new layer (that is Ctrl+Shift+J, Layer via Cut).
const cut = await editorState(page);
expect(cut.layers).toHaveLength(before.layers.length + 1);
expect(cut.layers).toHaveLength(before.layers.length);
expect(cut.selection).toBeNull();
expect(await sourceSignature()).not.toBe(beforeSource);
await page.locator('#ge-undo').click();
@@ -93,9 +96,9 @@ test('completed lasso copy preserves the source layer coordinate space', async (
state.layerOffsets.set(state.activeLayerId, { x: 32, y: 24 });
window.galleryEditorComposite?.();
});
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.28, y: 0.3 }, { x: 0.62, y: 0.58 });
await page.locator('.ge-tool-btn[data-tool="lasso"]').click();
await selectTool(page, 'lasso');
await drawLasso(page, [[0.26, 0.26], [0.68, 0.26], [0.68, 0.64], [0.26, 0.64]]);
const before = await editorState(page);
@@ -110,7 +113,7 @@ test('completed lasso copy preserves the source layer coordinate space', async (
test('selection refine previews safely and layer masks round-trip to selection', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection refinement');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.55, y: 0.55 });
const original = (await editorState(page)).selection;
@@ -132,7 +135,7 @@ test('selection refine previews safely and layer masks round-trip to selection',
expect(refined.bounds.width).toBeGreaterThan(original.bounds.width);
expect(refined.pixelHash).not.toBe(original.pixelHash);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
let current = await editorState(page);
const active = current.layers.find(layer => layer.id === current.activeLayerId);
expect(active.masks).toHaveLength(1);
@@ -149,7 +152,7 @@ test('selection refine previews safely and layer masks round-trip to selection',
test('lasso uses the shared replace, add, subtract, and intersect modes', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Lasso combine modes');
await page.locator('.ge-tool-btn[data-tool="lasso"]').click();
await selectTool(page, 'lasso');
await drawLasso(page, [[0.1, 0.2], [0.35, 0.2], [0.35, 0.55], [0.1, 0.55]]);
const first = (await editorState(page)).selection;
expect(first.source).toBe('lasso');
@@ -0,0 +1,46 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, selectTool } = require('./helpers.js');
// Editors opened wider than 700px move the tool controls into the options
// strip. At tablet widths (701-820px) that strip must stay visible, or every
// tool control is stranded in a hidden container.
for (const width of [768, 820]) {
test(`tool controls stay reachable at a ${width}px tablet width`, async ({ page }) => {
await page.setViewportSize({ width, height: 1024 });
await openBlankEditor(page, { width: 320, height: 240 }, `Tablet ${width}`);
const inViewport = locator => locator.evaluate(el => {
const box = el.getBoundingClientRect();
return box.width > 0 && box.left >= 0 && box.right <= innerWidth + 1 && box.bottom <= innerHeight + 1;
});
await selectTool(page, 'brush');
await expect(page.locator('#ge-options-bar')).toBeVisible();
await expect(page.locator('#ge-options-bar .ge-size-slider')).toBeVisible();
expect(await inViewport(page.locator('#ge-options-bar .ge-size-slider'))).toBe(true);
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-w')).toBeVisible();
await page.locator('#ge-transform-w').fill('200');
await page.locator('#ge-transform-w').dispatchEvent('input');
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.transformPendingW)).toBe(200);
await page.locator('#ge-transform-cancel-btn').click();
});
}
test('responsive transition across 700px breakpoint preserves dock/sheet layout and state', async ({ page }) => {
// Desktop -> Phone transition
await page.setViewportSize({ width: 1024, height: 768 });
await openBlankEditor(page, { width: 320, height: 240 }, 'Responsive transitions');
await expect(page.locator('.ge-right-panel')).toHaveClass(/ge-right-panel--dock/);
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeVisible();
// Resize across 700px to phone
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/ge-right-panel--dock/);
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeHidden();
// Resize back to desktop
await page.setViewportSize({ width: 1024, height: 768 });
await expect(page.locator('.ge-right-panel')).toHaveClass(/ge-right-panel--dock/);
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeVisible();
});
@@ -0,0 +1,42 @@
const { test, expect } = require('@playwright/test');
const crypto = require('crypto');
const { openBlankEditor } = require('./helpers');
const FONTS = [
'Inter', 'Roboto', 'Playfair Display', 'Lobster',
'Bebas Neue', 'Roboto Mono', 'Space Grotesk', 'Fraunces',
'Syne', 'Bungee', 'Instrument Serif', 'Georgia', 'Courier New',
];
const md5 = (buf) => crypto.createHash('md5').update(buf).digest('hex');
test('text font dropdown renders a distinct face for each font', async ({ page }) => {
await openBlankEditor(page, { width: 900, height: 600 }, 'Font swap');
await page.evaluate(() => {
document.querySelectorAll('.ge-tour-close,.ge-protip,.ge-tip,[class*="protip"]').forEach((e) => e.remove());
});
await page.evaluate(async () => {
const tool = document.querySelector('[data-tool="text"]');
if (tool) tool.click();
await window.geEditorControl.invoke('text.add', { text: 'AWjgyq 128', x: 60, y: 80 });
});
await page.waitForTimeout(400);
const box = await page.locator('.ge-main-canvas').boundingBox();
expect(box).toBeTruthy();
const hashes = {};
for (const font of FONTS) {
await page.evaluate((f) => {
const sel = document.getElementById('ge-text-font');
sel.value = f;
sel.dispatchEvent(new Event('input', { bubbles: true }));
sel.dispatchEvent(new Event('change', { bubbles: true }));
}, font);
await page.waitForTimeout(250);
const shot = await page.screenshot({ clip: box });
hashes[font] = md5(shot);
}
const distinct = new Set(Object.values(hashes)).size;
expect(distinct, `distinct font hashes: ${JSON.stringify(hashes)}`).toBeGreaterThanOrEqual(5);
});
+22 -9
View File
@@ -1,9 +1,11 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, openOptionsMore, reopenDraft, waitForDraft } = require('./helpers.js');
test('text edits directly on canvas and remains retained after reopen', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
await openOptionsMore(page, 'text');
await page.locator('#ge-text-frame-width').fill('260');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvas.x + canvas.width * 0.2, canvas.y + canvas.height * 0.2);
@@ -13,11 +15,13 @@ test('text edits directly on canvas and remains retained after reopen', async ({
await editor.press('Control+Enter');
await expect(editor).toHaveCount(0);
await openOptionsMore(page, 'text');
await page.locator('#ge-text-letter-spacing').fill('3.5');
await page.locator('#ge-text-line-height').fill('1.4');
await page.locator('#ge-text-font').selectOption('Georgia');
await page.locator('.ge-text-auto-width-option').click();
await expect(page.locator('#ge-text-auto-width')).toBeChecked();
await openOptionsMore(page, 'text');
await expect(page.locator('#ge-text-frame-width')).toBeDisabled();
await page.locator('#ge-text-frame-height').fill('180');
await page.locator('#ge-text-vertical-align').selectOption('bottom');
@@ -42,17 +46,22 @@ test('text edits directly on canvas and remains retained after reopen', async ({
test('text paragraph controls remain usable in a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile text layout E2E');
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
const section = page.locator('#ge-text-section');
await expect(section).toBeVisible();
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await openOptionsMore(page, 'text');
await expect(page.locator('#ge-text-frame-height')).toBeVisible();
await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
expect(await page.locator('#ge-text-more').evaluate(el => {
const box = el.getBoundingClientRect();
return box.left >= 0 && box.right <= innerWidth + 1;
})).toBe(true);
});
test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await selectTool(page, 'shape');
await expect(page.locator('#ge-shape-section')).toBeVisible();
const types = ['rectangle', 'ellipse', 'line', 'polygon'];
@@ -70,6 +79,7 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
let current = await editorState(page);
const shapes = current.layers.filter(layer => layer.kind === 'shape');
expect(shapes.map(layer => layer.shape.type)).toEqual(types);
await openOptionsMore(page, 'shape');
await page.locator('#ge-shape-sides').fill('7');
await page.locator('#ge-shape-stroke-width').fill('6');
await page.locator('#ge-shape-radius').fill('14');
@@ -77,7 +87,7 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
const polygon = current.layers.find(layer => layer.kind === 'shape' && layer.shape.type === 'polygon');
expect(polygon.shape).toMatchObject({ sides: 7, strokeWidth: 6, cornerRadius: 14 });
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(originalWidth + 40));
await page.locator('#ge-transform-apply').click();
@@ -94,7 +104,8 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
test('gradient shape fill remains editable after reopening the project', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await selectTool(page, 'shape');
await openOptionsMore(page, 'shape');
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeHidden();
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.65, y: 0.55 });
await page.locator('#ge-shape-gradient-angle').evaluate((angle) => {
@@ -109,6 +120,7 @@ test('gradient shape fill remains editable after reopening the project', async (
angle.value = '35';
angle.dispatchEvent(new Event('input', { bubbles: true }));
});
await openOptionsMore(page, 'shape');
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible();
const before = await editorState(page);
@@ -140,8 +152,9 @@ test('gradient shape fill remains editable after reopening the project', async (
test('shape gradients retain added stops through editing and reopen', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E');
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await selectTool(page, 'shape');
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
await openOptionsMore(page, 'shape');
await page.locator('#ge-shape-fill-type').selectOption('linear-gradient');
await page.evaluate(() => {
for (const [id, value] of [['ge-shape-gradient-start', '#ff0000'], ['ge-shape-gradient-end', '#0000ff']]) {
@@ -220,7 +233,7 @@ test('shape gradient stop normalization keeps imported endpoints bounded', async
test('gradient tool paints a reversible drag on the active layer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Gradient tool E2E');
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
await selectTool(page, 'gradient');
await expect(page.locator('#ge-gradient-section')).toBeVisible();
await page.evaluate(() => {
@@ -309,7 +322,7 @@ test('gradient tool paints a reversible drag on the active layer', async ({ page
test('radial gradient remains retained and survives reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Radial gradient E2E');
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
await selectTool(page, 'gradient');
await page.locator('#ge-gradient-type').selectOption('radial-gradient');
await page.evaluate(() => {
const start = document.getElementById('ge-gradient-start');
+107 -12
View File
@@ -1,10 +1,11 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { openBlankEditor } = require('./helpers');
test('transform frame exposes eight accurate handles and supports edge resize', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform frame');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-transform-popup')).toBeVisible();
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-section')).toBeVisible();
const frame = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
@@ -43,7 +44,7 @@ test('transform frame exposes eight accurate handles and supports edge resize',
test('rotated edge resize follows the frame axis and keeps its opposite edge anchored', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Rotated transform frame');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-rot').fill('90');
const frame = await page.evaluate(async () => {
@@ -84,7 +85,7 @@ test('rotated edge resize follows the frame axis and keeps its opposite edge anc
test('rotated frame moves only from its visible interior and supports keyboard nudging', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform interaction');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-rot').fill('45');
const frame = await page.evaluate(async () => {
@@ -153,7 +154,7 @@ test('rotated frame moves only from its visible interior and supports keyboard n
})).toEqual({ x: 250, y: 180 });
await page.keyboard.press('Escape');
await expect(page.locator('.ge-transform-popup')).toBeHidden();
await expect(page.locator('#ge-transform-section')).toBeHidden();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformActive;
@@ -182,14 +183,14 @@ test('transform previews remain source-derived and reject unsafe allocations', a
return { width: layer.canvas.width, height: layer.canvas.height, hash: hash >>> 0 };
});
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.8)));
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
const afterSequentialPreviews = await layerSnapshot();
await page.locator('#ge-transform-cancel-btn').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
const afterDirectPreview = await layerSnapshot();
expect(afterSequentialPreviews).toEqual(afterDirectPreview);
@@ -214,23 +215,31 @@ test('mobile touch input can grab every transform handle', async ({ browser, bro
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile transform handles');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-transform-popup')).toBeVisible();
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-section')).toBeVisible();
const cdp = await context.newCDPSession(page);
const ids = ['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l', 'rot'];
const handlePoint = id => page.evaluate(async handleId => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const geometry = transformFrameGeometry({
const frame = {
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom, rotationInside: handleId === 'rot' });
const handle = geometry.handles.find(item => item.id === handleId);
};
const rect = state.mainCanvas.getBoundingClientRect();
const outside = transformFrameGeometry(frame, { zoom: state.zoom }).rotationHandle;
const x = rect.left + outside.x * rect.width / state.mainCanvas.width;
const y = rect.top + outside.y * rect.height / state.mainCanvas.height;
const area = state.container.querySelector('.ge-canvas-area').getBoundingClientRect();
const popup = state.transformPopup?.getBoundingClientRect();
const rotationInside = y < area.top + 6 || x < area.left + 6 || x > area.right - 6 ||
!!(popup && x >= popup.left && x <= popup.right && y >= popup.top && y <= popup.bottom);
const geometry = transformFrameGeometry(frame, { zoom: state.zoom, rotationInside });
const handle = geometry.handles.find(item => item.id === handleId);
return {
x: rect.left + handle.x * rect.width / state.mainCanvas.width,
y: rect.top + handle.y * rect.height / state.mainCanvas.height,
@@ -318,3 +327,89 @@ test('mobile touch input can grab every transform handle', async ({ browser, bro
await context.close();
}
});
test('transform handle keeps tracking when the pointer crosses the right panel', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform overshoot');
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-section')).toBeVisible();
const setup = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const geometry = transformFrameGeometry({
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom });
const right = geometry.resizeHandles.find(handle => handle.id === 'r');
const rect = state.mainCanvas.getBoundingClientRect();
const panel = document.querySelector('.ge-right-panel');
const panelBox = panel ? panel.getBoundingClientRect() : null;
return {
width: state.transformPendingW,
handle: {
x: rect.left + right.x * rect.width / state.mainCanvas.width,
y: rect.top + right.y * rect.height / state.mainCanvas.height,
},
// A point safely inside the right panel (editor chrome).
panelPoint: panelBox
? { x: panelBox.left + panelBox.width / 2, y: rect.top + rect.height / 2 }
: null,
};
});
expect(setup.panelPoint, 'right panel must be visible on desktop').toBeTruthy();
await page.mouse.move(setup.handle.x, setup.handle.y);
await page.mouse.down();
// Resize far to the right, ending over the right panel. The handle must
// keep tracking across chrome — an overshoot must not freeze the drag.
await page.mouse.move(setup.panelPoint.x, setup.panelPoint.y, { steps: 12 });
await page.mouse.up();
const after = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
});
expect(after).toBeGreaterThan(setup.width);
});
test('phone transform overlay follows the canvas through refits and zoom', async ({ browser }) => {
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 640, height: 480 }, 'Phone transform overlay');
const fitBefore = await page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoom);
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-section')).toBeVisible();
const alignment = () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const overlay = state.transformOverlay.getBoundingClientRect();
const canvas = state.mainCanvas.getBoundingClientRect();
return {
zoom: state.zoom,
overlayScale: overlay.width / state.transformOverlay.width,
covers: overlay.left <= canvas.left + 1 && overlay.top <= canvas.top + 1
&& overlay.right >= canvas.right - 1 && overlay.bottom >= canvas.bottom - 1,
};
});
// Selecting Transform collapses the Layers sheet, which refits the canvas.
await expect.poll(async () => (await alignment()).zoom).not.toBe(fitBefore);
let current = await alignment();
expect(Math.abs(current.overlayScale - current.zoom)).toBeLessThan(0.002);
expect(current.covers).toBe(true);
await page.locator('#ge-zoom-in').evaluate(button => button.click());
await expect.poll(async () => (await alignment()).zoom).toBeGreaterThan(current.zoom);
current = await alignment();
expect(Math.abs(current.overlayScale - current.zoom)).toBeLessThan(0.002);
} finally {
await context.close();
}
});
+15 -4
View File
@@ -10,10 +10,12 @@ const defaultPython = process.platform === 'win32'
? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe')
: path.join(repositoryRoot, '.venv', 'bin', 'python');
const python = process.env.ODYSSEUS_TEST_PYTHON || (fs.existsSync(defaultPython) ? defaultPython : 'python');
const tmpBase = fs.existsSync('/var/tmp') ? '/var/tmp' : os.tmpdir();
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-photo-editor-e2e-'));
|| fs.mkdtempSync(path.join(tmpBase, 'odysseus-photo-editor-e2e-'));
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH
|| path.join(dataDirectory, 'app.db');
const authenticated = process.env.PHOTO_EDITOR_E2E_AUTH !== '0';
process.env.PHOTO_EDITOR_E2E_DB_PATH = databasePath;
process.env.PHOTO_EDITOR_E2E_DATA_DIR = dataDirectory;
@@ -22,6 +24,9 @@ if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
}
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
module.exports = defineConfig({
testDir: path.join(__dirname, 'photo-editor'),
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
@@ -29,18 +34,24 @@ module.exports = defineConfig({
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
reporter: isCI ? 'line' : 'line',
globalSetup: require.resolve('./setup.js'),
globalTeardown: require.resolve('./teardown.js'),
use: {
baseURL,
browserName,
headless: true,
launchOptions: {
args: ['--disable-dev-shm-usage', '--no-sandbox'],
},
viewport: { width: 1440, height: 960 },
extraHTTPHeaders: { 'Accept-Encoding': 'identity' },
serviceWorkers: 'block',
...(authenticated ? { storageState: path.join(dataDirectory, 'browser-auth.json') } : {}),
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
// Unconditional CI trace suppression: Playwright traces are NEVER recorded in CI.
// Local developer tracing requires explicit PLAYWRIGHT_ENABLE_TRACE=1.
trace: traceOptIn ? 'retain-on-failure' : 'off',
},
webServer: {
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
@@ -50,7 +61,7 @@ module.exports = defineConfig({
reuseExistingServer: false,
env: {
...process.env,
AUTH_ENABLED: 'false',
AUTH_ENABLED: authenticated ? 'true' : 'false',
DATABASE_URL: `sqlite:///${databasePath}`,
ODYSSEUS_DATA_DIR: dataDirectory,
ODYSSEUS_STARTUP_WARMUPS: '0',
+16
View File
@@ -1,4 +1,6 @@
const fs = require('node:fs');
const path = require('node:path');
const { request } = require('@playwright/test');
module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
@@ -7,4 +9,18 @@ module.exports = async () => {
throw new Error('Photo editor E2E data paths were not configured');
}
fs.mkdirSync(dataDirectory, { recursive: true });
if (process.env.PHOTO_EDITOR_E2E_AUTH !== '0') {
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
const client = await request.newContext({ baseURL: `http://127.0.0.1:${port}` });
try {
const credentials = { username: 'editor-e2e', password: 'disposable-editor-e2e-password' };
const setup = await client.post('/api/auth/setup', { data: credentials });
if (!setup.ok()) throw new Error(`Disposable editor account setup failed: ${setup.status()}`);
const login = await client.post('/api/auth/login', { data: credentials });
if (!login.ok()) throw new Error(`Disposable editor account login failed: ${login.status()}`);
await client.storageState({ path: path.join(dataDirectory, 'browser-auth.json') });
} finally {
await client.dispose();
}
}
};
+127 -3
View File
@@ -1,7 +1,131 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
module.exports = async () => {
const repoRoot = path.resolve(__dirname, '..', '..');
const defaultResultsDir = path.join(repoRoot, 'test-results');
const defaultReportDir = path.join(repoRoot, 'playwright-report');
const defaultSanitizerScript = path.join(repoRoot, 'scripts', 'sanitize_trace_artifacts.py');
/**
* Confine dynamic paths to explicitly allowed test directories.
*/
function validateTestPath(targetPath, allowedBases, label) {
if (!targetPath || typeof targetPath !== 'string') return null;
const resolved = path.resolve(targetPath);
const isAllowed = allowedBases.some((base) => {
const resolvedBase = path.resolve(base);
return resolved.startsWith(resolvedBase + path.sep) || resolved === resolvedBase;
});
if (!isAllowed) {
throw new Error(`Security rejection: ${label} path "${targetPath}" is outside allowed test directories`);
}
return resolved;
}
module.exports = async (options = {}) => {
// If options is a Playwright FullConfig object, ignore it and use safe defaults
const isPlaywrightConfig = options && (options.configFile || options.rootDir);
const testOpts = (!isPlaywrightConfig && typeof options === 'object') ? options : {};
// Confine cleanup of PHOTO_EDITOR_E2E_DATA_DIR:
// Strictly require it to be within an OS temporary directory and match the expected test prefix
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
if (!dataDirectory) return;
fs.rmSync(dataDirectory, { recursive: true, force: true });
if (dataDirectory && typeof dataDirectory === 'string') {
try {
const resolvedData = path.resolve(dataDirectory);
const allowedTmpBases = [os.tmpdir(), '/var/tmp', '/tmp'].map((b) => path.resolve(b));
const isTmp = allowedTmpBases.some((base) => resolvedData.startsWith(base + path.sep));
const baseName = path.basename(resolvedData);
if (isTmp && baseName.startsWith('odysseus-photo-editor-e2e-')) {
fs.rmSync(resolvedData, { recursive: true, force: true });
}
} catch (_) {}
}
// Secure default tracing: never publish trace archives in CI or without explicit opt-in.
// When traces are not opted-in, purge all archives unconditionally.
// When local opt-in tracing is active, run the strict fail-closed structured sanitizer.
const allowedBases = [defaultResultsDir, defaultReportDir, os.tmpdir(), '/var/tmp', '/tmp'];
const resultsDir = testOpts.resultsDir
? validateTestPath(testOpts.resultsDir, allowedBases, 'resultsDir')
: defaultResultsDir;
const reportDir = testOpts.reportDir
? validateTestPath(testOpts.reportDir, allowedBases, 'reportDir')
: defaultReportDir;
if (fs.existsSync(reportDir)) {
try {
fs.rmSync(reportDir, { recursive: true, force: true });
} catch (_) {}
}
if (fs.existsSync(resultsDir)) {
const purgeSensitiveArtifacts = (dir) => {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
purgeSensitiveArtifacts(fullPath);
} else if (entry.isFile() && (
entry.name.endsWith('.zip') ||
entry.name.endsWith('.tmp') ||
entry.name.endsWith('.png') ||
entry.name.endsWith('.har')
)) {
try {
fs.rmSync(fullPath, { force: true });
} catch (_) {}
}
}
};
const purgeZipAndTmp = (dir) => {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
purgeZipAndTmp(fullPath);
} else if (entry.isFile() && (entry.name.endsWith('.zip') || entry.name.endsWith('.tmp'))) {
try {
fs.rmSync(fullPath, { force: true });
} catch (_) {}
}
}
};
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
if (isCI || !traceOptIn) {
// In CI or default local runs, purge all sensitive trace archives and artifacts
purgeSensitiveArtifacts(resultsDir);
} else {
const python = process.env.ODYSSEUS_TEST_PYTHON || 'python';
const sanitizerScript = testOpts.sanitizerScript
? validateTestPath(testOpts.sanitizerScript, [path.join(repoRoot, 'scripts'), os.tmpdir(), '/var/tmp', '/tmp'], 'sanitizerScript')
: defaultSanitizerScript;
const rawTimeout = Number(testOpts.timeout) || 30_000;
const timeout = Math.max(100, Math.min(rawTimeout, 120_000));
let sanitizerSucceeded = false;
try {
execFileSync(python, [sanitizerScript, resultsDir], {
stdio: 'ignore',
timeout,
shell: false,
});
sanitizerSucceeded = true;
} catch (_) {
sanitizerSucceeded = false;
}
if (!sanitizerSucceeded) {
// Fail closed: purge all remaining zip or tmp archives
purgeZipAndTmp(resultsDir);
}
}
}
};