Squash Odysseus development history

This commit is contained in:
pewdiepie-archdaemon
2026-09-11 06:04:19 +00:00
parent e5c99a5eee
commit 6ee6502010
2050 changed files with 538359 additions and 57745 deletions
+15
View File
@@ -0,0 +1,15 @@
# 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.
```bash
npm install
npm run test:photo-editor:install
npm run test:photo-editor
```
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.
@@ -0,0 +1,367 @@
const { test, expect } = require('@playwright/test');
const {
dragOnCanvas,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click();
const menu = page.locator('.ge-add-layer-menu');
await expect(menu).toBeVisible();
await menu.locator(`[data-adjustment-type="${type}"]`).click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
}
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 dragOnCanvas(page, { x: 0.18, y: 0.25 }, { x: 0.78, y: 0.7 });
const before = await flattenedPixelDigest(page);
await addAdjustment(page, 'levels');
await page.locator('.ge-adj-channel-select').selectOption('red');
await page.locator('.ge-adj-row input[data-key="outWhite"]').fill('128');
await page.locator('.ge-adj-row input[data-key="outWhite"]').dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
let current = await editorState(page);
const levels = current.layers.find(layer => layer.kind === 'adjustment');
expect(levels.adjustment.type).toBe('levels');
expect(levels.adjustment.params.channels.red.outWhite).toBe(128);
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();
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();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === levels.id).masks).toHaveLength(1);
await row.locator('.ge-layer-vis').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
await row.locator('.ge-layer-vis').click();
await row.locator('.ge-layer-opacity').fill('45');
await row.locator('.ge-layer-opacity').dispatchEvent('input');
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBeCloseTo(.45, 2);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBe(1);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBeCloseTo(.45, 2);
const draftId = await waitForDraft(page);
const expected = await editorState(page);
const expectedPixels = await flattenedPixelDigest(page);
const exportedPng = await page.evaluate(async () => {
const editor = await import('/static/js/galleryEditor.js');
return editor.exportPNG();
});
expect(await encodedImagePixelDigest(
page,
Buffer.from(exportedPng.split(',', 2)[1], 'base64'),
)).toEqual(expectedPixels);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.find(layer => layer.id === levels.id)).toEqual(expected.layers.find(layer => layer.id === levels.id));
expect(await flattenedPixelDigest(page)).toEqual(expectedPixels);
});
test('Curves supports editable RGB and channel points with reset and cancel', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 260 }, 'Curves E2E');
await addAdjustment(page, 'curves');
const curve = page.locator('.ge-curves-canvas');
const box = await curve.boundingBox();
await page.mouse.click(box.x + box.width * .5, box.y + box.height * .28);
await page.locator('.ge-adj-channel-select').selectOption('blue');
const blueBox = await curve.boundingBox();
await page.mouse.click(blueBox.x + blueBox.width * .4, blueBox.y + blueBox.height * .68);
await page.locator('[data-adj-action="ok"]').click();
let current = await editorState(page);
const curves = current.layers.find(layer => layer.kind === 'adjustment');
expect(curves.adjustment.type).toBe('curves');
expect(curves.adjustment.params.points.rgb).toHaveLength(3);
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('[data-adj-action="reset"]').click();
await page.locator('[data-adj-action="cancel"]').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === curves.id).adjustment.params.points.rgb).toHaveLength(3);
});
test('color adjustment controls retain neutral reusable parameters', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Color adjustments E2E');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const gradient = layer.ctx.createLinearGradient(0, 0, layer.canvas.width, layer.canvas.height);
gradient.addColorStop(0, '#b8324f');
gradient.addColorStop(.5, '#56a56d');
gradient.addColorStop(1, '#315fc4');
layer.ctx.fillStyle = gradient;
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
window.galleryEditorComposite?.();
});
const cases = [
{ type: 'exposure', key: 'exposure', value: '100', check: p => p.exposure === 1 },
{ type: 'white-balance', key: 'temperature', value: '70', check: p => p.temperature === 70 },
{ type: 'hue-saturation', key: 'lightness', value: '18', check: p => p.lightness === 18 },
{ type: 'vibrance', key: 'vibrance', value: '55', check: p => p.vibrance === 55 },
{ type: 'black-white', key: 'red', value: '72', compare: true, check: p => p.red === 72 && p.green === 59 && p.blue === 11 },
{ type: 'shadows-highlights', key: 'shadows', value: '48', check: p => p.shadows === 48 },
{ type: 'color-balance', key: 'shadows-r', value: '45', check: p => p.shadows.r === 45 },
{ type: 'selective-color', key: 'cyan', value: '35', check: p => p.ranges.reds.cyan === 35 },
];
let priorDigest = await flattenedPixelDigest(page);
for (const item of cases) {
await addAdjustment(page, item.type);
const control = page.locator(`.ge-adj-row input[data-key="${item.key}"]`);
await control.fill(item.value);
await control.dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const current = await editorState(page);
const added = current.layers.filter(layer => layer.kind === 'adjustment').at(-1);
expect(added.adjustment.type).toBe(item.type);
expect(item.check(added.adjustment.params)).toBe(true);
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 expect(page.locator('.ge-adj-popup')).toBeVisible();
await page.locator('[data-adj-action="compare"]').click();
expect(await flattenedPixelDigest(page)).toEqual(priorDigest);
await page.locator('[data-adj-action="compare"]').click();
expect(await flattenedPixelDigest(page)).toEqual(nextDigest);
await page.locator('[data-adj-action="cancel"]').click();
}
priorDigest = nextDigest;
}
await addAdjustment(page, 'gradient-map');
await page.locator('[data-gradient-key="shadows"]').fill('#123456');
await page.locator('[data-gradient-key="highlights"]').fill('#f0d080');
await page.locator('[data-gradient-reverse]').check();
await page.locator('[data-adj-action="ok"]').click();
const current = await editorState(page);
const gradient = current.layers.filter(layer => layer.kind === 'adjustment').at(-1);
expect(gradient.adjustment).toEqual({
type: 'gradient-map',
params: { shadows: '#123456', highlights: '#f0d080', midpoint: 50, reverse: true },
});
const expectedPixels = await flattenedPixelDigest(page);
const draftId = await waitForDraft(page);
const expectedState = await editorState(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.filter(layer => layer.kind === 'adjustment')).toEqual(
expectedState.layers.filter(layer => layer.kind === 'adjustment'),
);
expect(await flattenedPixelDigest(page)).toEqual(expectedPixels);
});
test('adjustment presets apply through the retained popup', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Adjustment presets E2E');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.fillStyle = '#5577aa';
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
window.galleryEditorComposite?.();
});
const before = await flattenedPixelDigest(page);
await addAdjustment(page, 'exposure');
await page.locator('.ge-adj-preset-select').selectOption('Lift Exposure');
await page.locator('[data-adj-action="ok"]').click();
const current = await editorState(page);
const exposure = current.layers.find(layer => layer.kind === 'adjustment');
expect(exposure.adjustment.params.exposure).toBe(0.45);
expect(await flattenedPixelDigest(page)).not.toEqual(before);
});
test('adjustment popup keeps controls inside a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile adjustment E2E');
await addAdjustment(page, 'exposure');
const popup = await page.locator('.ge-adj-popup').boundingBox();
expect(popup).not.toBeNull();
expect(popup.x).toBeGreaterThanOrEqual(0);
expect(popup.x + popup.width).toBeLessThanOrEqual(320);
for (const row of await page.locator('.ge-adj-row').all()) {
const box = await row.boundingBox();
expect(box).not.toBeNull();
expect(box.x).toBeGreaterThanOrEqual(popup.x);
expect(box.x + box.width).toBeLessThanOrEqual(popup.x + popup.width);
}
await expect(page.locator('.ge-adj-foot [data-adj-action="cancel"]')).toBeVisible();
await expect(page.locator('.ge-adj-foot [data-adj-action="ok"]')).toBeVisible();
});
test('gradient map controls remain usable on a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile gradient map E2E');
await addAdjustment(page, 'gradient-map');
const popup = await page.locator('.ge-adj-popup').boundingBox();
const colors = page.locator('.ge-gradient-color-row');
const colorBox = await colors.boundingBox();
expect(popup).not.toBeNull();
expect(colorBox).not.toBeNull();
expect(colorBox.x).toBeGreaterThanOrEqual(popup.x);
expect(colorBox.x + colorBox.width).toBeLessThanOrEqual(popup.x + popup.width);
expect(await page.locator('.ge-gradient-color-row').evaluate(el => (
getComputedStyle(el).gridTemplateColumns.trim().split(/\s+/).length
))).toBe(1);
});
test('all adjustment popups remain usable on a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile adjustment matrix E2E');
for (const type of [
'brightness-contrast', 'exposure', 'white-balance', 'hue-saturation',
'vibrance', 'black-white', 'shadows-highlights', 'levels', 'curves',
'color-balance', 'selective-color', 'gradient-map',
]) {
await addAdjustment(page, type);
const popup = await page.locator('.ge-adj-popup').boundingBox();
expect(popup, `${type} popup should be visible`).not.toBeNull();
expect(popup.x).toBeGreaterThanOrEqual(0);
expect(popup.x + popup.width).toBeLessThanOrEqual(320);
const body = page.locator('.ge-adj-body');
expect(await body.evaluate(el => el.scrollWidth <= el.clientWidth + 1), `${type} body should not overflow horizontally`).toBe(true);
await expect(page.locator('.ge-adj-foot [data-adj-action="cancel"]')).toBeVisible();
await expect(page.locator('.ge-adj-foot [data-adj-action="ok"]')).toBeVisible();
await page.locator('[data-adj-action="cancel"]').click();
}
});
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 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 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();
let current = await editorState(page);
const layer = current.layers.find(item => item.effects?.length);
expect(layer.effects).toHaveLength(1);
expect(layer.effects[0].type).toBe('gaussian-blur');
expect(layer.effects[0].params.radius).toBe(14);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
const reopened = current.layers.find(item => item.effects?.length);
expect(reopened.effects).toEqual(layer.effects);
expect(await flattenedPixelDigest(page)).toEqual(after);
await page.locator('.ge-effect-sub-item .ge-adj-sub-name').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('22');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].params.radius).toBe(22);
});
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 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');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
let current = await editorState(page);
let layer = current.layers.find(item => item.effects?.length);
expect(layer.effects[0].type).toBe('sharpen');
expect(layer.effects[0].params.amount).toBeCloseTo(0.75, 2);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
layer = current.layers.find(item => item.effects?.length);
expect(layer.effects[0].type).toBe('sharpen');
expect(layer.effects[0].params.amount).toBeCloseTo(0.75, 2);
});
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();
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 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 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();
let current = await editorState(page);
let layer = current.layers.find(item => item.effects?.length);
expect(layer.effects.map(effect => effect.type)).toEqual(['color-overlay', 'drop-shadow']);
expect(layer.effects[0].params.color).toBe('#336699');
expect(layer.effects[0].mask.size).toEqual([320, 220]);
await page.locator('button[title="Hide effect mask"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].mask.visible).toBe(false);
await page.locator('button[title="Remove effect mask"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].mask).toBeNull();
expect(layer.effects[0].params.opacity).toBeCloseTo(0.35, 2);
expect(layer.effects[1].params.blur).toBe(18);
await page.locator('.ge-effect-sub-item').nth(0).locator('.ge-layer-vis').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].visible).toBe(false);
await page.locator('.ge-effect-sub-item').nth(1).locator('button[title="Move effect up"]').click();
current = await editorState(page);
layer = current.layers.find(item => item.effects?.length);
expect(layer.effects.map(effect => effect.type)).toEqual(['drop-shadow', 'color-overlay']);
await page.locator('.ge-effect-sub-item').nth(1).locator('button[title="Delete effect"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects).toHaveLength(1);
});
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 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 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();
const layer = (await editorState(page)).layers.find(item => item.effects?.length);
expect(layer.effects[0].type).toBe('stroke');
expect(layer.effects[0].params.width).toBe(9);
const beforeRasterize = await flattenedPixelDigest(page);
await page.locator('.ge-effect-sub-item button[title="Rasterize effects"]').click();
await expect.poll(async () => (await editorState(page)).layers.find(item => item.name === 'Edit').effects.length).toBe(0);
const rasterized = (await editorState(page)).layers.find(item => item.name === 'Edit');
expect(rasterized.effects).toHaveLength(0);
expect(await flattenedPixelDigest(page)).toEqual(beforeRasterize);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
expect((await editorState(page)).layers.find(item => item.name === 'Edit').effects).toHaveLength(0);
});
@@ -0,0 +1,100 @@
const { test, expect } = require('@playwright/test');
test('mobile compare uses tabs to show one mounted pane at a time', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/login');
await page.addStyleTag({ url: '/static/style.css?v=20260903comparemodeicons1-emailsettingscards1' });
await page.evaluate(async () => {
const { default: state } = await import('/static/js/compare/state.js');
const { mountMobilePaneTabs } = await import('/static/js/compare/panes.js?v=20260903comparemodeicons1');
document.body.innerHTML = '<main id="compare-test-host" class="chat-container compare-active"><div class="compare-grid" data-cols="3"></div></main>';
const host = document.getElementById('compare-test-host');
host.style.cssText = 'position:fixed;inset:0;display:flex;flex-direction:column;padding-top:12px;';
const grid = host.querySelector('.compare-grid');
state._blindMode = false;
state._parallel = true;
state._selectedModels = [
{ name: 'Alpha model' },
{ name: 'Beta model' },
{ name: 'Gamma model' },
];
state._activeMobilePane = 0;
state._selectedModels.forEach((model, index) => {
const pane = document.createElement('section');
pane.className = 'compare-pane';
pane.dataset.pane = String(index);
pane.innerHTML = `<header class="pane-header"><button id="cmp-title-${index}" class="pane-title-btn">${model.name}</button></header><div class="chat-history">Response ${index + 1}</div>`;
grid.appendChild(pane);
});
mountMobilePaneTabs(host, grid);
});
const tabs = page.locator('.compare-mobile-tab');
const panes = page.locator('.compare-pane');
await expect(tabs).toHaveCount(3);
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true');
await expect(panes.nth(0)).toBeVisible();
await expect(panes.nth(1)).toBeHidden();
await expect(panes.nth(2)).toBeHidden();
await tabs.nth(1).click();
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true');
await expect(panes.nth(0)).toBeHidden();
await expect(panes.nth(1)).toBeVisible();
await expect(panes.nth(2)).toBeHidden();
const geometry = await page.evaluate(() => ({
grid: document.querySelector('.compare-grid').getBoundingClientRect().toJSON(),
pane: document.querySelector('.compare-pane-mobile-active').getBoundingClientRect().toJSON(),
visiblePaneCount: Array.from(document.querySelectorAll('.compare-pane')).filter((pane) => getComputedStyle(pane).display !== 'none').length,
}));
expect(geometry.visiblePaneCount).toBe(1);
expect(Math.abs((geometry.grid.width - 16) - geometry.pane.width)).toBeLessThan(2);
});
test('mobile compare probe keeps feedback below models and actions split', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/login');
await page.addStyleTag({ url: '/static/style.css?v=20260903comparemodeicons1-emailsettingscards1' });
await page.evaluate(() => {
document.body.innerHTML = `
<div class="compare-probe-overlay">
<section class="compare-probe-card">
<div class="compare-probe-title">Checking models...</div>
<div class="compare-probe-list">
<div class="compare-probe-row"><span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">Alpha</span></div>
<div class="compare-probe-row fail"><span class="compare-probe-spinner fail">×</span><span class="compare-probe-name">Beta</span></div>
</div>
<div class="compare-probe-feedback">
<div class="compare-probe-detail"><span class="compare-probe-detail-message">Insufficient balance</span><button class="compare-probe-action-btn"><svg></svg><span>Retry</span></button><button class="compare-probe-action-btn"><svg></svg><span>Swap</span></button></div>
</div>
<div class="compare-probe-footer"><button class="cmp-btn-secondary compare-probe-footer-btn">Go Back</button><button class="cmp-btn-primary compare-probe-footer-btn compare-probe-start-anyway">Start Anyway</button></div>
</section>
</div>`;
});
const layout = await page.evaluate(() => {
const list = document.querySelector('.compare-probe-list').getBoundingClientRect();
const feedback = document.querySelector('.compare-probe-feedback').getBoundingClientRect();
const back = document.querySelector('.compare-probe-footer-btn').getBoundingClientRect();
const start = document.querySelector('.compare-probe-start-anyway').getBoundingClientRect();
const spinnerStyle = getComputedStyle(document.querySelector('.compare-probe-spinner'));
const cardStyle = getComputedStyle(document.querySelector('.compare-probe-card'));
const startStyle = getComputedStyle(document.querySelector('.compare-probe-start-anyway'));
return {
feedbackBelowList: feedback.top >= list.bottom,
splitActions: back.left < start.left && start.right > 350,
spinnerTransform: spinnerStyle.transform,
cardRadius: cardStyle.borderRadius,
startBackground: startStyle.backgroundColor,
};
});
expect(layout.feedbackBelowList).toBe(true);
expect(layout.splitActions).toBe(true);
expect(layout.spinnerTransform).toContain('-2');
expect(layout.cardRadius).toBe('8px');
expect(layout.startBackground).not.toBe('rgba(0, 0, 0, 0)');
});
@@ -0,0 +1,47 @@
const { test, expect } = require('@playwright/test');
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 page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 80;
});
const canvas = page.locator('.ge-main-canvas');
const canvasBox = await canvas.boundingBox();
await page.mouse.move(canvasBox.x + canvasBox.width / 2, canvasBox.y + canvasBox.height / 2);
const cursor = page.locator('.ge-brush-cursor');
await expect(cursor).toBeVisible();
await expect(canvas).toHaveCSS('cursor', 'none');
const measurements = await page.evaluate(() => {
const canvasEl = document.querySelector('.ge-main-canvas');
const cursorEl = document.querySelector('.ge-brush-cursor');
const canvasRect = canvasEl.getBoundingClientRect();
const cursorRect = cursorEl.getBoundingClientRect();
return {
expectedWidth: 80 * canvasRect.width / canvasEl.width,
expectedHeight: 80 * canvasRect.height / canvasEl.height,
cursorWidth: cursorRect.width,
cursorHeight: cursorRect.height,
cursorZ: Number(getComputedStyle(cursorEl).zIndex),
galleryZ: Number(getComputedStyle(document.getElementById('gallery-modal')).zIndex || 0),
};
});
expect(measurements.cursorWidth).toBeCloseTo(measurements.expectedWidth, 1);
expect(measurements.cursorHeight).toBeCloseTo(measurements.expectedHeight, 1);
expect(measurements.cursorZ).toBeGreaterThan(measurements.galleryZ);
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 160;
});
await page.mouse.move(canvasBox.x + canvasBox.width / 2 + 1, canvasBox.y + canvasBox.height / 2);
await expect.poll(async () => (await cursor.boundingBox()).width)
.toBeCloseTo(measurements.expectedWidth * 2, 1);
});
+197
View File
@@ -0,0 +1,197 @@
const { test, expect } = require('@playwright/test');
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
async function canvasPoint(page, xRatio, yRatio) {
const box = await page.locator('.ge-main-canvas').boundingBox();
return { x: box.x + box.width * xRatio, y: box.y + box.height * 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 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();
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-spacing').fill('5');
await page.locator('#ge-brush-preset').selectOption({ label: 'My Detail Brush' });
await expect(page.locator('#ge-brush-spacing')).toHaveValue('28');
await expect(page.locator('#ge-brush-smoothing')).toHaveValue('52');
await expect(page.locator('#ge-brush-blend')).toHaveValue('multiply');
await page.locator('#ge-brush-preset-delete').click();
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(0);
});
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 page.locator('.ge-size-slider').fill('700');
await page.locator('#ge-brush-spacing').fill('8');
await page.locator('#ge-brush-smoothing').fill('65');
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());
const before = await flattenedPixelDigest(page);
const start = await canvasPoint(page, 0.08, 0.5);
const end = await canvasPoint(page, 0.92, 0.5);
const startedAt = Date.now();
await page.mouse.move(start.x, start.y);
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 after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
await expect(thumb).toHaveAttribute('width', '68');
expect(await thumb.evaluate(canvas => canvas.toDataURL())).not.toEqual(thumbBefore);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
await page.locator('#ge-redo').click();
expect(await flattenedPixelDigest(page)).toEqual(after);
});
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 page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#33aa55';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
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 expect(page.locator('#ge-eyedropper-section')).toBeVisible();
await page.locator('#ge-eyedropper-sample').selectOption('composite');
await page.mouse.move(sample.x, sample.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 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();
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);
await page.mouse.down();
await page.mouse.move(to.x, to.y, { steps: 20 });
await page.mouse.up();
expect((await editorState(page)).layers.length).toBeGreaterThan(0);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
}
const blemish = await canvasPoint(page, 0.72, 0.3);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
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();
const beforeHeal = await flattenedPixelDigest(page);
const healTarget = blemish;
await page.mouse.move(healTarget.x, healTarget.y);
await page.mouse.down();
await page.mouse.move(healTarget.x + 50, healTarget.y, { steps: 12 });
await page.mouse.up();
expect(await flattenedPixelDigest(page)).not.toEqual(beforeHeal);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(beforeHeal);
});
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 page.locator('.ge-size-slider').fill('420');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#d24b62';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
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 page.keyboard.down('Alt');
await page.mouse.click(source.x, source.y);
await page.keyboard.up('Alt');
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.cloneSourceX !== null && state.cloneSourceY !== null;
})).toBe(true);
const target = await canvasPoint(page, 0.72, 0.5);
await page.mouse.click(target.x, target.y);
const result = await page.evaluate(async ({ xRatio, yRatio }) => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const x = Math.round(layer.canvas.width * xRatio);
const y = Math.round(layer.canvas.height * yRatio);
const point = layer.ctx.getImageData(x, y, 1, 1).data;
return { hasSource: !!state.cloneSourceSnapshot, pixel: [...point] };
}, { xRatio: 0.72, yRatio: 0.5 });
expect(result.hasSource).toBe(true);
expect(result.pixel[0]).toBeGreaterThan(80);
expect(result.pixel[3]).toBeGreaterThan(0);
await expect(page.locator('#ge-clone-source-label')).toHaveText('Active layer sampled');
await page.locator('#ge-clone-source-clear').click();
await expect(page.locator('#ge-clone-source-label')).toHaveText('No source selected');
});
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 page.locator('.ge-size-slider').fill('700');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#d24b62';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
const source = await canvasPoint(page, 0.28, 0.5);
const target = await canvasPoint(page, 0.55, 0.5);
await page.mouse.click(source.x, source.y);
const before = await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const x = Math.round(canvas.width * 0.55);
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 expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
await page.mouse.move(source.x, source.y);
await page.mouse.down();
await page.mouse.move(target.x, target.y, { steps: 20 });
await page.mouse.up();
const after = await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const x = Math.round(canvas.width * 0.55);
const y = Math.round(canvas.height * 0.5);
const ctx = canvas.getContext('2d');
return [...ctx.getImageData(x, y, 1, 1).data];
});
expect(before[0]).toBeGreaterThan(220);
expect(before[3]).toBe(255);
expect(after[0]).toBeLessThan(220);
expect(after[2]).toBeLessThan(220);
expect(after[3]).toBeGreaterThan(0);
await page.locator('#ge-undo').click();
expect(await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const x = Math.round(canvas.width * 0.55);
const y = Math.round(canvas.height * 0.5);
return canvas.getContext('2d').getImageData(x, y, 1, 1).data[3];
})).toBe(255);
});
@@ -0,0 +1,43 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor } = require('./helpers.js');
async function mainCanvasHash(page) {
return page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let hash = 2166136261;
for (let index = 0; index < data.length; index += 4) {
hash ^= data[index];
hash = Math.imul(hash, 16777619);
hash ^= data[index + 1];
hash = Math.imul(hash, 16777619);
hash ^= data[index + 2];
hash = Math.imul(hash, 16777619);
hash ^= data[index + 3];
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
});
}
test('before compare is visual-only and toggles back to the edited document', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Compare mode E2E');
await expect(page.locator('#ge-compare-btn')).toBeVisible();
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 dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
const editedState = await editorState(page);
const editedHash = await mainCanvasHash(page);
expect(editedHash).not.toBe(baselineHash);
await page.locator('#ge-compare-btn').click();
await expect(page.locator('#ge-compare-btn')).toHaveAttribute('aria-pressed', 'true');
expect(await mainCanvasHash(page)).toBe(baselineHash);
expect((await editorState(page)).layers).toEqual(editedState.layers);
await page.locator('#ge-compare-btn').click();
await expect(page.locator('#ge-compare-btn')).toHaveAttribute('aria-pressed', 'false');
expect(await mainCanvasHash(page)).toBe(editedHash);
});
@@ -0,0 +1,106 @@
const fs = require('node:fs');
const { test, expect } = require('@playwright/test');
const {
compareExportPixels,
dragOnCanvas,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
test('layered document survives edit, mask, transform, crop, reopen, and export', async ({ page, request }) => {
await openBlankEditor(page);
const initial = await editorState(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
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');
const initialLayer = initial.layers.find(layer => layer.name === 'Edit');
expect(paintedLayer.pixelHash).not.toBe(initialLayer.pixelHash);
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();
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 expect(page.locator('#ge-transform-w')).toBeVisible();
await page.locator('#ge-transform-w').fill('560');
await page.locator('#ge-transform-apply').click();
current = await editorState(page);
const transformed = current.layers.find(layer => layer.name === 'Edit');
expect(transformed.size[0]).toBe(560);
expect(transformed.masks[0].size).toEqual(transformed.size);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').size[0]).toBe(640);
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 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);
await page.locator('#ge-text-content').fill('Durable photo edit');
await page.locator('#ge-text-size').fill('42');
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 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();
const cropped = await editorState(page);
expect(cropped.dimensions[0]).toBeLessThan(640);
expect(cropped.dimensions[1]).toBeLessThan(480);
await page.locator('#ge-undo').click();
expect((await editorState(page)).dimensions).toEqual([640, 480]);
await page.locator('#ge-redo').click();
expect((await editorState(page)).dimensions).toEqual(cropped.dimensions);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
const beforeReopenPixels = await flattenedPixelDigest(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.dimensions).toEqual(beforeReopen.dimensions);
expect(reopened.activeLayerId).toBe(beforeReopen.activeLayerId);
expect(reopened.layers).toEqual(beforeReopen.layers);
expect(await flattenedPixelDigest(page)).toEqual(beforeReopenPixels);
await openExportDialog(page);
await page.locator('[data-format="png"]').click();
await page.locator('#ge-export-width').fill('320');
const expectedHeight = Number(await page.locator('#ge-export-height').inputValue());
await page.locator('#ge-export-filename').fill('photo-editor-release-gate');
const downloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('photo-editor-release-gate.png');
const bytes = fs.readFileSync(await download.path());
expect(bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(bytes.readUInt32BE(16)).toBe(320);
expect(bytes.readUInt32BE(20)).toBe(expectedHeight);
const resizedComparison = await compareExportPixels(page, bytes, { width: 320, height: expectedHeight });
expect(resizedComparison.meanAbsoluteError).toBeLessThanOrEqual(1);
expect(resizedComparison.maximumError).toBeLessThanOrEqual(32);
expect(resizedComparison.changedPixelRatio).toBeLessThanOrEqual(0.08);
await openExportDialog(page);
await page.locator('[data-format="png"]').click();
await page.locator('#ge-export-filename').fill('photo-editor-native-fidelity');
const nativeDownloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const nativeDownload = await nativeDownloadPromise;
const nativeBytes = fs.readFileSync(await nativeDownload.path());
expect(await encodedImagePixelDigest(page, nativeBytes)).toEqual(beforeReopenPixels);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,191 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = 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 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 });
expect(await page.evaluate(async () => {
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');
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 };
})).toEqual({ tool: 'move', cropping: false, moving: false, rect: null });
await page.mouse.up();
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
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;
await page.mouse.move(
box.x + bounds.x + bounds.width / 2,
box.y + bounds.y + bounds.height / 2,
);
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');
const afterSwitch = await editorState(page);
expect(afterSwitch.selection.bounds).toEqual(bounds);
expect(afterSwitch.undo).toBe(beforeMove.undo);
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { tool: state.tool, selectionMoving: state.selectionMoving, marqueeActive: state.marqueeActive };
})).toEqual({ tool: 'brush', selectionMoving: false, marqueeActive: false });
await page.mouse.up();
expect((await editorState(page)).selection.bounds).toEqual(bounds);
});
test('Escape cancels an active crop without changing the document', async ({ page }) => {
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 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 });
await page.keyboard.press('Escape');
await page.mouse.up();
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { cropping: state.cropping, moving: state.cropMoving, rect: state.cropRect };
})).toEqual({ cropping: false, moving: false, rect: null });
});
test('touch crop and selection gestures complete through the shared lifecycle', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
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: 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 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);
await expect(page.locator('.ge-crop-apply')).toBeVisible();
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.cropRect;
})).toMatchObject({ w: 155, h: 115 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
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);
expect((await editorState(page)).selection.bounds).toBeTruthy();
} finally {
await context.close();
}
});
test('touch brush paints and undoes as one stroke', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
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: 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();
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);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
} finally {
await context.close();
}
});
test('touch cancellation rolls back a partial brush stroke', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
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: 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();
const before = await flattenedPixelDigest(page);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: canvasBox.x + 70, y: canvasBox.y + 110, id: 1 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: canvasBox.x + 230, y: canvasBox.y + 110, id: 1 }],
});
await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] });
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.drawing;
})).toBe(false);
expect(await flattenedPixelDigest(page)).toEqual(before);
} finally {
await context.close();
}
});
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();
const box = await page.locator('.ge-main-canvas').boundingBox();
const cdp = await context.newCDPSession(page);
await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed', pointerType: 'pen', button: 'left', buttons: 1,
clickCount: 1, x: box.x + 30, y: box.y + 25,
});
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseMoved', pointerType: 'pen', button: 'none', buttons: 1,
x: box.x + 175, y: box.y + 130,
});
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseReleased', pointerType: 'pen', button: 'left', buttons: 0,
clickCount: 1, x: box.x + 175, y: box.y + 130,
});
await expect(page.locator('.ge-crop-apply')).toBeVisible();
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { cropping: state.cropping, rect: state.cropRect };
})).toEqual({ cropping: false, rect: { x: 30, y: 25, w: 145, h: 105 } });
});
@@ -0,0 +1,191 @@
const fs = require('node:fs');
const { test, expect } = require('@playwright/test');
const {
compareExportPixels,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
async function seedGradient(page) {
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const gradient = layer.ctx.createLinearGradient(0, 0, layer.canvas.width, layer.canvas.height);
gradient.addColorStop(0, '#d43f67');
gradient.addColorStop(0.5, '#4aa884');
gradient.addColorStop(1, '#3868d4');
layer.ctx.fillStyle = gradient;
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
window.galleryEditorComposite?.();
});
}
async function downloadFormat(page, format, filename) {
await openExportDialog(page);
await page.locator(`[data-format="${format}"]`).click();
await page.locator('#ge-export-filename').fill(filename);
const downloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const download = await downloadPromise;
return {
name: download.suggestedFilename(),
bytes: fs.readFileSync(await download.path()),
};
}
async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').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();
}
async function exportCurrentPng(page) {
const dataUrl = await page.evaluate(async () => {
const editor = await import('/static/js/galleryEditor.js');
return editor.exportPNG();
});
return Buffer.from(dataUrl.split(',', 2)[1], 'base64');
}
test('every retained adjustment family exports the visible composite exactly', async ({ page }) => {
await openBlankEditor(page, { width: 128, height: 96 }, 'Adjustment export matrix');
await seedGradient(page);
const cases = [
['brightness-contrast', 'brightness', '35'],
['exposure', 'exposure', '35'],
['white-balance', 'temperature', '30'],
['hue-saturation', 'hue', '30'],
['vibrance', 'vibrance', '45'],
['black-white', 'red', '60'],
['shadows-highlights', 'shadows', '35'],
['levels', 'inBlack', '20'],
['color-balance', 'shadows-r', '35'],
['selective-color', 'cyan', '30'],
];
for (const [type, key, value] of cases) {
await addAdjustment(page, type);
const control = page.locator(`.ge-adj-row input[data-key="${key}"]`);
await control.fill(value);
await control.dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const visibleDigest = await flattenedPixelDigest(page);
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(visibleDigest);
}
await addAdjustment(page, 'curves');
const curve = page.locator('.ge-curves-canvas');
const curveBox = await curve.boundingBox();
await page.mouse.click(curveBox.x + curveBox.width * 0.5, curveBox.y + curveBox.height * 0.28);
await page.locator('[data-adj-action="ok"]').click();
const curveDigest = await flattenedPixelDigest(page);
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(curveDigest);
await addAdjustment(page, 'gradient-map');
await page.locator('[data-gradient-key="shadows"]').fill('#102030');
await page.locator('[data-gradient-key="shadows"]').dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const gradientDigest = await flattenedPixelDigest(page);
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(gradientDigest);
});
test('composited correction exports preserve pixels and format metadata', async ({ page }) => {
await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity');
await seedGradient(page);
await page.locator('#ge-add-layer').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');
await page.locator('[data-adj-action="ok"]').click();
const renderedPixels = await flattenedPixelDigest(page);
const png = await downloadFormat(page, 'png', 'correction');
expect(png.name).toBe('correction.png');
expect(png.bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(png.bytes.readUInt32BE(16)).toBe(160);
expect(png.bytes.readUInt32BE(20)).toBe(120);
expect(await encodedImagePixelDigest(page, png.bytes)).toEqual(renderedPixels);
const jpeg = await downloadFormat(page, 'jpeg', 'correction-jpeg');
expect(jpeg.name).toBe('correction-jpeg.jpg');
expect(jpeg.bytes[0]).toBe(0xff);
expect(jpeg.bytes[1]).toBe(0xd8);
const webp = await downloadFormat(page, 'webp', 'correction-webp');
expect(webp.name).toBe('correction-webp.webp');
expect(webp.bytes.subarray(0, 4).toString('ascii')).toBe('RIFF');
expect(webp.bytes.subarray(8, 12).toString('ascii')).toBe('WEBP');
const comparison = await compareExportPixels(page, png.bytes, { width: 160, height: 120 });
expect(comparison.meanAbsoluteError).toBe(0);
expect(comparison.maximumError).toBe(0);
const exposure = (await editorState(page)).layers.find(layer => layer.kind === 'adjustment');
const exposureRow = page.locator(`.ge-layer-item[data-layer-id="${exposure.id}"]`);
await exposureRow.locator('.ge-layer-opacity').fill('62');
await exposureRow.locator('.ge-layer-opacity').dispatchEvent('input');
const opacityState = await editorState(page);
expect(opacityState.layers.find(layer => layer.id === exposure.id).opacity).toBeCloseTo(.62, 2);
const opacityPixels = await flattenedPixelDigest(page);
const opacityPng = await downloadFormat(page, 'png', 'correction-opacity');
expect(await encodedImagePixelDigest(page, opacityPng.bytes)).toEqual(opacityPixels);
expect(await compareExportPixels(page, opacityPng.bytes, { width: 160, height: 120 })).toEqual({
meanAbsoluteError: 0,
maximumError: 0,
changedPixelRatio: 0,
});
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.id === exposure.id).opacity).toBeCloseTo(.62, 2);
expect(await flattenedPixelDigest(page)).toEqual(opacityPixels);
});
test('export preview clears stale matte pixels when transparency changes', async ({ page }) => {
await openBlankEditor(page, { width: 160, height: 120 }, 'Export preview');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
for (const layer of state.layers) layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.fillStyle = '#2671c8';
layer.ctx.fillRect(40, 30, 80, 60);
window.galleryEditorComposite?.();
});
await openExportDialog(page);
const previewPixel = () => page.locator('.ge-export-preview').evaluate(canvas => (
[...canvas.getContext('2d').getImageData(0, 0, 1, 1).data]
));
expect(await previewPixel()).toEqual([0, 0, 0, 0]);
await page.locator('#ge-export-transparency').uncheck();
await page.locator('#ge-export-matte').click();
await page.locator('.cp-hex').fill('#d94747');
await page.locator('.cp-hex').press('Enter');
expect(await previewPixel()).toEqual([217, 71, 71, 255]);
await page.locator('#ge-export-transparency').check();
expect(await previewPixel()).toEqual([0, 0, 0, 0]);
await page.locator('.ge-export-close').click();
});
test('closing export returns focus to the launch control', async ({ page }) => {
await openBlankEditor(page, { width: 160, height: 120 }, 'Export focus');
const launch = page.locator('#ge-save-menu-btn');
await launch.focus();
await launch.click();
await page.locator('#ge-download').click();
await expect(page.locator('.ge-export-dialog')).toBeVisible();
await page.locator('.ge-export-close').click();
await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe('ge-save-menu-btn');
});
@@ -0,0 +1,47 @@
const { test, expect } = require('@playwright/test');
test('gallery photos body uses tall viewport without cropping or stretching cards', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1200 });
await page.route('**/api/gallery/library?**', async route => {
const pixel = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="160" height="160"%3E%3Crect width="160" height="160" fill="%23666"/%3E%3C/svg%3E';
const items = Array.from({ length: 60 }, (_, index) => ({
id: `layout-${index}`,
url: pixel,
thumbnail_url: pixel,
filename: `Layout ${index}.png`,
prompt: `Layout ${index}`,
model: 'imported',
created_at: '2026-08-31T00:00:00Z',
}));
await route.fulfill({ json: { items, total: items.length, has_more: false } });
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.locator('#tool-gallery-btn').click();
await expect(page.locator('#gallery-modal')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#gallery-grid .gallery-card')).toHaveCount(61);
await expect(page.locator('#gallery-grid')).not.toHaveClass(/gallery-just-opened/, { timeout: 2_000 });
const layout = await page.evaluate(() => {
const modal = document.querySelector('.gallery-modal-content').getBoundingClientRect();
const body = document.querySelector('#gallery-modal .modal-body').getBoundingClientRect();
const grid = document.querySelector('#gallery-grid').getBoundingClientRect();
const upload = document.querySelector('#gallery-upload-tile').getBoundingClientRect();
const cards = [...document.querySelectorAll('#gallery-grid .gallery-card')]
.slice(0, 12)
.map(card => card.getBoundingClientRect());
return { modal, body, grid, upload, cards, viewportHeight: innerHeight };
});
expect(layout.grid.height).toBeGreaterThan(layout.viewportHeight * 0.65);
expect(layout.grid.bottom).toBeLessThanOrEqual(layout.body.bottom + 1);
expect(Math.abs(layout.upload.width - layout.upload.height)).toBeLessThanOrEqual(2);
for (let i = 0; i < layout.cards.length; i += 1) {
for (let j = i + 1; j < layout.cards.length; j += 1) {
const a = layout.cards[i];
const b = layout.cards[j];
const overlaps = a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
expect(overlaps).toBe(false);
}
}
});
@@ -0,0 +1,108 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const {
compareExportPixels,
dragOnCanvas,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
async function downloadPng(page, filename) {
await openExportDialog(page);
await page.locator('[data-format="png"]').click();
await page.locator('#ge-export-filename').fill(filename);
const downloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const download = await downloadPromise;
return fs.readFileSync(await download.path());
}
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 page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 48;
state.brushSoftness = 0;
});
await dragOnCanvas(page, { x: 0.25, y: 0.35 }, { x: 0.75, y: 0.65 });
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
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-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
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();
let current = await editorState(page);
expect(current.groups[0].effects).toHaveLength(1);
expect(current.groups[0].effects[0].params.radius).toBe(14);
const afterAdd = await flattenedPixelDigest(page);
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
await page.locator('.ge-group-effect-sub-item .ge-adj-sub-name').click();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('22');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
current = await editorState(page);
expect(current.groups[0].effects[0].params.radius).toBe(22);
expect(await flattenedPixelDigest(page)).not.toEqual(afterAdd);
await page.locator('.ge-group-effect-sub-item .ge-layer-vis').click();
expect((await editorState(page)).groups[0].effects[0].visible).toBe(false);
await page.locator('.ge-group-effect-sub-item .ge-layer-vis').click();
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
expect((await editorState(page)).groups).toEqual(beforeReopen.groups);
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
await page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`).click();
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
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 page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 64;
state.brushSoftness = 0;
});
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
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 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');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const renderedPixels = await flattenedPixelDigest(page);
const pngBytes = await downloadPng(page, 'group-effects');
const comparison = await compareExportPixels(page, pngBytes, { width: 240, height: 180 });
expect(comparison.meanAbsoluteError).toBe(0);
expect(comparison.maximumError).toBe(0);
expect(await flattenedPixelDigest(page)).toEqual(renderedPixels);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.groups[0].effects).toHaveLength(1);
expect(reopened.groups[0].effects[0]).toMatchObject({
type: 'color-overlay',
params: { color: '#336699', opacity: 0.4 },
});
});
+68
View File
@@ -0,0 +1,68 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function compositeAlphaAtCenter(page) {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const x = Math.floor(state.imgWidth / 2);
const y = Math.floor(state.imgHeight / 2);
return state.documentCompositeCanvas.getContext('2d').getImageData(x, y, 1, 1).data[3];
});
}
test('group mask paints, toggles, survives history and reopen, and protects ungroup', async ({ page, request }) => {
await openBlankEditor(page, { width: 420, height: 300 }, 'Group mask E2E');
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
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 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();
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 page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 90;
state.eraserSoftness = 0;
});
await dragOnCanvas(page, { x: 0.46, y: 0.5 }, { x: 0.54, y: 0.5 });
current = await editorState(page);
const erasedMaskHash = current.groups[0].masks[0].pixelHash;
expect(erasedMaskHash).not.toBe(whiteMaskHash);
expect(await compositeAlphaAtCenter(page)).toBe(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups[0].masks[0].pixelHash).toBe(whiteMaskHash);
expect(await compositeAlphaAtCenter(page)).toBe(255);
await page.locator('#ge-redo').click();
expect((await editorState(page)).groups[0].masks[0].pixelHash).toBe(erasedMaskHash);
expect(await compositeAlphaAtCenter(page)).toBe(0);
const maskRow = page.locator('.ge-group-mask-sub-item');
await maskRow.locator('.ge-layer-vis').click();
expect(await compositeAlphaAtCenter(page)).toBe(255);
await maskRow.locator('.ge-layer-vis').click();
expect(await compositeAlphaAtCenter(page)).toBe(0);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.groups).toEqual(beforeReopen.groups);
expect(await compositeAlphaAtCenter(page)).toBe(0);
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
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();
expect((await editorState(page)).groups).toHaveLength(0);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,107 @@
const { test, expect } = require('@playwright/test');
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('group drag reorders the complete subtree with undo and server persistence', async ({ page, request }) => {
await openBlankEditor(page, { width: 420, height: 300 }, 'Group reorder E2E');
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
let current = await editorState(page);
const group = current.groups[0];
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]);
const handle = page.locator(`.ge-layer-group-row[data-group-id="${group.id}"] .ge-layer-group-drag`);
const looseRow = page.locator(`.ge-layer-item[data-layer-id="${looseId}"]`);
const handleBox = await handle.boundingBox();
const looseBox = await looseRow.boundingBox();
const dragReadiness = await page.evaluate(async ({ groupId, point }) => {
const { state } = await import('/static/js/editor/state.js');
const { groupSiblingUnits } = await import('/static/js/editor/layer-groups.js');
const group = state.layerGroups.find(item => item.id === groupId);
const hit = document.elementFromPoint(point.x, point.y);
return {
siblingIds: groupSiblingUnits(state, group.parentId || null).map(unit => unit.id),
hitHandle: !!hit?.closest('.ge-layer-group-drag'),
};
}, { groupId: group.id, point: { x: handleBox.x + handleBox.width / 2, y: handleBox.y + handleBox.height / 2 } });
expect(dragReadiness.hitHandle, JSON.stringify(dragReadiness)).toBe(true);
expect(dragReadiness.siblingIds).toEqual([group.id, looseId]);
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
await page.mouse.down();
await page.mouse.move(looseBox.x + looseBox.width / 2, looseBox.y + 1, { steps: 8 });
await expect(page.locator('.ge-group-drop-line')).toBeVisible();
await page.mouse.up();
current = await editorState(page);
expect(current.layers.map(layer => layer.id)).toEqual([looseId, ...originalOrder]);
expect(current.groups[0].layerIds).toEqual(group.layerIds);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([looseId, ...originalOrder]);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.map(layer => layer.id)).toEqual(beforeReopen.layers.map(layer => layer.id));
expect(current.groups).toEqual(beforeReopen.groups);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('mobile layer drag handle supports long-press reorder', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
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: 320, height: 240 }, 'Mobile layer reorder');
await page.locator('.ge-layers-header').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.
const dragId = before.layers[0].id;
const targetId = before.layers[before.layers.length - 1].id;
const bottomHandle = page.locator(`.ge-layer-item[data-layer-id="${dragId}"] .ge-layer-drag`);
const topRow = page.locator(`.ge-layer-item[data-layer-id="${targetId}"]`);
await bottomHandle.scrollIntoViewIfNeeded();
const handleBox = await bottomHandle.boundingBox();
const topBox = await topRow.boundingBox();
expect(handleBox.width).toBeGreaterThanOrEqual(24);
const point = { x: handleBox.x + handleBox.width / 2, y: handleBox.y + handleBox.height / 2 };
const hitHandle = await page.evaluate(({ x, y }) => {
const hit = document.elementFromPoint(x, y);
return !!hit?.closest('.ge-layer-drag');
}, point);
expect(hitHandle).toBe(true);
const cdp = await page.context().newCDPSession(page);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: point.x, y: point.y, id: 1, radiusX: 8, radiusY: 8 }],
});
await page.waitForTimeout(450);
await expect(page.locator(`.ge-layer-item[data-layer-id="${dragId}"]`)).toHaveClass(/dragging/);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: point.x, y: topBox.y + 1, id: 1, radiusX: 8, radiusY: 8 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
await page.waitForTimeout(120);
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([targetId, dragId]);
} finally {
await context.close();
}
});
+375
View File
@@ -0,0 +1,375 @@
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 expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.layers.length;
})).toBe(2);
}
async function editorState(page) {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const hashCanvas = canvas => {
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let hash = 2166136261;
for (let index = 0; index < data.length; index += Math.max(4, Math.floor(data.length / 20_000 / 4) * 4)) {
const alpha = data[index + 3] / 255;
hash ^= Math.round(data[index] * alpha);
hash = Math.imul(hash, 16777619);
hash ^= Math.round(data[index + 1] * alpha);
hash = Math.imul(hash, 16777619);
hash ^= Math.round(data[index + 2] * alpha);
hash = Math.imul(hash, 16777619);
hash ^= data[index + 3];
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
};
const selectionBounds = canvas => {
if (!canvas) return null;
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let left = canvas.width, top = canvas.height, right = -1, bottom = -1;
for (let y = 0; y < canvas.height; y += 1) for (let x = 0; x < canvas.width; x += 1) {
if (data[(y * canvas.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 right < left ? null : { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
};
return {
documentRenderReady: !!state.documentRenderReady,
dimensions: [state.imgWidth, state.imgHeight],
activeLayerId: state.activeLayerId,
selectedLayerIds: [...(state.selectedLayerIds || [])],
groups: (state.layerGroups || []).map(group => ({
id: group.id,
name: group.name,
layerIds: [...group.layerIds],
parentId: group.parentId || null,
visible: group.visible !== false,
opacity: group.opacity,
blendMode: group.blendMode,
locked: !!group.locked,
collapsed: !!group.collapsed,
activeMaskId: group.activeMaskId || null,
effects: (group.effects || []).map(effect => ({
id: effect.id,
type: effect.type,
name: effect.name,
visible: effect.visible !== false,
opacity: effect.opacity,
params: JSON.parse(JSON.stringify(effect.params || {})),
})),
masks: (group.masks || []).map(mask => ({
id: mask.id,
name: mask.name,
visible: mask.visible !== false,
density: Number.isFinite(Number(mask.density)) ? mask.density : 1,
feather: Number.isFinite(Number(mask.feather)) ? mask.feather : 0,
mode: mask.mode,
size: [mask.canvas.width, mask.canvas.height],
pixelHash: hashCanvas(mask.canvas),
})),
})),
layers: state.layers.map(layer => ({
id: layer.id,
name: layer.name,
kind: layer.kind || 'raster',
effects: (layer.effects || []).map(effect => ({
id: effect.id,
type: effect.type,
name: effect.name,
visible: effect.visible !== false,
opacity: effect.opacity,
mask: effect.mask ? {
visible: effect.mask.visible !== false,
size: [effect.mask.canvas?.width || effect.mask.canvasW, effect.mask.canvas?.height || effect.mask.canvasH],
} : null,
params: JSON.parse(JSON.stringify(effect.params || {})),
})),
visible: layer.visible !== false,
opacity: layer.opacity,
locked: !!layer.locked,
locks: {
pixels: !!layer.locks?.pixels,
transparency: !!layer.locks?.transparency,
position: !!layer.locks?.position,
},
clipped: !!layer.clipped,
size: [layer.canvas.width, layer.canvas.height],
offset: state.layerOffsets.get(layer.id) || { x: 0, y: 0 },
pixelHash: hashCanvas(layer.canvas),
text: layer.text ? {
content: layer.text.content,
fontSize: layer.text.fontSize,
fontFamily: layer.text.fontFamily,
lineHeight: layer.text.lineHeight,
letterSpacing: layer.text.letterSpacing,
frameWidth: layer.text.frameWidth,
frameHeight: layer.text.frameHeight,
verticalAlign: layer.text.verticalAlign,
autoWidth: layer.text.autoWidth,
transform: { ...layer.text.transform },
} : null,
shape: layer.shape ? {
type: layer.shape.type,
width: layer.shape.width,
height: layer.shape.height,
fillColor: layer.shape.fillColor,
fillType: layer.shape.fillType,
gradientStart: layer.shape.gradientStart,
gradientMid: layer.shape.gradientMid,
gradientMidEnabled: layer.shape.gradientMidEnabled,
gradientMidPosition: layer.shape.gradientMidPosition,
gradientEnd: layer.shape.gradientEnd,
gradientStops: layer.shape.gradientStops ? layer.shape.gradientStops.map(stop => ({ ...stop })) : undefined,
gradientAngle: layer.shape.gradientAngle,
strokeColor: layer.shape.strokeColor,
strokeWidth: layer.shape.strokeWidth,
cornerRadius: layer.shape.cornerRadius,
sides: layer.shape.sides,
transform: { ...layer.shape.transform },
} : null,
adjustment: layer.adjustment ? JSON.parse(JSON.stringify(layer.adjustment)) : null,
placed: layer.kind === 'placed' && layer.placed?.sourceCanvas ? {
sourceSize: [layer.placed.sourceCanvas.width, layer.placed.sourceCanvas.height],
sourceName: layer.placed.sourceName,
matrix: [...layer.placed.matrix],
sourcePixelHash: hashCanvas(layer.placed.sourceCanvas),
} : null,
masks: (layer.masks || []).map(mask => ({
id: mask.id,
mode: mask.mode,
space: mask.space,
linked: mask.mode === 'layer' ? mask.linked !== false : true,
density: Number.isFinite(Number(mask.density)) ? mask.density : 1,
feather: Number.isFinite(Number(mask.feather)) ? mask.feather : 0,
offset: { x: Number(mask.offset?.x) || 0, y: Number(mask.offset?.y) || 0 },
size: [mask.canvas.width, mask.canvas.height],
pixelHash: hashCanvas(mask.canvas),
})),
})),
guides: state.guides,
draftId: state.draftId,
undo: state.undoStack.length,
redo: state.redoStack.length,
quickMaskActive: !!state.quickMaskActive,
selection: state.wandMask ? {
space: state.wandMaskSpace,
source: state.selectionSource,
size: [state.wandMask.width, state.wandMask.height],
pixelHash: hashCanvas(state.wandMask),
bounds: selectionBounds(state.wandMask),
} : null,
savedSelections: (state.savedSelections || []).map(selection => ({
id: selection.id,
name: selection.name,
size: [selection.canvas.width, selection.canvas.height],
pixelHash: hashCanvas(selection.canvas),
bounds: selectionBounds(selection.canvas),
})),
lastSelection: state.lastSelection?.canvas ? {
pixelHash: hashCanvas(state.lastSelection.canvas),
bounds: selectionBounds(state.lastSelection.canvas),
} : null,
persistIdle: !state.persistTimer && !state.persistInFlight,
};
});
}
async function dragOnCanvas(page, start, end) {
const box = await page.locator('.ge-main-canvas').boundingBox();
if (!box) throw new Error('Canvas has no visible bounds');
await page.mouse.move(box.x + box.width * start.x, box.y + box.height * start.y);
await page.mouse.down();
await page.mouse.move(box.x + box.width * end.x, box.y + box.height * end.y, { steps: 10 });
await page.mouse.up();
}
async function waitForDraft(page) {
await expect.poll(async () => {
const current = await editorState(page);
return !!current.draftId && current.persistIdle;
}, { timeout: 15_000 }).toBe(true);
return (await editorState(page)).draftId;
}
async function reopenDraft(page, draftId) {
await page.evaluate(async id => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
window.__galleryAllowCloseEditor = true;
editor.closeEditor();
await editor.openEditor(null, null, null, 'Reopened E2E document', id);
}, draftId);
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
await expect.poll(async () => (await editorState(page)).draftId).toBe(draftId);
await expect.poll(async () => (await editorState(page)).documentRenderReady, {
timeout: 20_000,
}).toBe(true);
}
async function openExportDialog(page) {
await page.locator('#ge-save-menu-btn').click();
await page.locator('#ge-download').click();
await expect(page.locator('.ge-export-dialog')).toBeVisible();
}
async function flattenedPixelDigest(page, size = null) {
return page.evaluate(async targetSize => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
const image = new Image();
image.src = editor.exportPNG();
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = targetSize?.width || image.naturalWidth;
canvas.height = targetSize?.height || image.naturalHeight;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const raw = context.getImageData(0, 0, canvas.width, canvas.height).data;
const pixels = new Uint8ClampedArray(raw.length);
for (let index = 0; index < raw.length; index += 4) {
const alpha = raw[index + 3] / 255;
pixels[index] = Math.round(raw[index] * alpha);
pixels[index + 1] = Math.round(raw[index + 1] * alpha);
pixels[index + 2] = Math.round(raw[index + 2] * alpha);
pixels[index + 3] = raw[index + 3];
}
const digest = await crypto.subtle.digest('SHA-256', pixels);
return {
width: canvas.width,
height: canvas.height,
sha256: Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''),
};
}, size);
}
async function encodedImagePixelDigest(page, bytes) {
return page.evaluate(async base64 => {
const binary = atob(base64);
const encoded = Uint8Array.from(binary, character => character.charCodeAt(0));
const bitmap = await createImageBitmap(new Blob([encoded], { type: 'image/png' }));
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
bitmap.close();
const raw = context.getImageData(0, 0, canvas.width, canvas.height).data;
const pixels = new Uint8ClampedArray(raw.length);
for (let index = 0; index < raw.length; index += 4) {
const alpha = raw[index + 3] / 255;
pixels[index] = Math.round(raw[index] * alpha);
pixels[index + 1] = Math.round(raw[index + 1] * alpha);
pixels[index + 2] = Math.round(raw[index + 2] * alpha);
pixels[index + 3] = raw[index + 3];
}
const digest = await crypto.subtle.digest('SHA-256', pixels);
return {
width: canvas.width,
height: canvas.height,
sha256: Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''),
};
}, bytes.toString('base64'));
}
async function compareExportPixels(page, bytes, size) {
return page.evaluate(async ({ base64, targetSize }) => {
const decode = async source => {
const image = new Image();
image.src = source;
await image.decode();
return image;
};
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
const expectedImage = await decode(editor.exportPNG());
const binary = atob(base64);
const actualImage = await decode(URL.createObjectURL(new Blob([
Uint8Array.from(binary, character => character.charCodeAt(0)),
], { type: 'image/png' })));
const pixels = image => {
const canvas = document.createElement('canvas');
canvas.width = targetSize.width;
canvas.height = targetSize.height;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return context.getImageData(0, 0, canvas.width, canvas.height).data;
};
const expected = pixels(expectedImage);
const actual = pixels(actualImage);
URL.revokeObjectURL(actualImage.src);
let total = 0;
let maximum = 0;
let changedPixels = 0;
for (let index = 0; index < expected.length; index += 4) {
const expectedAlpha = expected[index + 3] / 255;
const actualAlpha = actual[index + 3] / 255;
let pixelMaximum = Math.abs(expected[index + 3] - actual[index + 3]);
total += pixelMaximum;
for (let channel = 0; channel < 3; channel += 1) {
const delta = Math.abs(
expected[index + channel] * expectedAlpha - actual[index + channel] * actualAlpha,
);
total += delta;
pixelMaximum = Math.max(pixelMaximum, delta);
}
maximum = Math.max(maximum, pixelMaximum);
if (pixelMaximum > 1) changedPixels += 1;
}
return {
meanAbsoluteError: total / expected.length,
maximumError: maximum,
changedPixelRatio: changedPixels / (expected.length / 4),
};
}, { base64: bytes.toString('base64'), targetSize: size });
}
module.exports = {
compareExportPixels,
dragOnCanvas,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
};
@@ -0,0 +1,57 @@
const { test, expect } = require('@playwright/test');
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function sampleComposite(page) {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const ctx = state.documentCompositeCanvas.getContext('2d');
return {
left: [...ctx.getImageData(40, 50, 1, 1).data],
right: [...ctx.getImageData(160, 50, 1, 1).data],
};
});
}
test('clipping mask uses base alpha and survives undo, redo, and server reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 200, height: 100 }, 'Clipping E2E');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const base = state.layers.find(layer => layer.name === 'Background');
const color = state.layers.find(layer => layer.name === 'Edit');
for (const layer of [base, color]) layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
base.ctx.fillStyle = '#ff0000';
base.ctx.fillRect(0, 0, 100, 100);
color.ctx.fillStyle = '#0000ff';
color.ctx.fillRect(0, 0, 200, 100);
});
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 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);
let pixels = await sampleComposite(page);
expect(pixels.left).toEqual([0, 0, 255, 255]);
expect(pixels.right[3]).toBe(0);
await page.locator('#ge-undo').click();
expect((await sampleComposite(page)).right).toEqual([0, 0, 255, 255]);
await page.locator('#ge-redo').click();
expect((await sampleComposite(page)).right[3]).toBe(0);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
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();
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();
expect((await sampleComposite(page)).right).toEqual([0, 0, 255, 255]);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,65 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('independent layer locks enforce operations and survive undo and reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 360, height: 260 }, 'Layer locks E2E');
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
const lockButton = editRow.locator('.ge-layer-lock-btn');
const opacityRow = editRow.locator('.ge-layer-opacity-row');
const lockBox = await lockButton.boundingBox();
const opacityBox = await opacityRow.boundingBox();
expect(lockBox).toBeTruthy();
expect(opacityBox).toBeTruthy();
expect(lockBox.y + lockBox.height).toBeLessThanOrEqual(opacityBox.y + 1);
const toggleLock = async type => {
await lockButton.click();
const menu = page.locator('#ge-layer-lock-menu');
await expect(menu).toBeVisible();
await menu.locator(`[data-lock-type="${type}"]`).click();
};
await toggleLock('position');
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 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 });
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').locks.position).toBe(false);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').locks.position).toBe(true);
await toggleLock('position');
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 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');
await toggleLock('transparency');
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('transparency');
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).not.toBe(emptyHash);
await toggleLock('pixels');
await toggleLock('transparency');
await toggleLock('position');
const beforeReopen = await editorState(page);
const expectedLocks = beforeReopen.layers.find(layer => layer.name === 'Edit').locks;
expect(expectedLocks).toEqual({ pixels: true, transparency: true, position: true });
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.name === 'Edit').locks).toEqual(expectedLocks);
await expect(page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first().locator('.ge-layer-lock-btn')).toHaveClass(/active/);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,141 @@
const { test, expect } = require('@playwright/test');
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 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();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
current = await editorState(page);
const originalLayer = current.layers.find(layer => layer.id === layerId);
expect(originalLayer.masks[0].linked).toBe(true);
expect(originalLayer.masks[0].offset).toEqual({ x: 0, y: 0 });
const originalMaskHash = originalLayer.masks[0].pixelHash;
await maskRow.locator('button[title="Invert mask"]').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].pixelHash)
.not.toBe(originalMaskHash);
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].pixelHash)
.toBe(originalMaskHash);
await maskRow.locator('details.ge-mask-properties > summary').click();
await maskRow.locator('input.ge-mask-density').fill('50');
await maskRow.locator('input.ge-mask-density').dispatchEvent('change');
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].density).toBe(0.5);
await maskRow.locator('input.ge-mask-feather').fill('12');
await maskRow.locator('input.ge-mask-feather').dispatchEvent('change');
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].feather).toBe(12);
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].density).toBe(1);
expect(current.layers.find(layer => layer.id === layerId).masks[0].feather).toBe(0);
await maskRow.locator('button[title="Inspect mask"]').click();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.maskInspectMode;
})).toBe(true);
const inspectedPixel = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return [...state.mainCtx.getImageData(Math.floor(state.imgWidth / 2), Math.floor(state.imgHeight / 2), 1, 1).data];
});
expect(inspectedPixel[0]).toBeGreaterThan(200);
expect(inspectedPixel[1]).toBeGreaterThan(200);
expect(inspectedPixel[2]).toBeGreaterThan(200);
await page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' })
.locator('button[title="Inspect mask"]').click();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.maskInspectMode;
})).toBe(false);
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 dragOnCanvas(page, { x: 0.45, y: 0.45 }, { x: 0.55, y: 0.5 });
current = await editorState(page);
let layer = current.layers.find(item => item.id === layerId);
expect(layer.offset).toEqual(originalLayer.offset);
expect(layer.pixelHash).toBe(originalLayer.pixelHash);
expect(layer.masks[0].linked).toBe(false);
expect(layer.masks[0].offset.x).toBeGreaterThan(0);
expect(layer.masks[0].offset.y).toBeGreaterThan(0);
const fixedDocumentMask = {
x: layer.offset.x + layer.masks[0].offset.x,
y: layer.offset.y + layer.masks[0].offset.y,
};
await layerRow.click();
await dragOnCanvas(page, { x: 0.4, y: 0.4 }, { x: 0.5, y: 0.45 });
current = await editorState(page);
layer = current.layers.find(item => item.id === layerId);
expect(layer.offset.x).toBeGreaterThan(originalLayer.offset.x);
expect(layer.offset.y).toBeGreaterThan(originalLayer.offset.y);
expect({
x: layer.offset.x + layer.masks[0].offset.x,
y: layer.offset.y + layer.masks[0].offset.y,
}).toEqual(fixedDocumentMask);
const beforeTransform = layer;
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-w').fill('280');
await page.locator('#ge-transform-w').dispatchEvent('input');
await page.locator('#ge-transform-apply').click();
current = await editorState(page);
layer = current.layers.find(item => item.id === layerId);
expect(layer.size[0]).toBe(280);
expect({
x: layer.offset.x + layer.masks[0].offset.x,
y: layer.offset.y + layer.masks[0].offset.y,
}).toEqual(fixedDocumentMask);
await page.locator('#ge-undo').click();
await expect.poll(async () => {
const state = await editorState(page);
return state.layers.find(item => item.id === layerId).size[0];
}).toBe(beforeTransform.size[0]);
await maskRow.locator('.ge-mask-link-btn').click();
current = await editorState(page);
const relinked = current.layers.find(item => item.id === layerId);
expect(relinked.masks[0].linked).toBe(true);
const linkedRelativeOffset = { ...relinked.masks[0].offset };
await layerRow.click();
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.45, y: 0.35 });
current = await editorState(page);
layer = current.layers.find(item => item.id === layerId);
expect(layer.masks[0].offset).toEqual(linkedRelativeOffset);
await page.locator('#ge-undo').click();
await expect.poll(async () => {
const state = await editorState(page);
return state.layers.find(item => item.id === layerId).offset.x;
}).toBe(relinked.offset.x);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(item => item.id === layerId)).toEqual(
beforeReopen.layers.find(item => item.id === layerId),
);
const maskRowAfterReopen = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await maskRowAfterReopen.locator('details.ge-mask-properties > summary').click();
await maskRowAfterReopen.locator('button[title="Bake this mask into the layer and remove the mask"]').click();
await expect.poll(async () => (await editorState(page)).layers.find(item => item.id === layerId).masks).toHaveLength(0);
await page.locator('#ge-undo').click();
await expect.poll(async () => (await editorState(page)).layers.find(item => item.id === layerId).masks).toHaveLength(1);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,304 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
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');
const added = state.layers.find(layer => layer.id === state.activeLayerId);
edit.canvas.width = 100;
edit.canvas.height = 80;
added.canvas.width = 50;
added.canvas.height = 40;
state.layerOffsets.set(edit.id, { x: 12, y: 18 });
state.layerOffsets.set(added.id, { x: 190, y: 150 });
});
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await editRow.click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
await page.locator('#ge-selected-align').click();
await page.locator('#ge-layer-align-menu button').filter({ hasText: 'Align center' }).click();
let current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 110, y: 18 });
expect(current.layers.find(layer => layer.name !== 'Background' && layer.name !== 'Edit').offset)
.toEqual({ x: 135, y: 150 });
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 12, y: 18 });
expect(current.layers.find(layer => layer.name !== 'Background' && layer.name !== 'Edit').offset)
.toEqual({ x: 190, y: 150 });
});
test('layer rename cancels on Escape without committing the draft name', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Rename cancel E2E');
const row = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
const rowId = await row.getAttribute('data-layer-id');
const name = row.locator('.ge-layer-name');
const original = await name.textContent();
await name.dblclick();
const stableRow = page.locator(`.ge-layer-item[data-layer-id="${rowId}"]`);
const input = stableRow.locator('.ge-layer-name-input');
await input.fill('Temporary name');
await input.press('Escape');
await expect(stableRow.locator('.ge-layer-name')).toHaveText(original);
await expect(stableRow.locator('.ge-layer-name-input')).toHaveCount(0);
});
test('layer rows can be selected with Enter and Space', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard layer selection E2E');
const background = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
const edit = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await background.focus();
await page.keyboard.press('Enter');
await expect(background).toHaveAttribute('aria-pressed', 'true');
await expect(edit).toHaveAttribute('aria-pressed', 'false');
await edit.focus();
await page.keyboard.press('Space');
await expect(edit).toHaveAttribute('aria-pressed', 'true');
await expect(background).toHaveAttribute('aria-pressed', 'false');
});
test('group rows can be selected with the keyboard', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard group selection E2E');
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
const group = page.locator('.ge-layer-group-row').first();
await group.focus();
await page.keyboard.press('Enter');
await expect(group).toHaveAttribute('aria-pressed', 'true');
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.activeGroupId;
})).toBe(await group.getAttribute('data-group-id'));
});
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');
expect((await editorState(page)).layers.some(layer => layer.id === addedId)).toBe(false);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.some(layer => layer.id === addedId)).toBe(true);
});
test('Ctrl/Cmd+J duplicates the active layer through the layer panel path', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard duplicate E2E');
const before = await editorState(page);
const activeId = before.activeLayerId;
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+j' : 'Control+j');
const after = await editorState(page);
expect(after.layers).toHaveLength(before.layers.length + 1);
expect(after.activeLayerId).not.toBe(activeId);
expect(after.layers.find(layer => layer.id === after.activeLayerId).name).toContain('copy');
});
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 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 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 expect(page.locator('.ge-auto-select-option')).toBeHidden();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
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);
await expect.poll(async () => (await editorState(page)).activeLayerId).toBe(shape.id);
expect((await editorState(page)).selectedLayerIds).toEqual([shape.id]);
});
test('multi-selected layers move together and support history-backed bulk actions', async ({ page }) => {
await openBlankEditor(page);
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
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 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');
const background = current.layers.find(layer => layer.name === 'Background');
expect(edit.offset).toEqual(background.offset);
expect(edit.offset).not.toEqual({ x: 0, y: 0 });
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 0, y: 0 });
expect(current.layers.find(layer => layer.name === 'Background').offset).toEqual({ x: 0, y: 0 });
expect(current.selectedLayerIds).toHaveLength(2);
await page.locator('#ge-redo').click();
await page.locator('#ge-selected-visibility').click();
current = await editorState(page);
expect(current.layers.every(layer => layer.visible === false)).toBe(true);
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.every(layer => layer.visible !== false)).toBe(true);
await page.locator('#ge-selected-lock').click();
current = await editorState(page);
expect(current.layers.every(layer => layer.locked)).toBe(true);
await page.locator('#ge-undo').click();
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);
await page.locator('#ge-selected-delete').click();
current = await editorState(page);
expect(current.layers).toHaveLength(1);
expect(current.layers[0].name).toBe('Background');
expect(current.layers.some(layer => layer.id === addedId)).toBe(false);
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers).toHaveLength(3);
expect(current.selectedLayerIds).toHaveLength(2);
});
test('layer groups composite, lock, collapse, undo, and survive server reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 480, height: 320 }, 'Grouped E2E');
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
await expect(page.locator('.ge-layer-group-row .ge-group-inline-thumb')).toHaveCount(1);
let current = await editorState(page);
expect(current.groups).toHaveLength(1);
expect(current.groups[0].layerIds).toHaveLength(2);
const groupName = page.locator('.ge-layer-group-name');
await groupName.dblclick();
await page.locator('.ge-layer-group-row input.ge-layer-name-input').fill('Hero group');
await page.locator('.ge-layer-group-row input.ge-layer-name-input').press('Enter');
await page.locator('.ge-layer-group-row .ge-layer-opacity').fill('55');
await page.locator('.ge-layer-group-toggle').click();
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(0);
current = await editorState(page);
expect(current.groups[0]).toMatchObject({ name: 'Hero group', opacity: 0.55, collapsed: true });
await page.locator('.ge-layer-group-row .ge-layer-lock-btn').click();
const beforeLockNudge = await editorState(page);
await page.keyboard.press('ArrowRight');
expect((await editorState(page)).layers.map(layer => layer.offset)).toEqual(beforeLockNudge.layers.map(layer => layer.offset));
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 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);
expect(current.layers[0].offset).not.toEqual({ x: 0, y: 0 });
await page.locator('.ge-layer-group-row .ge-layer-vis').click();
current = await editorState(page);
expect(current.groups[0].visible).toBe(false);
expect(current.layers.every(layer => layer.visible)).toBe(true);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups[0].visible).toBe(true);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.groups).toEqual(beforeReopen.groups);
expect(reopened.layers.map(layer => layer.offset)).toEqual(beforeReopen.layers.map(layer => layer.offset));
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
await expect(page.locator('.ge-layer-group-row .ge-group-inline-thumb')).toHaveCount(1);
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();
expect((await editorState(page)).groups).toHaveLength(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups).toHaveLength(1);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('nested groups preserve hierarchy, ancestor locks, collapse, and server reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 420, height: 300 }, 'Nested groups E2E');
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
let current = await editorState(page);
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();
await page.locator(`.ge-layer-item[data-layer-id="${looseId}"]`).click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(3);
await page.locator('#ge-group-selected').click();
current = await editorState(page);
expect(current.groups).toHaveLength(2);
const inner = current.groups.find(group => group.id === innerId);
const outer = current.groups.find(group => group.id !== innerId);
expect(inner.parentId).toBe(outer.id);
expect(outer.layerIds).toEqual([looseId]);
expect(inner.layerIds).toHaveLength(2);
await expect(page.locator('.ge-layer-group-row')).toHaveCount(2);
await expect(page.locator('.ge-layer-group-row .ge-group-inline-thumb')).toHaveCount(2);
await expect(page.locator(`.ge-layer-group-row[data-group-id="${inner.id}"]`)).toHaveCSS('--group-depth', '1');
const outerRow = page.locator(`.ge-layer-group-row[data-group-id="${outer.id}"]`);
await outerRow.locator('.ge-layer-group-toggle').click();
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(0);
await outerRow.locator('.ge-layer-group-toggle').click();
await expect(page.locator('.ge-layer-group-row')).toHaveCount(2);
await outerRow.locator('.ge-layer-lock-btn').click();
await page.locator(`.ge-layer-item[data-layer-id="${inner.layerIds[0]}"]`).click();
const beforeNudge = await editorState(page);
await page.keyboard.press('ArrowRight');
expect((await editorState(page)).layers.map(layer => layer.offset)).toEqual(beforeNudge.layers.map(layer => layer.offset));
await outerRow.locator('.ge-layer-lock-btn').click();
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.groups).toEqual(beforeReopen.groups);
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();
current = await editorState(page);
expect(current.groups).toHaveLength(1);
expect(current.groups[0]).toMatchObject({ id: inner.id, parentId: null });
expect(current.groups[0].layerIds).toHaveLength(2);
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(2);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups).toEqual(beforeReopen.groups);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,48 @@
const { test, expect } = require('@playwright/test');
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
async function seedVisibleLayers(page) {
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const background = state.layers.find(layer => layer.name === 'Background');
const edit = state.layers.find(layer => layer.name === 'Edit');
background.ctx.fillStyle = '#26354f';
background.ctx.fillRect(0, 0, background.canvas.width, background.canvas.height);
edit.ctx.fillStyle = '#cf5b4a';
edit.ctx.fillRect(48, 32, edit.canvas.width - 96, edit.canvas.height - 64);
window.galleryEditorComposite?.();
});
}
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 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-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');
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const before = await flattenedPixelDigest(page);
const beforeState = await editorState(page);
expect(beforeState.layers.some(layer => layer.effects.length)).toBe(true);
expect(beforeState.layers.some(layer => layer.kind === 'adjustment')).toBe(true);
await page.locator('#ge-merge-all').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(1);
const afterState = await editorState(page);
expect(afterState.layers[0].effects).toHaveLength(0);
expect(afterState.layers).toEqual([
expect.objectContaining({ kind: 'raster', effects: [] }),
]);
expect(await flattenedPixelDigest(page)).toEqual(before);
});
@@ -0,0 +1,212 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const { editorState, openBlankEditor, waitForDraft } = require('./helpers');
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('mobile tool and layer sheets do not overlap mask controls', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile layer sheet');
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
const controls = page.locator('.ge-controls');
const layerSheet = page.locator('.ge-right-panel');
await expect(controls).toHaveClass(/dismissed/);
await expect(layerSheet).not.toHaveClass(/minimized/);
await page.locator('.ge-layers-title').click();
await expect(layerSheet).toHaveClass(/expanded/);
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();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
const maskName = maskRow.locator('.ge-layer-name');
const maskThumb = maskRow.locator('.ge-mask-inline-thumb');
await expect(maskRow).toBeVisible();
await expect(maskName).toContainText('Layer Mask');
await expect(maskThumb).toBeVisible();
const rowBox = await maskRow.boundingBox();
const nameBox = await maskName.boundingBox();
const linkBox = await maskRow.locator('.ge-mask-link-btn').boundingBox();
expect(rowBox).toBeTruthy();
expect(nameBox.width).toBeGreaterThan(80);
expect(rowBox.x).toBeGreaterThanOrEqual(0);
expect(rowBox.x + rowBox.width).toBeLessThanOrEqual(390);
expect(nameBox.x).toBeGreaterThanOrEqual(0);
expect(nameBox.x + nameBox.width).toBeLessThanOrEqual(linkBox.x);
expect(await page.locator('.ge-layers-list').evaluate(list => list.scrollLeft)).toBe(0);
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 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 expect(controls).toHaveClass(/dismissed/);
});
test('mobile group rows keep their preview and touch controls inside the viewport', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile group preview');
await page.locator('.ge-layers-title').click();
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
const groupRow = page.locator('.ge-layer-group-row').first();
await expect(groupRow).toBeVisible();
await expect(groupRow.locator('.ge-group-inline-thumb')).toBeVisible();
const rowBox = await groupRow.boundingBox();
const thumbBox = await groupRow.locator('.ge-group-inline-thumb').boundingBox();
const toggleBox = await groupRow.locator('.ge-layer-group-toggle').boundingBox();
const visibilityBox = await groupRow.locator('.ge-layer-vis').boundingBox();
expect(rowBox).toBeTruthy();
for (const box of [thumbBox, toggleBox, visibilityBox]) {
expect(box).toBeTruthy();
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(390);
}
});
test('mobile touch mask editing supports undo and persists through reload', async ({ page, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile mask persistence');
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();
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();
// 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();
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const cdp = await page.context().newCDPSession(page);
const visibleCanvasPoint = await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const rect = canvas?.getBoundingClientRect();
if (!canvas || !rect) return null;
for (let y = rect.top + 8; y < rect.bottom - 8; y += 8) {
for (let x = rect.left + 8; x < rect.right - 8; x += 8) {
if (document.elementFromPoint(x, y) === canvas) return { x, y };
}
}
return null;
});
expect(visibleCanvasPoint).toBeTruthy();
const x1 = visibleCanvasPoint.x;
const x2 = Math.min(canvasBox.x + canvasBox.width - 8, x1 + canvasBox.width * 0.35);
const y = visibleCanvasPoint.y;
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: x1, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: x2, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const painted = await editorState(page);
const paintedLayer = painted.layers.find(layer => layer.masks.length);
expect(paintedLayer.masks[0].pixelHash).not.toBe(before);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
.toBe(before);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
.toBe(paintedLayer.masks[0].pixelHash);
const draftId = await waitForDraft(page);
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('.ge-main-canvas')).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const state = await editorState(page);
return state.layers.find(layer => layer.masks.length)?.masks[0]?.pixelHash;
}).toBe(paintedLayer.masks[0].pixelHash);
await page.request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('mobile export dialog stays usable and downloads the requested PNG', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile export');
await page.locator('#ge-save-menu-btn').click();
await page.locator('#ge-download').click();
const dialog = page.locator('.ge-export-dialog');
await expect(dialog).toBeVisible();
const dialogBox = await dialog.boundingBox();
expect(dialogBox).toBeTruthy();
expect(dialogBox.x).toBeGreaterThanOrEqual(0);
expect(dialogBox.y).toBeGreaterThanOrEqual(0);
expect(dialogBox.x + dialogBox.width).toBeLessThanOrEqual(390);
expect(dialogBox.y + dialogBox.height).toBeLessThanOrEqual(844);
await page.locator('#ge-export-width').fill('160');
await page.locator('#ge-export-filename').fill('mobile-export');
const downloadPromise = page.waitForEvent('download');
await dialog.locator('button[type="submit"]').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('mobile-export.png');
const bytes = fs.readFileSync(await download.path());
expect(bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(bytes.readUInt32BE(16)).toBe(160);
expect(bytes.readUInt32BE(20)).toBe(120);
});
test('mobile touch moves an unlinked layer mask without moving its layer', async ({ page, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile mask movement');
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();
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();
const point = await page.evaluate(() => {
const target = document.querySelector('.ge-main-canvas');
const rect = target?.getBoundingClientRect();
if (!target || !rect) return null;
for (let y = rect.top + 8; y < rect.bottom - 8; y += 8) {
for (let x = rect.left + 8; x < rect.right - 8; x += 8) {
if (document.elementFromPoint(x, y) === target) return { x, y };
}
}
return null;
});
expect(point).toBeTruthy();
const before = await editorState(page);
const cdp = await page.context().newCDPSession(page);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: point.x, y: point.y, id: 1, radiusX: 6, radiusY: 6 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: point.x + 24, y: point.y + 16, id: 1, radiusX: 6, radiusY: 6 }],
});
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const moved = await editorState(page);
const beforeLayer = before.layers.find(layer => layer.id === layerId);
const movedLayer = moved.layers.find(layer => layer.id === layerId);
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-undo').click();
expect((await editorState(page)).layers.find(layer => layer.id === layerId).masks[0].offset)
.toEqual(beforeLayer.masks[0].offset);
});
@@ -0,0 +1,127 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs/promises');
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
function selectionBounds(layers) {
const left = Math.min(...layers.map(layer => layer.offset.x));
const top = Math.min(...layers.map(layer => layer.offset.y));
const right = Math.max(...layers.map(layer => layer.offset.x + layer.size[0]));
const bottom = Math.max(...layers.map(layer => layer.offset.y + layer.size[1]));
return { left, top, right, bottom, width: right - left, height: bottom - top, centerX: (left + right) / 2, centerY: (top + bottom) / 2 };
}
test('shared transform preserves relative layout, masks, retained text, undo, cancel, and reopen', async ({ page, request }) => {
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-tool-btn[data-tool="text"]').click();
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');
await page.locator('#ge-text-size').fill('30');
await page.locator('#ge-text-size').press('Enter');
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 editRow.click();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
await page.locator('#ge-select-all-layers').click();
const initial = await editorState(page);
expect(initial.selectedLayerIds).toHaveLength(3);
const bounds = selectionBounds(initial.layers);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
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);
await page.locator('#ge-transform-w').fill(String(bounds.width * 2));
expect(Number(await page.locator('#ge-transform-h').inputValue())).toBe(bounds.height * 2);
await page.locator('#ge-transform-apply').click();
const transformed = await editorState(page);
const initialByName = new Map(initial.layers.map(layer => [layer.name, layer]));
for (const layer of transformed.layers.filter(layer => layer.kind === 'raster')) {
const before = initialByName.get(layer.name);
expect(layer.size).toEqual([before.size[0] * 2, before.size[1] * 2]);
const expectedX = Math.round(bounds.centerX + (before.offset.x + before.size[0] / 2 - bounds.centerX) * 2 - layer.size[0] / 2);
const expectedY = Math.round(bounds.centerY + (before.offset.y + before.size[1] / 2 - bounds.centerY) * 2 - layer.size[1] / 2);
expect(layer.offset).toEqual({ x: expectedX, y: expectedY });
}
const transformedEdit = transformed.layers.find(layer => layer.name === 'Edit');
expect(transformedEdit.clipped).toBe(true);
expect(transformed.groups).toEqual(initial.groups);
expect(transformed.layers.map(layer => layer.id)).toEqual(initial.layers.map(layer => layer.id));
expect(transformedEdit.masks[0].size).toEqual(transformedEdit.size);
const beforeText = initial.layers.find(layer => layer.kind === 'text');
const afterText = transformed.layers.find(layer => layer.kind === 'text');
expect(afterText.text.content).toBe('Transform together');
expect(afterText.text.transform.scaleX).toBeCloseTo(beforeText.text.transform.scaleX * 2, 5);
expect(afterText.text.transform.scaleY).toBeCloseTo(beforeText.text.transform.scaleY * 2, 5);
await page.locator('#ge-undo').click();
const undone = await editorState(page);
expect(undone.layers).toEqual(initial.layers);
expect(undone.selectedLayerIds).toEqual(initial.selectedLayerIds);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers).toEqual(transformed.layers);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
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');
await expect.poll(async () => {
const edit = (await editorState(page)).layers.find(layer => layer.name === 'Edit');
return edit.size;
}).toEqual([transformedEdit.size[1], transformedEdit.size[0]]);
await page.locator('#ge-transform-flip-h').click();
await expect(page.locator('#ge-transform-w')).toHaveValue(`-${secondWidth}`);
await page.locator('#ge-transform-cancel-btn').click();
const cancelled = await editorState(page);
expect(cancelled.layers).toEqual(transformed.layers);
expect(cancelled.groups).toEqual(transformed.groups);
expect(cancelled.redo).toBe(0);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
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();
await page.locator('#ge-transform-apply').click();
const finalTransform = await editorState(page);
const finalText = finalTransform.layers.find(layer => layer.kind === 'text');
expect(finalText.text.content).toBe('Transform together');
expect(finalText.text.transform.rotation).toBeCloseTo(afterText.text.transform.rotation + 90, 5);
expect(finalText.text.transform.flipH).toBe(!afterText.text.transform.flipH);
expect(finalTransform.groups).toEqual(transformed.groups);
expect(finalTransform.layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
const downloadPromise = page.waitForEvent('download');
await page.locator('#ge-save-menu-btn').click();
await page.locator('#ge-save-project').click();
const download = await downloadPromise;
const projectBuffer = await fs.readFile(await download.path());
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers).toEqual(transformed.layers);
await page.locator('#ge-save-menu-btn').click();
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-load-project').click();
const chooser = await chooserPromise;
await chooser.setFiles({
name: 'transform-roundtrip.geproj.json',
mimeType: 'application/json',
buffer: projectBuffer,
});
await expect.poll(async () => (await editorState(page)).layers).toEqual(finalTransform.layers);
expect((await editorState(page)).groups).toEqual(finalTransform.groups);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
expect((await editorState(page)).layers).toEqual(finalTransform.layers);
expect((await editorState(page)).groups).toEqual(finalTransform.groups);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,206 @@
const { test, expect } = require('@playwright/test');
const { editorState, flattenedPixelDigest, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function patternedPng(page, width, height, colors) {
const base64 = await page.evaluate(({ width: w, height: h, colors: palette }) => {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const context = canvas.getContext('2d');
for (let y = 0; y < h; y += 1) for (let x = 0; x < w; x += 1) {
context.fillStyle = palette[(x + y * w) % palette.length];
context.fillRect(x, y, 1, 1);
}
return canvas.toDataURL('image/png').split(',')[1];
}, { width, height, colors });
return Buffer.from(base64, 'base64');
}
test('placed image transforms from source, replaces in place, rasterizes, and reopens', async ({ page, request }) => {
await openBlankEditor(page, { width: 300, height: 200 }, 'Placed layers E2E');
const firstImage = await patternedPng(page, 40, 20, ['#f44336', '#4caf50', '#2196f3', '#ffeb3b']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
const chooser = await chooserPromise;
await chooser.setFiles({ name: 'first-pattern.png', mimeType: 'image/png', buffer: firstImage });
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const imported = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
expect(imported.placed.sourceSize).toEqual([40, 20]);
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();
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 page.locator('#ge-transform-w').fill(String(originalWidth));
await page.locator('#ge-transform-apply').click();
const restoredSize = (await editorState(page)).layers.find(layer => layer.id === imported.id);
expect(restoredSize.placed.sourcePixelHash).toBe(sourceHash);
expect(restoredSize.size[0]).toBe(originalWidth);
expect(restoredSize.masks).toHaveLength(1);
const beforeReplaceFrame = { size: restoredSize.size, offset: restoredSize.offset };
const beforeReplaceMask = restoredSize.masks[0];
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();
const replaceChooser = await replaceChooserPromise;
await replaceChooser.setFiles({ name: 'replacement.png', mimeType: 'image/png', buffer: replacement });
await expect.poll(async () => {
const layer = (await editorState(page)).layers.find(item => item.id === imported.id);
return layer.placed?.sourceName;
}).toBe('replacement.png');
const replaced = (await editorState(page)).layers.find(layer => layer.id === imported.id);
expect(replaced.size).toEqual(beforeReplaceFrame.size);
expect(replaced.offset).toEqual(beforeReplaceFrame.offset);
expect(replaced.placed.sourcePixelHash).not.toBe(sourceHash);
expect(replaced.masks[0]).toEqual(beforeReplaceMask);
const beforeRasterize = await flattenedPixelDigest(page);
await page.locator('#ge-layer-tools 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();
expect(await flattenedPixelDigest(page)).toEqual(beforeRasterize);
await page.locator('#ge-undo').click();
await expect.poll(async () => {
const layer = (await editorState(page)).layers.find(item => item.id === imported.id);
return layer.kind;
}).toBe('placed');
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = (await editorState(page)).layers.find(layer => layer.id === imported.id);
expect(reopened.kind).toBe('placed');
expect(reopened.placed.sourceName).toBe('replacement.png');
expect(reopened.masks[0]).toEqual(beforeReplaceMask);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('raster layers can be converted to editable sources without changing pixels', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Editable source E2E');
const image = await patternedPng(page, 36, 24, ['#ef5350', '#42a5f5', '#66bb6a']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
const chooser = await chooserPromise;
await chooser.setFiles({ name: 'editable-source.png', mimeType: 'image/png', buffer: image });
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();
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 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);
expect(converted.placed.sourcePixelHash).toBe(before.pixelHash);
expect(converted.size).toEqual(before.size);
expect(converted.offset).toEqual(before.offset);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = (await editorState(page)).layers.find(layer => layer.id === placed.id);
expect(reopened.kind).toBe('placed');
expect(reopened.placed.sourcePixelHash).toBe(before.pixelHash);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('external clipboard images use the editable source import path', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Clipboard source E2E');
const image = await patternedPng(page, 28, 18, ['#ef5350', '#42a5f5', '#66bb6a']);
await page.evaluate(base64 => {
const bytes = Uint8Array.from(atob(base64), char => char.charCodeAt(0));
const file = new File([bytes], 'clipboard.png', { type: 'image/png' });
const data = new DataTransfer();
data.items.add(file);
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true }));
}, image.toString('base64'));
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const pasted = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
expect(pasted.placed.sourceName).toBe('Pasted image');
expect(pasted.placed.sourceSize).toEqual([28, 18]);
const draftId = await waitForDraft(page);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('duplicating a placed layer copies its source independently', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Duplicate source E2E');
const image = await patternedPng(page, 28, 18, ['#ef5350', '#42a5f5', '#66bb6a']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
await (await chooserPromise).setFiles({ name: 'duplicate-source.png', mimeType: 'image/png', buffer: image });
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 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');
const placed = state.layers.filter(layer => layer.kind === 'placed');
return {
count: placed.length,
sameSource: placed[0].placed.sourceCanvas === placed[1].placed.sourceCanvas,
sizes: placed.map(layer => [layer.placed.sourceCanvas.width, layer.placed.sourceCanvas.height]),
};
});
expect(sourceOwnership).toEqual({ count: 2, sameSource: false, sizes: [[28, 18], [28, 18]] });
const draftId = await waitForDraft(page);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('raster transforms preserve source pixels by default and can opt out', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Non-destructive transform E2E');
const image = await patternedPng(page, 42, 28, ['#ff7043', '#26a69a', '#5c6bc0']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
const chooser = await chooserPromise;
await chooser.setFiles({ name: 'transform-source.png', mimeType: 'image/png', buffer: image });
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();
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 expect(page.locator('#ge-transform-preserve-source')).toBeChecked();
await page.locator('#ge-transform-w').fill('84');
await page.locator('#ge-transform-apply').click();
const preserved = (await editorState(page)).layers.find(layer => layer.id === placed.id);
expect(preserved.kind).toBe('placed');
expect(preserved.placed.sourceSize).toEqual([42, 28]);
expect(preserved.placed.sourcePixelHash).toBe(sourceHash);
const draftId = await waitForDraft(page);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('raster transforms can explicitly replace source pixels', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Destructive transform E2E');
const image = await patternedPng(page, 42, 28, ['#ff7043', '#26a69a', '#5c6bc0']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
const chooser = await chooserPromise;
await chooser.setFiles({ name: 'destructive-transform.png', mimeType: 'image/png', buffer: image });
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 expect(page.locator('#ge-transform-preserve-source')).toBeChecked();
await page.locator('#ge-transform-preserve-source').uncheck();
await page.locator('#ge-transform-w').fill('60');
await page.locator('#ge-transform-apply').click();
const destructive = (await editorState(page)).layers.find(layer => layer.id === placed.id);
expect(destructive.kind).toBe('raster');
expect(destructive.placed).toBeNull();
const draftId = await waitForDraft(page);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,180 @@
const { test, expect } = require('@playwright/test');
const { editorState, openBlankEditor } = require('./helpers.js');
async function currentPng(page) {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.layers[0].canvas.toDataURL('image/png');
});
}
async function loadProject(page, project) {
await page.locator('#ge-save-menu-btn').click();
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-load-project').click();
const chooser = await chooserPromise;
await chooser.setFiles({
name: 'recovery.geproj.json',
mimeType: 'application/json',
buffer: Buffer.from(JSON.stringify(project)),
});
}
test('mixed-corrupt project recovers valid layers and remains undoable', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Recovery E2E');
const before = await editorState(page);
const png = await currentPng(page);
await loadProject(page, {
type: 'odysseus-gallery-editor-project',
v: 5,
imgWidth: 160,
imgHeight: 120,
activeLayerId: 'broken',
nextLayerId: 3,
view: {},
layers: [
{ id: 'good', name: 'Recovered photo', canvasW: 160, canvasH: 120, dataUrl: png, offset: { x: 0, y: 0 }, masks: [] },
{ id: 'broken', name: 'Broken pixels', canvasW: 160, canvasH: 120, dataUrl: 'data:image/png;base64,AAAA', offset: { x: 0, y: 0 }, masks: [] },
],
});
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name)).toEqual(['Recovered photo']);
await expect(page.locator('#toast')).toContainText('Broken pixels was skipped');
await page.locator('#ge-undo').click();
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name)).toEqual(before.layers.map(layer => layer.name));
expect((await editorState(page)).dimensions).toEqual(before.dimensions);
});
test('fully corrupt project leaves the open document unchanged', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Recovery E2E');
const before = await editorState(page);
await loadProject(page, {
type: 'odysseus-gallery-editor-project',
v: 5,
imgWidth: 160,
imgHeight: 120,
activeLayerId: 'broken',
view: {},
layers: [
{ id: 'broken', name: 'Broken pixels', canvasW: 160, canvasH: 120, dataUrl: 'data:image/png;base64,AAAA', offset: { x: 0, y: 0 }, masks: [] },
],
});
await expect(page.locator('#toast')).toContainText('No recoverable layers could be decoded');
await expect.poll(async () => await editorState(page)).toEqual(before);
});
test('active editor reopens after a browser refresh', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Refresh recovery');
await expect.poll(async () => (await editorState(page)).draftId).not.toBeNull();
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('#gallery-modal')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#gallery-editor-tab')).toHaveClass(/active/);
await expect(page.locator('.ge-main-canvas')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name))
.toEqual(['Background', 'Edit']);
});
test('active editor reopens after a mobile browser refresh', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile refresh recovery');
await expect.poll(async () => (await editorState(page)).draftId).not.toBeNull();
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('#gallery-modal')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#gallery-editor-tab')).toHaveClass(/active/);
await expect(page.locator('.ge-main-canvas')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name))
.toEqual(['Background', 'Edit']);
});
test('new project size dialog cancels cleanly with Escape', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.locator('#tool-gallery-btn').click();
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.locator('#gallery-editor-new').click();
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
await page.locator('#ge-canvas-prompt-w').press('Escape');
await expect(page.locator('#ge-canvas-size-overlay')).toBeHidden();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.editorOpen;
})).toBe(false);
await expect(page.locator('#gallery-editor-new')).toBeVisible();
});
test('editor topbar uses uppercase action 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-import-topbar')).toHaveText(/IMPORT/);
await expect(page.locator('#ge-save-menu-btn')).toHaveText(/SAVE/);
});
test('canvas size anchor keeps the composition centered', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Canvas anchor E2E');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.name === 'Edit');
state.layerOffsets.set(layer.id, { x: 10, y: 5 });
});
await page.locator('#ge-image-menu-btn').click();
await page.locator('[data-image-action="canvas-size"]').click();
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
await page.locator('#ge-canvas-prompt-lock').uncheck();
await page.locator('#ge-canvas-prompt-w').fill('420');
await page.locator('#ge-canvas-prompt-h').fill('340');
await page.locator('.ge-canvas-anchor').nth(4).click();
await page.locator('#ge-canvas-prompt-ok').click();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.name === 'Edit');
return { dimensions: [state.imgWidth, state.imgHeight], offset: state.layerOffsets.get(layer.id) };
})).toEqual({ dimensions: [420, 340], offset: { x: 60, y: 55 } });
});
test('image size supports percentage resampling with locked proportions', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Image size E2E');
await page.locator('#ge-image-menu-btn').click();
await page.locator('[data-image-action="image-size"]').click();
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
await expect(page.locator('#ge-canvas-prompt-units')).toHaveValue('px');
await page.locator('#ge-canvas-prompt-units').selectOption('percent');
await page.locator('#ge-canvas-prompt-w').fill('50');
await expect(page.locator('#ge-canvas-prompt-h')).toHaveValue('50');
await page.locator('#ge-canvas-prompt-interpolation').selectOption('medium');
await page.locator('#ge-canvas-prompt-ok').click();
await expect.poll(async () => (await editorState(page)).dimensions).toEqual([160, 120]);
await page.locator('#ge-image-menu-btn').click();
await page.locator('[data-image-action="image-size"]').click();
await expect(page.locator('#ge-canvas-prompt-units')).toHaveValue('px');
await expect(page.locator('#ge-canvas-prompt-w')).toHaveValue('160');
await expect(page.locator('#ge-canvas-prompt-h')).toHaveValue('120');
await page.locator('#ge-canvas-prompt-cancel').click();
});
test('canvas size supports percentage bounds with an anchor', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Canvas percentage E2E');
await page.locator('#ge-image-menu-btn').click();
await page.locator('[data-image-action="canvas-size"]').click();
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
await page.locator('#ge-canvas-prompt-units').selectOption('percent');
await page.locator('#ge-canvas-prompt-w').fill('125');
await expect(page.locator('#ge-canvas-prompt-h')).toHaveValue('125');
await page.locator('.ge-canvas-anchor').nth(4).click();
await page.locator('#ge-canvas-prompt-ok').click();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.name === 'Edit');
return { dimensions: [state.imgWidth, state.imgHeight], offset: state.layerOffsets.get(layer.id) };
})).toEqual({ dimensions: [400, 300], offset: { x: 40, y: 30 } });
});
@@ -0,0 +1,20 @@
const { test, expect } = require('@playwright/test');
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 dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
const before = await editorState(page);
const draftId = await waitForDraft(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 after = await editorState(page);
expect(after.layers).toEqual(before.layers);
expect(after.dimensions).toEqual(before.dimensions);
});
@@ -0,0 +1,181 @@
const { test, expect } = require('@playwright/test');
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 dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
const initial = await editorState(page);
expect(initial.selection?.space).toBe('document');
expect(initial.selection?.bounds).toBeTruthy();
const layerBefore = initial.layers.find(layer => layer.id === initial.activeLayerId);
const overlayHash = () => page.locator('.ge-selection-overlay').evaluate(canvas => {
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let hash = 0;
for (let i = 0; i < data.length; i += 4) if (data[i + 3]) hash = ((hash * 33) ^ data[i]) >>> 0;
return hash;
});
const firstOverlay = await overlayHash();
expect(firstOverlay).not.toBe(0);
await expect.poll(overlayHash, { timeout: 1200 }).not.toBe(firstOverlay);
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.45, y: 0.42 });
const moved = await editorState(page);
expect(moved.selection.bounds.x).toBeGreaterThan(initial.selection.bounds.x);
expect(moved.selection.bounds.y).toBeGreaterThan(initial.selection.bounds.y);
const layerAfterMove = moved.layers.find(layer => layer.id === moved.activeLayerId);
expect(layerAfterMove.offset).toEqual(layerBefore.offset);
expect(layerAfterMove.pixelHash).toBe(layerBefore.pixelHash);
await page.keyboard.press('ArrowRight');
await expect.poll(async () => (await editorState(page)).selection.bounds.x).toBe(moved.selection.bounds.x + 1);
await page.keyboard.press('q');
await expect(page.locator('#ge-quick-mask-bar')).toBeVisible();
expect((await editorState(page)).quickMaskActive).toBe(true);
const beforePaint = (await editorState(page)).selection.pixelHash;
await dragOnCanvas(page, { x: 0.72, y: 0.72 }, { x: 0.78, y: 0.72 });
await expect.poll(async () => (await editorState(page)).selection.pixelHash).not.toBe(beforePaint);
await page.locator('.ge-quick-mask-done').click();
await expect(page.locator('#ge-quick-mask-bar')).toBeHidden();
expect((await editorState(page)).quickMaskActive).toBe(false);
});
test('marquee supports exact fixed-size and 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 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();
await page.locator('#ge-marquee-height').fill('3');
await page.locator('#ge-marquee-height').blur();
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.6, y: 0.3 });
const ratioBounds = (await editorState(page)).selection.bounds;
expect(ratioBounds.width).toBe(160);
expect(ratioBounds.height).toBe(120);
expect(ratioBounds.width / ratioBounds.height).toBeCloseTo(4 / 3, 5);
});
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 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-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);
expect(moved.selection.bounds.x).toBeGreaterThan(original.selection.bounds.x);
expect(moved.selection.bounds.y).toBeGreaterThan(original.selection.bounds.y);
await page.locator('#ge-transform-w').fill('140');
await page.locator('#ge-transform-w').dispatchEvent('input');
await page.locator('#ge-transform-rot').fill('25');
await page.locator('#ge-transform-rot').dispatchEvent('input');
await page.locator('#ge-transform-apply').click();
let transformed = await editorState(page);
const transformedLayer = transformed.layers.find(layer => layer.id === transformed.activeLayerId);
expect(transformed.selection.pixelHash).not.toBe(original.selection.pixelHash);
expect(transformed.selection.bounds.width).toBeGreaterThan(original.selection.bounds.width);
expect(transformedLayer.offset).toEqual(originalLayer.offset);
expect(transformedLayer.pixelHash).toBe(originalLayer.pixelHash);
await page.keyboard.press('Control+z');
await expect.poll(async () => (await editorState(page)).selection.pixelHash).toBe(original.selection.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="transform"]').click();
await page.locator('#ge-transform-h').fill('40');
await page.locator('#ge-transform-h').dispatchEvent('input');
expect((await editorState(page)).selection.pixelHash).not.toBe(original.selection.pixelHash);
await page.locator('#ge-transform-cancel-btn').click();
transformed = await editorState(page);
expect(transformed.selection.pixelHash).toBe(original.selection.pixelHash);
expect(transformed.redo).toBe(0);
await page.setViewportSize({ width: 390, height: 844 });
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 page.locator('#ge-transform-cancel-btn').click();
});
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 dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.5, y: 0.6 });
const original = (await editorState(page)).selection;
await page.locator('#ge-selection-menu-btn').click();
await page.locator('#ge-selection-name').fill('Subject');
await page.locator('[data-selection-action="save"]').click();
let current = await editorState(page);
expect(current.savedSelections).toHaveLength(1);
expect(current.savedSelections[0].name).toBe('Subject');
expect(current.savedSelections[0].pixelHash).toBe(original.pixelHash);
await page.locator('[data-selection-action="deselect"]').click();
current = await editorState(page);
expect(current.selection).toBeNull();
expect(current.lastSelection.pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="reselect"]').click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="deselect"]').click();
await dragOnCanvas(page, { x: 0.62, y: 0.15 }, { x: 0.88, y: 0.4 });
expect((await editorState(page)).selection.pixelHash).not.toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('.ge-saved-selection-load', { hasText: 'Subject' }).click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.savedSelections).toHaveLength(1);
expect(current.savedSelections[0].pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('.ge-saved-selection-load', { hasText: 'Subject' }).click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('.ge-saved-selection-delete').click();
expect((await editorState(page)).savedSelections).toHaveLength(0);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('#ge-selection-menu')).toBeHidden();
await page.waitForTimeout(250);
await page.locator('#ge-selection-menu-btn').click();
await expect(page.locator('#ge-selection-menu')).toBeVisible();
const mobileMenu = await page.locator('#ge-selection-menu').boundingBox();
expect(mobileMenu.x).toBeGreaterThanOrEqual(0);
expect(mobileMenu.x + mobileMenu.width).toBeLessThanOrEqual(390);
});
@@ -0,0 +1,171 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
async function openRefine(page) {
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="refine"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
}
async function drawLasso(page, points) {
const box = await page.locator('.ge-main-canvas').boundingBox();
const point = ([x, y]) => ({ x: box.x + box.width * x, y: box.y + box.height * y });
const first = point(points[0]);
await page.mouse.move(first.x, first.y);
await page.mouse.down();
for (const item of points.slice(1)) {
const next = point(item);
await page.mouse.move(next.x, next.y, { steps: 3 });
}
await page.mouse.move(first.x, first.y, { steps: 3 });
await page.mouse.up();
}
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 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 dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
const before = await editorState(page);
await page.keyboard.press('Control+c');
await page.evaluate(() => {
window.dispatchEvent(new Event('paste', { bubbles: true, cancelable: true }));
});
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Pasted Selection' })).toBeVisible();
const pasted = await editorState(page);
expect(pasted.layers).toHaveLength(before.layers.length + 1);
const pastedLayer = pasted.layers.at(-1);
expect(pasted.activeLayerId).toBe(pastedLayer.id);
expect(pastedLayer.kind).toBe('placed');
expect(pastedLayer.placed.sourceSize).toEqual(pastedLayer.size);
await page.locator('#ge-undo').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length);
await page.locator('#ge-redo').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length + 1);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
const reopenedLayer = reopened.layers.at(-1);
expect(reopenedLayer.kind).toBe('placed');
expect(reopenedLayer.placed.sourceSize).toEqual(pastedLayer.size);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('cut selection is one undoable move from source to new layer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection cut');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
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 dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
const before = await editorState(page);
const sourceSignature = () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.name === 'Edit');
const data = layer.canvas.getContext('2d').getImageData(0, 0, layer.canvas.width, layer.canvas.height).data;
let hash = 2166136261;
for (const value of data) {
hash ^= value;
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
});
const beforeSource = await sourceSignature();
await page.keyboard.press('Control+x');
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Wand copy' })).toBeVisible();
const cut = await editorState(page);
expect(cut.layers).toHaveLength(before.layers.length + 1);
expect(await sourceSignature()).not.toBe(beforeSource);
await page.locator('#ge-undo').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length);
expect(await sourceSignature()).toBe(beforeSource);
});
test('completed lasso copy preserves the source layer coordinate space', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Offset selection copy');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.layerOffsets.set(state.activeLayerId, { x: 32, y: 24 });
window.galleryEditorComposite?.();
});
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
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 drawLasso(page, [[0.26, 0.26], [0.68, 0.26], [0.68, 0.64], [0.26, 0.64]]);
const before = await editorState(page);
const source = before.layers.find(layer => layer.id === before.activeLayerId);
await page.locator('#ge-lasso-copy').click();
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Wand copy' })).toBeVisible();
const copied = await editorState(page);
const copy = copied.layers.find(layer => layer.name === 'Wand copy');
expect(copy.size).toEqual(source.size);
expect(copy.offset).toEqual(source.offset);
});
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 dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.55, y: 0.55 });
const original = (await editorState(page)).selection;
await openRefine(page);
const expand = page.locator('.ge-filter-modal input[data-key="expand"]');
await expand.fill('12');
await expand.dispatchEvent('input');
await expect.poll(async () => (await editorState(page)).selection.bounds.width)
.toBeGreaterThan(original.bounds.width);
await page.locator('.ge-filter-modal [data-action="cancel"]').click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
await openRefine(page);
const appliedExpand = page.locator('.ge-filter-modal input[data-key="expand"]');
await appliedExpand.fill('12');
await appliedExpand.dispatchEvent('input');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const refined = (await editorState(page)).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();
let current = await editorState(page);
const active = current.layers.find(layer => layer.id === current.activeLayerId);
expect(active.masks).toHaveLength(1);
expect(active.masks[0].mode).toBe('layer');
expect(active.masks[0].pixelHash).toBe(refined.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="deselect"]').click();
expect((await editorState(page)).selection).toBeNull();
await page.getByRole('button', { name: 'Load mask as selection' }).click();
current = await editorState(page);
expect(current.selection.pixelHash).toBe(refined.pixelHash);
});
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 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');
await page.locator('#ge-lasso-section [data-wand-mode="add"]').click();
await drawLasso(page, [[0.55, 0.2], [0.85, 0.2], [0.85, 0.55], [0.55, 0.55]]);
const added = (await editorState(page)).selection;
expect(added.bounds.width).toBeGreaterThan(first.bounds.width);
await page.locator('#ge-lasso-section [data-wand-mode="subtract"]').click();
await drawLasso(page, [[0.05, 0.15], [0.4, 0.15], [0.4, 0.6], [0.05, 0.6]]);
const subtracted = (await editorState(page)).selection;
expect(subtracted.bounds.x).toBeGreaterThan(first.bounds.x);
await page.locator('#ge-lasso-section [data-wand-mode="intersect"]').click();
await drawLasso(page, [[0.65, 0.25], [0.78, 0.25], [0.78, 0.48], [0.65, 0.48]]);
const intersected = (await editorState(page)).selection;
expect(intersected.bounds.width).toBeLessThan(subtracted.bounds.width);
});
+334
View File
@@ -0,0 +1,334 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, 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 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);
const editor = page.locator('.ge-direct-text-editor');
await expect(editor).toBeVisible();
await editor.fill('Editable canvas title');
await editor.press('Control+Enter');
await expect(editor).toHaveCount(0);
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 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');
const before = await editorState(page);
const textLayer = before.layers.find(layer => layer.kind === 'text');
expect(textLayer.text).toMatchObject({
content: 'Editable canvas title',
fontFamily: 'Georgia',
lineHeight: 1.4,
letterSpacing: 3.5,
autoWidth: true,
frameHeight: 180,
verticalAlign: 'bottom',
});
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.kind === 'text').text).toEqual(textLayer.text);
});
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();
const section = page.locator('#ge-text-section');
await expect(section).toBeVisible();
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await expect(page.locator('#ge-text-frame-height')).toBeVisible();
await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
});
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 expect(page.locator('#ge-shape-section')).toBeVisible();
const types = ['rectangle', 'ellipse', 'line', 'polygon'];
for (let index = 0; index < types.length; index += 1) {
const type = types[index];
await page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first().click();
await page.locator(`[data-shape-type="${type}"]`).click();
await dragOnCanvas(
page,
{ x: 0.12 + index * 0.18, y: 0.2 },
{ x: 0.25 + index * 0.18, y: 0.42 },
);
}
let current = await editorState(page);
const shapes = current.layers.filter(layer => layer.kind === 'shape');
expect(shapes.map(layer => layer.shape.type)).toEqual(types);
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');
current = await editorState(page);
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();
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();
current = await editorState(page);
const transformed = current.layers.find(layer => layer.id === polygon.id);
expect(transformed.kind).toBe('shape');
expect(transformed.shape.transform.scaleX).toBeGreaterThan(1);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.filter(layer => layer.kind === 'shape').map(layer => layer.shape.type)).toEqual(types);
});
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 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) => {
const section = angle.closest('#ge-shape-section');
section.querySelector('#ge-shape-fill-type').value = 'linear-gradient';
section.querySelector('#ge-shape-gradient-start').value = '#ff0000';
section.querySelector('#ge-shape-gradient-mid').value = '#00ff00';
section.querySelector('#ge-shape-gradient-mid-enabled').checked = true;
section.querySelector('#ge-shape-gradient-mid-position').value = '42';
section.querySelector('#ge-shape-gradient-end').value = '#0000ff';
section.querySelector('#ge-shape-fill-type').dispatchEvent(new Event('change', { bubbles: true }));
angle.value = '35';
angle.dispatchEvent(new Event('input', { bubbles: true }));
});
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible();
const before = await editorState(page);
const shape = before.layers.find(layer => layer.kind === 'shape');
expect(shape.shape).toMatchObject({
fillType: 'linear-gradient',
gradientStart: '#ff0000',
gradientMid: '#00ff00',
gradientMidEnabled: true,
gradientMidPosition: 42,
gradientEnd: '#0000ff',
gradientAngle: 35,
});
expect(page.locator('.ge-layer-inline-thumb')).toHaveCount(before.layers.length);
const thumbnailColorRange = await page.locator('.ge-layer-inline-thumb').evaluateAll((canvases) => canvases.map(canvas => {
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
return {
red: Math.max(...Array.from(pixels).filter((_, index) => index % 4 === 0)),
blue: Math.max(...Array.from(pixels).filter((_, index) => index % 4 === 2)),
};
}));
expect(thumbnailColorRange.some(({ red, blue }) => red > 180 && blue > 180)).toBe(true);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.id === shape.id).shape).toMatchObject(shape.shape);
});
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 dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
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']]) {
const input = document.getElementById(id);
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
await page.locator('#ge-shape-gradient-add-stop').click();
await expect(page.locator('[data-gradient-extra-stop]')).toHaveCount(1);
await page.evaluate(() => {
const color = document.querySelector('[data-gradient-stop-color]');
color.value = '#00ff00';
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = document.querySelector('[data-gradient-stop-position]');
position.value = '50';
position.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.locator('#ge-shape-gradient-add-stop').click();
await expect(page.locator('[data-gradient-extra-stop]')).toHaveCount(2);
await page.evaluate(() => {
const rows = [...document.querySelectorAll('[data-gradient-extra-stop]')];
const setRow = (row, colorValue, positionValue) => {
const color = row.querySelector('[data-gradient-stop-color]');
color.value = colorValue;
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = row.querySelector('[data-gradient-stop-position]');
position.value = positionValue;
position.dispatchEvent(new Event('input', { bubbles: true }));
};
setRow(rows[0], '#00ff00', '50');
setRow(rows[1], '#ffff00', '75');
});
let current = await editorState(page);
const shape = current.layers.find(layer => layer.kind === 'shape');
expect(shape.shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 50, color: '#00ff00' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
await page.locator('[data-gradient-extra-stop]').first().locator('[data-gradient-stop-remove]').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === shape.id).shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.id === shape.id).shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
});
test('shape gradient stop normalization keeps imported endpoints bounded', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Gradient stop bounds E2E');
const result = await page.evaluate(async () => {
const { MAX_GRADIENT_STOPS, normalizeGradientStops } = await import('/static/js/editor/gradient-stops.js');
const stops = normalizeGradientStops(Array.from({ length: 30 }, (_, index) => ({
position: index * 3,
color: `#${String(index).padStart(6, '0')}`,
})));
return { max: MAX_GRADIENT_STOPS, stops };
});
expect(result.stops).toHaveLength(result.max);
expect(result.stops[0].position).toBe(0);
expect(result.stops.at(-1).position).toBe(100);
});
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 expect(page.locator('#ge-gradient-section')).toBeVisible();
await page.evaluate(() => {
for (const [id, value] of [['ge-gradient-start', '#ff0000'], ['ge-gradient-end', '#0000ff']]) {
const input = document.getElementById(id);
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
const midpoint = document.getElementById('ge-gradient-mid');
midpoint.value = '#00ff00';
midpoint.dispatchEvent(new Event('input', { bubbles: true }));
const enabled = document.getElementById('ge-gradient-mid-enabled');
enabled.checked = true;
enabled.dispatchEvent(new Event('change', { bubbles: true }));
});
await page.locator('#ge-gradient-add-stop').click();
await page.evaluate(() => {
const row = document.querySelector('#ge-gradient-extra-stops [data-gradient-extra-stop]');
const color = row.querySelector('[data-gradient-stop-color]');
color.value = '#ffff00';
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = row.querySelector('[data-gradient-stop-position]');
position.value = '25';
position.dispatchEvent(new Event('input', { bubbles: true }));
});
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(box.x + 8, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 8, box.y + box.height / 2, { steps: 8 });
await page.mouse.up();
await expect.poll(async () => (await editorState(page)).layers.at(-1).effects?.length || 0).toBe(1);
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
const samples = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const rendered = state.documentCompositeCanvas;
const ctx = rendered.getContext('2d');
const y = Math.floor(rendered.height / 2);
return [
ctx.getImageData(4, y, 1, 1).data,
ctx.getImageData(Math.floor(rendered.width / 2), y, 1, 1).data,
ctx.getImageData(rendered.width - 5, y, 1, 1).data,
];
});
expect(samples[0][0]).toBeGreaterThan(220);
expect(samples[0][2]).toBeLessThan(40);
expect(samples[1][1]).toBeGreaterThan(180);
expect(samples[1][0]).toBeLessThan(80);
expect(samples[1][2]).toBeLessThan(80);
expect(samples[2][2]).toBeGreaterThan(220);
expect(samples[2][0]).toBeLessThan(40);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
const reopenedGradient = reopened.layers.find(layer => layer.effects?.some(effect => effect.type === 'linear-gradient'));
expect(reopenedGradient).toBeTruthy();
expect(reopenedGradient.effects.find(effect => effect.type === 'linear-gradient').params.stops).toEqual([
{ position: 0, color: '#ff0000', alpha: 1 },
{ position: 25, color: '#ffff00', alpha: 1 },
{ position: 50, color: '#00ff00', alpha: 1 },
{ position: 100, color: '#0000ff', alpha: 1 },
]);
await page.locator('.ge-effect-sub-item .ge-adj-sub-name').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await expect(page.locator('.ge-filter-row input[data-key="stopColor0"]')).toHaveValue('#ffff00');
await expect(page.locator('.ge-filter-row input[data-key="stopPosition0"]')).toHaveValue('25');
await expect(page.locator('.ge-filter-row input[data-key="stopColor1"]')).toHaveValue('#00ff00');
await page.locator('.ge-filter-row input[data-key="stopColor0"]').fill('#ffff00');
await page.locator('.ge-filter-row input[data-key="stopPosition0"]').fill('30');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const edited = (await editorState(page)).layers
.flatMap(layer => layer.effects || [])
.find(effect => effect.type === 'linear-gradient');
expect(edited.params.stops).toEqual([
{ position: 0, color: '#ff0000', alpha: 1 },
{ position: 30, color: '#ffff00', alpha: 1 },
{ position: 50, color: '#00ff00', alpha: 1 },
{ position: 100, color: '#0000ff', alpha: 1 },
]);
});
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 page.locator('#ge-gradient-type').selectOption('radial-gradient');
await page.evaluate(() => {
const start = document.getElementById('ge-gradient-start');
start.value = '#ffffff';
start.dispatchEvent(new Event('input', { bubbles: true }));
const end = document.getElementById('ge-gradient-end');
end.value = '#000000';
end.dispatchEvent(new Event('input', { bubbles: true }));
});
await dragOnCanvas(page, { x: 0.5, y: 0.5 }, { x: 0.85, y: 0.5 });
let current = await editorState(page);
const gradient = current.layers.flatMap(layer => layer.effects || []).find(effect => effect.type === 'radial-gradient');
expect(gradient).toBeTruthy();
expect(gradient.params.stops).toEqual([
{ position: 0, color: '#ffffff', alpha: 1 },
{ position: 100, color: '#000000', alpha: 1 },
]);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.flatMap(layer => layer.effects || []).some(effect => effect.type === 'radial-gradient')).toBe(true);
});
@@ -0,0 +1,320 @@
const { test, expect } = require('@playwright/test');
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();
const frame = 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 { getHandleAt } = await import('/static/js/editor/tools/transform-handles.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 canvasRect = state.mainCanvas.getBoundingClientRect();
const right = geometry.resizeHandles.find(handle => handle.id === 'r');
return {
width: state.transformPendingW,
ids: geometry.resizeHandles.map(handle => getHandleAt(handle.x, handle.y)),
rightClient: {
x: canvasRect.left + right.x * canvasRect.width / state.mainCanvas.width,
y: canvasRect.top + right.y * canvasRect.height / state.mainCanvas.height,
},
};
});
expect(frame.ids).toEqual(['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l']);
await page.mouse.move(frame.rightClient.x, frame.rightClient.y);
await page.mouse.down();
await page.mouse.move(frame.rightClient.x + 36, frame.rightClient.y, { steps: 6 });
await page.mouse.up();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
})).toBeGreaterThan(frame.width);
});
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 page.locator('#ge-transform-rot').fill('90');
const frame = 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 canvasRect = state.mainCanvas.getBoundingClientRect();
return {
width: state.transformPendingW,
center: { ...state.transformCenter },
rightClient: {
x: canvasRect.left + right.x * canvasRect.width / state.mainCanvas.width,
y: canvasRect.top + right.y * canvasRect.height / state.mainCanvas.height,
},
};
});
await page.mouse.move(frame.rightClient.x, frame.rightClient.y);
await page.mouse.down();
await page.mouse.move(frame.rightClient.x, frame.rightClient.y + 36, { steps: 6 });
await page.mouse.up();
const after = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { width: state.transformPendingW, center: { ...state.transformCenter } };
});
expect(after.width).toBeGreaterThan(frame.width);
expect(after.center.x).toBeCloseTo(frame.center.x, 4);
expect(after.center.y).toBeGreaterThan(frame.center.y);
});
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 page.locator('#ge-transform-rot').fill('45');
const frame = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.snapEnabled = false;
const rect = state.mainCanvas.getBoundingClientRect();
const toClient = point => ({
x: rect.left + point.x * rect.width / state.mainCanvas.width,
y: rect.top + point.y * rect.height / state.mainCanvas.height,
});
return {
center: { ...state.transformCenter },
emptyCorner: toClient({ x: 5, y: 5 }),
centerClient: toClient(state.transformCenter),
};
});
await page.mouse.move(frame.emptyCorner.x, frame.emptyCorner.y);
await page.mouse.down();
await page.mouse.move(frame.emptyCorner.x + 24, frame.emptyCorner.y + 18);
await page.mouse.up();
const afterEmptyCorner = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
});
expect(afterEmptyCorner.x).toBeCloseTo(frame.center.x, 5);
expect(afterEmptyCorner.y).toBeCloseTo(frame.center.y, 5);
await page.mouse.move(frame.centerClient.x, frame.centerClient.y);
await page.mouse.down();
await page.mouse.move(frame.centerClient.x + 24, frame.centerClient.y + 18, { steps: 4 });
await page.mouse.up();
const afterDrag = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
});
expect(afterDrag.x).toBeGreaterThan(frame.center.x);
expect(afterDrag.y).toBeGreaterThan(frame.center.y);
await page.keyboard.press('ArrowRight');
await page.keyboard.press('Shift+ArrowDown');
const afterKeys = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
});
expect(afterKeys.x).toBeCloseTo(afterDrag.x + 1, 5);
expect(afterKeys.y).toBeCloseTo(afterDrag.y + 10, 5);
const readout = {
x: Number(await page.locator('#ge-transform-x').inputValue()),
y: Number(await page.locator('#ge-transform-y').inputValue()),
w: Number(await page.locator('#ge-transform-w').inputValue()),
h: Number(await page.locator('#ge-transform-h').inputValue()),
angle: Number(await page.locator('#ge-transform-rot').inputValue()),
};
expect(readout.x).toBeCloseTo(afterKeys.x, 2);
expect(readout.y).toBeCloseTo(afterKeys.y, 2);
expect(readout.w).toBeGreaterThan(0);
expect(readout.h).toBeGreaterThan(0);
expect(readout.angle).toBe(45);
await page.locator('#ge-transform-x').fill('250');
await page.locator('#ge-transform-y').fill('180');
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
})).toEqual({ x: 250, y: 180 });
await page.keyboard.press('Escape');
await expect(page.locator('.ge-transform-popup')).toBeHidden();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformActive;
})).toBe(false);
});
test('transform previews remain source-derived and reject unsafe allocations', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Transform source integrity');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
for (let y = 0; y < layer.canvas.height; y += 8) {
for (let x = 0; x < layer.canvas.width; x += 8) {
layer.ctx.fillStyle = ((x / 8 + y / 8) % 2) ? '#f24f5f' : '#27c2a3';
layer.ctx.fillRect(x, y, 8, 8);
}
}
});
const layerSnapshot = () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const pixels = layer.ctx.getImageData(0, 0, layer.canvas.width, layer.canvas.height).data;
let hash = 2166136261;
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
return { width: layer.canvas.width, height: layer.canvas.height, hash: hash >>> 0 };
});
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
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 page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
const afterDirectPreview = await layerSnapshot();
expect(afterSequentialPreviews).toEqual(afterDirectPreview);
const safeWidth = await page.locator('#ge-transform-w').inputValue();
const beforeRejected = await layerSnapshot();
await page.locator('#ge-transform-w').fill('40000');
await expect(page.locator('#toast')).toContainText('dimension limit');
await expect(page.locator('#ge-transform-w')).toHaveValue(safeWidth);
expect(await layerSnapshot()).toEqual(beforeRejected);
await page.locator('#ge-transform-cancel-btn').click();
});
test('mobile touch input can grab every transform handle', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
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 }, 'Mobile transform handles');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-transform-popup')).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({
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();
return {
x: rect.left + handle.x * rect.width / state.mainCanvas.width,
y: rect.top + handle.y * rect.height / state.mainCanvas.height,
};
}, id);
for (let index = 0; index < ids.length; index += 1) {
const expectedId = ids[index];
const point = await handlePoint(expectedId);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: point.x, y: point.y, id: index + 1, radiusX: 4, radiusY: 4 }],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe(expectedId);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe(null);
}
const right = await handlePoint('r');
const widthBefore = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: right.x, y: right.y, id: 20, radiusX: 4, radiusY: 4 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: right.x + 80, y: right.y, id: 20, radiusX: 4, radiusY: 4 }],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
})).toBeGreaterThan(widthBefore);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const center = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const rect = state.mainCanvas.getBoundingClientRect();
return {
x: rect.left + state.transformCenter.x * rect.width / state.mainCanvas.width,
y: rect.top + state.transformCenter.y * rect.height / state.mainCanvas.height,
zoom: state.zoom,
};
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: center.x, y: center.y, id: 30, radiusX: 4, radiusY: 4 }],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe('move');
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [
{ x: center.x - 20, y: center.y, id: 30, radiusX: 4, radiusY: 4 },
{ x: center.x + 20, y: center.y, id: 31, radiusX: 4, radiusY: 4 },
],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe(null);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [
{ x: center.x - 45, y: center.y, id: 30, radiusX: 4, radiusY: 4 },
{ x: center.x + 45, y: center.y, id: 31, radiusX: 4, radiusY: 4 },
],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.zoom;
})).toBeGreaterThan(center.zoom);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
} finally {
await context.close();
}
});
+60
View File
@@ -0,0 +1,60 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { defineConfig } = require('@playwright/test');
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
const baseURL = `http://127.0.0.1:${port}`;
const repositoryRoot = path.join(__dirname, '..', '..');
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 dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-photo-editor-e2e-'));
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH
|| path.join(dataDirectory, 'app.db');
process.env.PHOTO_EDITOR_E2E_DB_PATH = databasePath;
process.env.PHOTO_EDITOR_E2E_DATA_DIR = dataDirectory;
const browserName = process.env.PHOTO_EDITOR_E2E_BROWSER || 'chromium';
if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
}
module.exports = defineConfig({
testDir: path.join(__dirname, 'photo-editor'),
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
timeout: 90_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
globalSetup: require.resolve('./setup.js'),
globalTeardown: require.resolve('./teardown.js'),
use: {
baseURL,
browserName,
headless: true,
viewport: { width: 1440, height: 960 },
extraHTTPHeaders: { 'Accept-Encoding': 'identity' },
serviceWorkers: 'block',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
webServer: {
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
cwd: repositoryRoot,
url: baseURL,
timeout: 120_000,
reuseExistingServer: false,
env: {
...process.env,
AUTH_ENABLED: 'false',
DATABASE_URL: `sqlite:///${databasePath}`,
ODYSSEUS_DATA_DIR: dataDirectory,
ODYSSEUS_STARTUP_WARMUPS: '0',
RESPONSE_COMPRESSION_ENABLED: 'false',
},
},
});
+10
View File
@@ -0,0 +1,10 @@
const fs = require('node:fs');
module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH;
if (!dataDirectory || !databasePath) {
throw new Error('Photo editor E2E data paths were not configured');
}
fs.mkdirSync(dataDirectory, { recursive: true });
};
+7
View File
@@ -0,0 +1,7 @@
const fs = require('node:fs');
module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
if (!dataDirectory) return;
fs.rmSync(dataDirectory, { recursive: true, force: true });
};