mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-30 03:52:21 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user