diff --git a/e2e/specs/mattermost/media_preview.test.ts b/e2e/specs/mattermost/media_preview.test.ts index 6ed370f753b..1bb921349e7 100644 --- a/e2e/specs/mattermost/media_preview.test.ts +++ b/e2e/specs/mattermost/media_preview.test.ts @@ -9,8 +9,10 @@ import {prepareMattermostServerView} from '../../helpers/prepareServerView'; import {getFilePublicLink, isPublicLinkEnabled} from '../../helpers/server_api/publicLinks'; import type {ServerView} from '../../helpers/serverView'; -// 64x64 PNG — above Mattermost's 48px inline-image minimum so thumbnails render visibly. -const PREVIEW_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAf0lEQVR4nNXOQREAIAzAsFJJ8y8FMYjgsWsU5NwZyiRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4twO/HqSogHAzFmDswAAAABJRU5ErkJggg=='; +// Valid 128x128 PNG. The previous fixture was rejected by the server decoder +// ("png: invalid format: too much pixel data"), so no preview was generated and +// SizeAwareImage (MM-69174 / 11.10+) ignored clicks until load forever. +const PREVIEW_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAIAAABMXPacAAACx0lEQVR4nO3dsVEbQRhH8ZWHOuyAHogcEVAClTgmcGpX4hIIlNgRPRC4EjnYmRuNYARrab/3P/R+kQMh43333Z1kFm12u10b8eXnn6HH//321ec/4tPQo3V2BoAZAGYAmAFgBoAZAGYAmAFgBoAZAGYAmAFgBoAZAHaV9v74pT2/EwAzAMwAMAPADAAzAMwAMAPADAAzAMwAMAPADAAzAMwAsM3q9gdsf30//oDrp8dTnv/A7P8/uBp6NOXNRd/3fHO3/Pl4jATRAYbW/VVLjNgSoQFOX/oDvURghrgAZ1/6fc83d9vWbu8f5v0Vo7LugvZP3/NMbTwqZQJqln7RGySMQsQEFK/+ImEU+ADU6nd4AzgAu/od24AMkLD6HdgAC5Cz+h3VgAmQtvod0gAIkLn6XX0D/i7owlUHSD78u+Ih2Hz+8XvoC055fxy/6X6/5W079wd8cHUBVnT4t8JTpRMAKwqwrsO/qxkCJwBmAFhFgDWef7qCs5ATADMAzACw6QHWewHoZn//TgDMADADwAwAm74/YO0X4Tb4I71+fsDKGABmAJgBYAaATQ8QuCtoyOzv3wmAGQBmAFhFgPVeBgo2kTkBMAPAigKs8SxUs4nVCYDVBVjXEJTt4S79/IDt0Feiln/mh9ofkPCrAd6jclirrwH5DYpPlV6EYUCA5CGov1NgJiCzAXKfhp2C0hpQd8nkNSCnAfgaBb4IJzRgXyHyd0FsA/z1OR+gcQ3w1W85v7SvNyj7OcaEpe8iJmBRMwo5q9/SArTWrp8e5y3Q7f1D1Oq3nFPQgb5MZ9wlmnC79arQAN1ytP53idh1X/j5AW/w8wMOHRzUowuUJu4ifGkMADMAzAAwA8AMADMAzAAwA8AMADMAzAAwA8AMACvdH+Dzv+QEwAwAMwDMADADwAwAMwDMADADwAwAMwDMADADwAwAMwDsH5fw4xGXqkx/AAAAAElFTkSuQmCC'; const PREVIEW_MODAL_SELECTOR = [ '.file-preview-modal', @@ -19,17 +21,88 @@ const PREVIEW_MODAL_SELECTOR = [ '#viewImageModalLabel', ].join(', '); -const POSTED_IMAGE_SELECTOR = [ - '.post-image .small-image__container', - '.post-image .image-loaded-container', - '.post-image__image', - '.post-image img', - '.file-viewer-touch', - '.file-attachment', - '.post--attachment img', - 'img[src*="/api/v4/files/"]', +const LOADED_IMAGE_SELECTOR = [ + '.post-image img:not(.image-loading__placeholder)', + '.post--attachment img:not(.image-loading__placeholder)', + 'img[src*="/api/v4/files/"]:not(.image-loading__placeholder)', ].join(', '); +const PREVIEW_FILE_NAME = 'e2e-preview.png'; + +// Shared helpers injected into renderer scripts (same pattern as DOM_UTILS in serverView.ts). +const PREVIEW_IMAGE_UTILS = ` +const LOADED_IMAGE_SELECTOR = ${JSON.stringify(LOADED_IMAGE_SELECTOR)}; +const PREVIEW_FILE_NAME = ${JSON.stringify(PREVIEW_FILE_NAME)}; +const isPreviewControlVisible = (el) => el instanceof HTMLElement && window.getComputedStyle(el).display !== 'none'; +const isLoadedPreviewImage = (el) => el instanceof HTMLImageElement && + !el.classList.contains('image-loading__placeholder') && + el.complete && + el.naturalWidth > 0 && + isPreviewControlVisible(el); +const postHasPreviewFixture = (post) => { + if (post.querySelector('[aria-label*="' + PREVIEW_FILE_NAME + '" i]')) { + return true; + } + // Filename can also appear in attachment headers before the image aria-label mounts. + const attachment = post.querySelector('.post-image, .post--attachment, .file-attachment, .file-preview__button'); + return Boolean(attachment && (attachment.textContent || '').toLowerCase().includes(PREVIEW_FILE_NAME)); +}; +const findPreviewFixturePost = () => { + const posts = Array.from(document.querySelectorAll('.post')); + for (let index = posts.length - 1; index >= 0; index--) { + const post = posts[index]; + if (postHasPreviewFixture(post)) { + return post; + } + } + return null; +}; +const findVisibleLoadedPreviewButton = (root) => { + for (const button of root.querySelectorAll('.file-preview__button')) { + if (!isPreviewControlVisible(button)) { + continue; + } + const loadedImg = button.querySelector('img:not(.image-loading__placeholder)'); + if (loadedImg instanceof HTMLImageElement && loadedImg.complete && loadedImg.naturalWidth > 0) { + return button; + } + } + return null; +}; +`; + +/** + * Mattermost 11.10+ (MM-69174) SizeAwareImage ignores clicks until the real image has + * loaded, and keeps a visible placeholder button while the clickable control is + * display:none. Wait for a visible, loaded non-placeholder control before opening. + */ +async function waitForLoadedImagePreviewControl(serverWin: ServerView): Promise { + await expect.poll(async () => serverWin.runInRenderer(` + ${PREVIEW_IMAGE_UTILS} + const post = findPreviewFixturePost(); + if (!post) { + return false; + } + + const previewButton = findVisibleLoadedPreviewButton(post); + if (previewButton) { + previewButton.scrollIntoView({block: 'center'}); + return true; + } + + // Legacy servers without .file-preview__button + const legacyImg = post.querySelector(LOADED_IMAGE_SELECTOR); + if (isLoadedPreviewImage(legacyImg)) { + legacyImg.scrollIntoView({block: 'center'}); + return true; + } + return false; + `, true), { + timeout: 60_000, + message: 'Uploaded e2e-preview.png must finish loading into a visible file-preview control before it can be opened', + }).toBe(true); +} + async function submitComposerPost(serverWin: ServerView): Promise { const sent = await serverWin.runInRenderer(` const sendButton = document.querySelector( @@ -49,24 +122,20 @@ async function submitComposerPost(serverWin: ServerView): Promise { async function waitForPostedAttachment(serverWin: ServerView): Promise { await expect.poll(async () => serverWin.runInRenderer(` - const attachmentSelector = ${JSON.stringify(POSTED_IMAGE_SELECTOR)}; + ${PREVIEW_IMAGE_UTILS} const composer = document.querySelector('#post-create, .AdvancedTextEditor, .post-create, [data-testid="post-create"]'); const draftAttachment = composer?.querySelector('.file-preview, .file-preview__container, .attachment-preview'); if (draftAttachment) { return false; } - const posts = Array.from(document.querySelectorAll('.post')); - for (let index = posts.length - 1; index >= 0; index--) { - const post = posts[index]; - if (post.querySelector(attachmentSelector) || - post.querySelector('[aria-label*="e2e-preview.png" i], [aria-label*="file thumbnail" i]')) { - post.scrollIntoView({block: 'center'}); - return true; - } + const post = findPreviewFixturePost(); + if (!post) { + return false; } - return false; - `, true), {timeout: 60_000, message: 'Uploaded image must appear in the channel post list'}).toBe(true); + post.scrollIntoView({block: 'center'}); + return true; + `, true), {timeout: 60_000, message: 'Uploaded e2e-preview.png must appear in the channel post list'}).toBe(true); } async function uploadAndPostPng(serverWin: ServerView): Promise { @@ -77,7 +146,7 @@ async function uploadAndPostPng(serverWin: ServerView): Promise { for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } - const file = new File([bytes], 'e2e-preview.png', {type: 'image/png'}); + const file = new File([bytes], ${JSON.stringify(PREVIEW_FILE_NAME)}, {type: 'image/png'}); const input = document.querySelector('#fileUploadInput, input[type="file"]'); if (!(input instanceof HTMLInputElement)) { @@ -109,6 +178,7 @@ async function uploadAndPostPng(serverWin: ServerView): Promise { await recoverInteractiveChannel(serverWin, {channelItem: '#sidebarItem_town-square'}); await waitForPostedAttachment(serverWin); + await waitForLoadedImagePreviewControl(serverWin); } async function isImagePreviewOpen(serverWin: ServerView): Promise { @@ -126,45 +196,46 @@ async function isImagePreviewOpen(serverWin: ServerView): Promise { async function openImagePreview(serverWin: ServerView): Promise { return serverWin.runInRenderer(` - const attachmentSelector = ${JSON.stringify(POSTED_IMAGE_SELECTOR)}; - const posts = Array.from(document.querySelectorAll('.post')); - let root = null; - for (let index = posts.length - 1; index >= 0; index--) { - const post = posts[index]; - if (post.querySelector(attachmentSelector) || - post.querySelector('[aria-label*="e2e-preview.png" i], [aria-label*="file thumbnail" i]')) { - root = post; - break; - } - } + ${PREVIEW_IMAGE_UTILS} + const root = findPreviewFixturePost(); if (!root) { return false; } + // Prefer the visible SizeAwareImage control (11.10+/MM-69174); clicks on the + // placeholder button are intentionally ignored until the real image loads. + const previewButton = findVisibleLoadedPreviewButton(root); + if (previewButton) { + previewButton.scrollIntoView({block: 'center', inline: 'center'}); + previewButton.click(); + return true; + } + const clickTargets = [ - root.querySelector('[aria-label*="e2e-preview.png" i]'), - root.querySelector('[aria-label*="file thumbnail" i]'), - root.querySelector('.post-image .small-image__container'), + ...Array.from(root.querySelectorAll('[aria-label*="' + PREVIEW_FILE_NAME + '" i]')), + ...Array.from(root.querySelectorAll(LOADED_IMAGE_SELECTOR)), root.querySelector('.post-image .image-loaded-container'), + root.querySelector('.post-image .small-image__container'), root.querySelector('.post-image__image'), - root.querySelector('.post-image img'), root.querySelector('.file-viewer-touch'), - root.querySelector('.file-attachment'), - root.querySelector('.post--attachment img'), - root.querySelector('img[src*="/api/v4/files/"]'), - root.querySelector('.post-image'), - root.querySelector('.post--attachment'), - ].filter(Boolean); + ].filter((target) => { + if (!target) { + return false; + } + if (target instanceof HTMLImageElement) { + return isLoadedPreviewImage(target); + } + return isPreviewControlVisible(target) && + Boolean(target.querySelector?.('img:not(.image-loading__placeholder)')); + }); const target = clickTargets[0]; - if (!target) { + if (!(target instanceof HTMLElement)) { return false; } target.scrollIntoView({block: 'center', inline: 'center'}); - if (target instanceof HTMLElement) { - target.click(); - } + target.click(); return true; `, true); } @@ -185,8 +256,8 @@ async function getPreviewFileId(serverWin: ServerView): Promise { const sources = [ document.querySelector('[data-testid="imagePreview"]')?.getAttribute('src'), document.querySelector('.file-preview-modal img')?.getAttribute('src'), - document.querySelector('.post-image img[src*="/files/"]')?.getAttribute('src'), - document.querySelector('img[src*="/api/v4/files/"]')?.getAttribute('src'), + document.querySelector('.post-image img[src*="/files/"]:not(.image-loading__placeholder)')?.getAttribute('src'), + document.querySelector('img[src*="/api/v4/files/"]:not(.image-loading__placeholder)')?.getAttribute('src'), ].filter(Boolean); for (const source of sources) {