mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-04 05:52:19 +02:00
213 lines
10 KiB
JavaScript
213 lines
10 KiB
JavaScript
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);
|
|
});
|