mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-02 13:02:20 +02:00
376 lines
16 KiB
JavaScript
376 lines
16 KiB
JavaScript
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,
|
|
};
|