From 37a6356796198f72c19cc8974568cbfa7b934845 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 19 Jun 2026 15:32:28 +0530 Subject: [PATCH 1/2] E2E: Downloads helpers and specs (5/10). --- e2e/helpers/downloads.ts | 250 ++++++++++++++++++ e2e/helpers/downloadsDropdown.ts | 39 +++ e2e/specs/downloads/download_cancel.test.ts | 57 ++++ .../downloads/download_clear_all.test.ts | 71 +++++ .../downloads/download_completion.test.ts | 16 +- e2e/specs/downloads/download_open.test.ts | 54 ++++ .../downloads_dropdown_items.test.ts | 14 +- e2e/specs/downloads/downloads_manager.test.ts | 12 +- e2e/specs/downloads/downloads_menubar.test.ts | 14 +- e2e/specs/downloads/video_download.test.ts | 173 ++++++++++++ 10 files changed, 670 insertions(+), 30 deletions(-) create mode 100644 e2e/helpers/downloads.ts create mode 100644 e2e/helpers/downloadsDropdown.ts create mode 100644 e2e/specs/downloads/download_cancel.test.ts create mode 100644 e2e/specs/downloads/download_clear_all.test.ts create mode 100644 e2e/specs/downloads/download_open.test.ts create mode 100644 e2e/specs/downloads/video_download.test.ts diff --git a/e2e/helpers/downloads.ts b/e2e/helpers/downloads.ts new file mode 100644 index 00000000000..f06673ef791 --- /dev/null +++ b/e2e/helpers/downloads.ts @@ -0,0 +1,250 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as fs from 'fs'; +import * as http from 'http'; +import * as path from 'path'; + +import {expect} from '@playwright/test'; +import type {ElectronApplication} from 'playwright'; + +import {waitForAppReady} from './appReadiness'; +import {waitForLockFileRelease} from './cleanup'; +import {electronBinaryPath, appDir, emptyConfig} from './config'; + +export type DownloadServer = { + server: http.Server; + url: string; + close: () => Promise; +}; + +export async function startDownloadServer( + filename: string, + options?: {contents?: string; slow?: boolean}, +): Promise { + const contents = options?.contents ?? 'download test contents'; + const slow = options?.slow ?? false; + + const server = http.createServer((request, response) => { + if (request.url === '/download.txt') { + if (slow) { + // Keep the response open long enough for CI to observe a progressing + // download even when popup setup and the will-download round-trip are slow. + const chunkCount = 120; + const chunkIntervalMs = 500; + const chunkPayload = `chunk-${'x'.repeat(4096)}`; + + response.writeHead(200, { + 'Content-Type': 'text/plain', + 'Content-Disposition': `attachment; filename="${filename}"`, + }); + + let sentChunks = 0; + let closed = false; + let timer: ReturnType | undefined; + const clearTimer = () => { + if (timer) { + clearInterval(timer); + timer = undefined; + } + }; + response.on('close', () => { + closed = true; + clearTimer(); + }); + const writeChunk = () => { + if (closed || response.destroyed || response.writableEnded) { + clearTimer(); + return; + } + sentChunks += 1; + response.write(`${chunkPayload}-${sentChunks}\n`); + if (sentChunks >= chunkCount) { + clearTimer(); + response.end(); + } + }; + + writeChunk(); + if (closed) { + return; + } + timer = setInterval(writeChunk, chunkIntervalMs); + return; + } + + response.writeHead(200, { + 'Content-Type': 'text/plain', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': Buffer.byteLength(contents), + }); + response.end(contents); + return; + } + + response.writeHead(200, {'Content-Type': 'text/html'}); + response.end(` + + + + Download file + + + `); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to start local download server'); + } + + return { + server, + url: `http://127.0.0.1:${address.port}`, + close: () => new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} + +export async function launchAppWithDownloadsDir(userDataDir: string, downloadLocation: string) { + const config = { + ...emptyConfig, + downloadLocation, + }; + + fs.mkdirSync(userDataDir, {recursive: true}); + fs.mkdirSync(downloadLocation, {recursive: true}); + fs.writeFileSync(path.join(userDataDir, 'config.json'), JSON.stringify(config)); + + const {_electron: electron} = await import('playwright'); + const app = await electron.launch({ + executablePath: electronBinaryPath, + args: [appDir, `--user-data-dir=${userDataDir}`, '--no-sandbox', '--disable-gpu'], + env: {...process.env, NODE_ENV: 'test'}, + timeout: 60_000, + }); + await waitForAppReady(app); + return app; +} + +export async function openDownloadsDropdown(app: ElectronApplication) { + const mainWindow = app.windows().find((window) => window.url().includes('index')) ?? + await app.waitForEvent('window', { + predicate: (window) => window.url().includes('index'), + timeout: 15_000, + }); + await mainWindow.waitForLoadState(); + await mainWindow.bringToFront(); + + const button = await mainWindow.waitForSelector('.DownloadsDropdownButton'); + await button.click(); + + let downloadsWindow = app.windows().find((window) => window.url().includes('downloadsDropdown.html')); + if (!downloadsWindow) { + downloadsWindow = await app.waitForEvent('window', { + predicate: (window) => window.url().includes('downloadsDropdown.html'), + timeout: 10_000, + }); + } + await downloadsWindow.waitForLoadState(); + await downloadsWindow.bringToFront(); + await downloadsWindow.waitForSelector('.DownloadsDropdown', {state: 'visible', timeout: 15_000}); + return {mainWindow, downloadsWindow}; +} + +export async function triggerDownloadFromPopup(app: ElectronApplication, popupUrl: string) { + const popupPromise = app.waitForEvent('window', { + predicate: (window) => window.url().startsWith(popupUrl), + timeout: 15_000, + }); + + await app.evaluate(async ({BrowserWindow}, url) => { + const popup = new BrowserWindow({ + show: true, + width: 900, + height: 700, + }); + await popup.loadURL(url); + (global as any).__e2eDownloadPopup = popup; + }, popupUrl); + + const popupWindow = await popupPromise; + await popupWindow.waitForLoadState(); + await popupWindow.click('#download-link'); + return popupWindow; +} + +export function readDownloadsState(userDataDir: string): Record { + try { + return JSON.parse(fs.readFileSync(path.join(userDataDir, 'downloads.json'), 'utf-8')); + } catch { + return {}; + } +} + +export async function waitForDownloadState( + userDataDir: string, + filename: string, + state: string, + timeout = 90_000, +) { + await expect.poll( + () => readDownloadsState(userDataDir)[filename]?.state, + {timeout, intervals: [25, 50, 100, 200, 500]}, + ).toBe(state); +} + +export async function waitForDownloadFile( + userDataDir: string, + downloadLocation: string, + filename: string, + timeout = 30_000, +): Promise { + let resolvedPath = path.join(downloadLocation, filename); + await expect.poll(() => { + const entry = readDownloadsState(userDataDir)[filename]; + if (entry?.location && fs.existsSync(entry.location)) { + resolvedPath = entry.location; + return true; + } + return fs.existsSync(path.join(downloadLocation, filename)); + }, {timeout, message: `Downloaded file "${filename}" should exist on disk`}).toBe(true); + return resolvedPath; +} + +export async function closeDownloadTestApp(app: ElectronApplication, userDataDir: string, downloadLocation: string) { + await app.evaluate(() => { + const popup = (global as any).__e2eDownloadPopup; + if (popup && !popup.isDestroyed?.()) { + popup.close(); + } + delete (global as any).__e2eDownloadPopup; + }).catch(() => {}); + + await app.close().catch(() => {}); + await waitForLockFileRelease(userDataDir).catch(() => {}); + + if (!fs.existsSync(downloadLocation)) { + return; + } + + const timeout = process.platform === 'win32' ? 10_000 : 2_000; + const deadline = Date.now() + timeout; + let lastError: unknown; + while (Date.now() < deadline) { + try { + fs.rmSync(downloadLocation, {recursive: true, force: true, maxRetries: 3, retryDelay: 200}); + return; + } catch (error) { + lastError = error; + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EBUSY' && code !== 'EPERM' && code !== 'ENOTEMPTY') { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw lastError ?? new Error(`Failed to remove download directory: ${downloadLocation}`); +} diff --git a/e2e/helpers/downloadsDropdown.ts b/e2e/helpers/downloadsDropdown.ts new file mode 100644 index 00000000000..e020b46d61c --- /dev/null +++ b/e2e/helpers/downloadsDropdown.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +/** + * Close the downloads dropdown BrowserWindow if it is open. + * Parallel download specs can leave this window focused and block other UI flows. + */ +export async function closeDownloadsDropdownIfOpen(app: ElectronApplication): Promise { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + try { + await app.evaluate(({BrowserWindow}) => { + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) { + continue; + } + try { + if (win.webContents.getURL().includes('downloadsDropdown.html')) { + win.close(); + } + } catch { + // Ignore windows that disappear while iterating. + } + } + }); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('Execution context was destroyed')) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + throw new Error('Timed out closing downloads dropdown after navigation'); +} diff --git a/e2e/specs/downloads/download_cancel.test.ts b/e2e/specs/downloads/download_cancel.test.ts new file mode 100644 index 00000000000..6d668a3709c --- /dev/null +++ b/e2e/specs/downloads/download_cancel.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as path from 'path'; + +import {test, expect} from '../../fixtures/index'; +import { + closeDownloadTestApp, + launchAppWithDownloadsDir, + openDownloadsDropdown, + readDownloadsState, + startDownloadServer, + triggerDownloadFromPopup, + waitForDownloadState, +} from '../../helpers/downloads'; + +test( + 'DL-06 in-progress download can be cancelled from the downloads dropdown menu', + {tag: ['@P1', '@all']}, + async ({}, testInfo) => { + const filename = 'slow-cancel.txt'; + const {url, close} = await startDownloadServer(filename, {slow: true}); + + const userDataDir = path.join(testInfo.outputDir, 'userdata'); + const downloadLocation = path.join(testInfo.outputDir, 'Downloads'); + const app = await launchAppWithDownloadsDir(userDataDir, downloadLocation); + + try { + await Promise.all([ + triggerDownloadFromPopup(app, url), + waitForDownloadState(userDataDir, filename, 'progressing'), + ]); + + const {downloadsWindow} = await openDownloadsDropdown(app); + await downloadsWindow.hover('.DownloadsDropdown__File'); + await downloadsWindow.click('.DownloadsDropdown__File__Body__ThreeDotButton'); + + let menuWindow = app.windows().find((window) => window.url().includes('downloadsDropdownMenu.html')); + if (!menuWindow) { + menuWindow = await app.waitForEvent('window', { + predicate: (window) => window.url().includes('downloadsDropdownMenu.html'), + timeout: 10_000, + }); + } + await menuWindow.waitForLoadState(); + await menuWindow.click('text=Cancel Download'); + + await expect.poll( + () => readDownloadsState(userDataDir)[filename]?.state, + {timeout: 15_000, message: 'Cancelled download should be marked cancelled in downloads.json'}, + ).toMatch(/cancelled|interrupted/); + } finally { + await closeDownloadTestApp(app, userDataDir, downloadLocation); + await close(); + } + }, +); diff --git a/e2e/specs/downloads/download_clear_all.test.ts b/e2e/specs/downloads/download_clear_all.test.ts new file mode 100644 index 00000000000..fc1d6591304 --- /dev/null +++ b/e2e/specs/downloads/download_clear_all.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as fs from 'fs'; +import * as path from 'path'; + +import {test, expect} from '../../fixtures/index'; +import {closeDownloadTestApp, launchAppWithDownloadsDir, openDownloadsDropdown} from '../../helpers/downloads'; + +const completedFile = { + addedAt: Date.UTC(2022, 7, 8, 10), + filename: 'file1.txt', + mimeType: 'text/plain', + progress: 100, + receivedBytes: 1024, + state: 'completed', + totalBytes: 1024, + type: 'file', +}; + +const secondFile = { + ...completedFile, + filename: 'file2.txt', + addedAt: Date.UTC(2022, 7, 8, 11), +}; + +test( + 'DL-07 clear all removes every completed download from the dropdown', + {tag: ['@P1', '@all']}, + async ({}, testInfo) => { + const userDataDir = path.join(testInfo.outputDir, 'userdata'); + const downloadLocation = path.join(testInfo.outputDir, 'Downloads'); + fs.mkdirSync(downloadLocation, {recursive: true}); + fs.writeFileSync(path.join(downloadLocation, completedFile.filename), 'file1'); + fs.writeFileSync(path.join(downloadLocation, secondFile.filename), 'file2'); + + const downloads = { + [completedFile.filename]: { + ...completedFile, + location: path.join(downloadLocation, completedFile.filename), + }, + [secondFile.filename]: { + ...secondFile, + location: path.join(downloadLocation, secondFile.filename), + }, + }; + fs.mkdirSync(userDataDir, {recursive: true}); + fs.writeFileSync(path.join(userDataDir, 'downloads.json'), JSON.stringify(downloads)); + + const app = await launchAppWithDownloadsDir(userDataDir, downloadLocation); + + try { + const {downloadsWindow} = await openDownloadsDropdown(app); + await downloadsWindow.waitForSelector('.DownloadsDropdown__File', {timeout: 10_000}); + expect(await downloadsWindow.locator('.DownloadsDropdown__File').count()).toBe(2); + + await downloadsWindow.click('.DownloadsDropdown__clearAllButton'); + + await expect.poll( + () => Object.keys(JSON.parse(fs.readFileSync(path.join(userDataDir, 'downloads.json'), 'utf-8'))).length, + {timeout: 10_000}, + ).toBe(0); + await expect.poll( + () => downloadsWindow.locator('.DownloadsDropdown__File').count(), + {timeout: 10_000}, + ).toBe(0); + } finally { + await closeDownloadTestApp(app, userDataDir, downloadLocation); + } + }, +); diff --git a/e2e/specs/downloads/download_completion.test.ts b/e2e/specs/downloads/download_completion.test.ts index 7161b1b831e..7207bcc3245 100644 --- a/e2e/specs/downloads/download_completion.test.ts +++ b/e2e/specs/downloads/download_completion.test.ts @@ -8,7 +8,7 @@ import * as path from 'path'; import {test, expect} from '../../fixtures/index'; import {waitForAppReady} from '../../helpers/appReadiness'; import {electronBinaryPath, appDir, emptyConfig} from '../../helpers/config'; -import {waitForLockFileRelease} from '../../helpers/cleanup'; +import {closeElectronAppFast} from '../../helpers/electronApp'; function readJsonFile(filePath: string): T | undefined { try { @@ -136,12 +136,14 @@ test( ). toBe('completed'); } finally { - await app.close().catch(() => {}); - await waitForLockFileRelease(userDataDir).catch(() => {}); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - fs.rmSync(downloadsDir, {recursive: true, force: true}); + try { + await closeElectronAppFast(app, userDataDir); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + fs.rmSync(downloadsDir, {recursive: true, force: true}); + } } }, ); diff --git a/e2e/specs/downloads/download_open.test.ts b/e2e/specs/downloads/download_open.test.ts new file mode 100644 index 00000000000..1c1a3d5797c --- /dev/null +++ b/e2e/specs/downloads/download_open.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as path from 'path'; + +import {test, expect} from '../../fixtures/index'; +import { + closeDownloadTestApp, + launchAppWithDownloadsDir, + openDownloadsDropdown, + startDownloadServer, + triggerDownloadFromPopup, + waitForDownloadFile, +} from '../../helpers/downloads'; + +test( + 'DL-05 completed download can be opened from the downloads dropdown', + {tag: ['@P1', '@all']}, + async ({}, testInfo) => { + const filename = 'open-me.txt'; + const fileContents = 'open download test'; + const {url, close} = await startDownloadServer(filename, {contents: fileContents}); + + const userDataDir = path.join(testInfo.outputDir, 'userdata'); + const downloadLocation = path.join(testInfo.outputDir, 'Downloads'); + const app = await launchAppWithDownloadsDir(userDataDir, downloadLocation); + + try { + await triggerDownloadFromPopup(app, url); + await waitForDownloadFile(userDataDir, downloadLocation, filename); + + await app.evaluate(({shell}) => { + (global as any).__e2eOpenedPaths = [] as string[]; + shell.openPath = async (targetPath: string) => { + (global as any).__e2eOpenedPaths.push(targetPath); + return ''; + }; + }); + + const {downloadsWindow} = await openDownloadsDropdown(app); + await downloadsWindow.click('.DownloadsDropdown__File'); + + await expect.poll(async () => { + return app.evaluate(() => ((global as any).__e2eOpenedPaths as string[] | undefined)?.length ?? 0); + }, {timeout: 10_000}).toBeGreaterThan(0); + + const openedPath = await app.evaluate(() => ((global as any).__e2eOpenedPaths as string[])[0]); + expect(openedPath).toContain(filename); + } finally { + await closeDownloadTestApp(app, userDataDir, downloadLocation); + await close(); + } + }, +); diff --git a/e2e/specs/downloads/downloads_dropdown_items.test.ts b/e2e/specs/downloads/downloads_dropdown_items.test.ts index 18092a20c4f..94bde8669d2 100644 --- a/e2e/specs/downloads/downloads_dropdown_items.test.ts +++ b/e2e/specs/downloads/downloads_dropdown_items.test.ts @@ -7,7 +7,7 @@ import * as path from 'path'; import {test, expect} from '../../fixtures/index'; import {waitForAppReady} from '../../helpers/appReadiness'; import {electronBinaryPath, appDir, demoConfig} from '../../helpers/config'; -import {waitForLockFileRelease} from '../../helpers/cleanup'; +import {closeElectronAppFast} from '../../helpers/electronApp'; const file1 = { addedAt: Date.UTC(2022, 7, 8, 10), // Aug 08, 2022 10:00AM UTC @@ -113,8 +113,7 @@ test.describe('downloads/downloads_dropdown_items', () => { const thumbnailBackgroundImage = await fileThumbnailLocator.evaluate((node) => window.getComputedStyle(node).getPropertyValue('background-image')); expect(thumbnailBackgroundImage).toContain('text.svg'); } finally { - await app.close(); - await waitForLockFileRelease(userDataDir); + await closeElectronAppFast(app, userDataDir); fs.rmSync(downloadsLocation, {recursive: true, force: true}); } }); @@ -149,8 +148,7 @@ test.describe('downloads/downloads_dropdown_items', () => { const thumbnailBackgroundImage = await fileThumbnailLocator.evaluate((node) => window.getComputedStyle(node).getPropertyValue('background-image')); expect(thumbnailBackgroundImage).toContain('text.svg'); } finally { - await app.close(); - await waitForLockFileRelease(userDataDir); + await closeElectronAppFast(app, userDataDir); fs.rmSync(downloadsLocation, {recursive: true, force: true}); } }); @@ -189,8 +187,7 @@ test.describe('downloads/downloads_dropdown_items', () => { const thumbnailBackgroundImage = await fileThumbnailLocator.evaluate((node) => window.getComputedStyle(node).getPropertyValue('background-image')); expect(thumbnailBackgroundImage).toContain('text.svg'); } finally { - await app.close(); - await waitForLockFileRelease(userDataDir); + await closeElectronAppFast(app, userDataDir); fs.rmSync(downloadsLocation, {recursive: true, force: true}); } }); @@ -223,8 +220,7 @@ test.describe('downloads/downloads_dropdown_items', () => { const file2InnerText = await secondItemLocator.innerText(); expect(file2InnerText).toBe(file1.filename); } finally { - await app.close(); - await waitForLockFileRelease(userDataDir); + await closeElectronAppFast(app, userDataDir); fs.rmSync(downloadsLocation, {recursive: true, force: true}); } }); diff --git a/e2e/specs/downloads/downloads_manager.test.ts b/e2e/specs/downloads/downloads_manager.test.ts index a2841a78018..8dc09487ad6 100644 --- a/e2e/specs/downloads/downloads_manager.test.ts +++ b/e2e/specs/downloads/downloads_manager.test.ts @@ -8,7 +8,7 @@ import * as path from 'path'; import {test, expect} from '../../fixtures/index'; import {waitForAppReady} from '../../helpers/appReadiness'; import {electronBinaryPath, appDir, emptyConfig} from '../../helpers/config'; -import {waitForLockFileRelease} from '../../helpers/cleanup'; +import {closeElectronAppFast} from '../../helpers/electronApp'; async function startSlowDownloadServer(filename: string, chunk = 'slow-download-chunk-') { const server = http.createServer((request, response) => { @@ -122,10 +122,12 @@ test.describe('downloads/downloads_manager', () => { }); }, {timeout: 15_000}).toBeGreaterThan(0); } finally { - await app.close().catch(() => {}); - await waitForLockFileRelease(userDataDir).catch(() => {}); - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - fs.rmSync(downloadsDir, {recursive: true, force: true}); + try { + await closeElectronAppFast(app, userDataDir); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + fs.rmSync(downloadsDir, {recursive: true, force: true}); + } } }); }); diff --git a/e2e/specs/downloads/downloads_menubar.test.ts b/e2e/specs/downloads/downloads_menubar.test.ts index 700a0fc998e..577447e7710 100644 --- a/e2e/specs/downloads/downloads_menubar.test.ts +++ b/e2e/specs/downloads/downloads_menubar.test.ts @@ -7,7 +7,7 @@ import * as path from 'path'; import {test, expect} from '../../fixtures/index'; import {waitForAppReady} from '../../helpers/appReadiness'; import {electronBinaryPath, appDir, demoConfig} from '../../helpers/config'; -import {waitForLockFileRelease} from '../../helpers/cleanup'; +import {closeElectronAppFast} from '../../helpers/electronApp'; const file1 = { addedAt: Date.UTC(2022, 8, 8, 10), // Sep 08, 2022 10:00AM UTC @@ -96,8 +96,7 @@ test.describe('downloads/downloads_menubar', () => { expect(saveMenuItem).toHaveProperty('enabled', false); } finally { - await app.close(); - await waitForLockFileRelease(userDataDir); + await closeElectronAppFast(app, userDataDir); } }); }); @@ -128,8 +127,7 @@ test.describe('downloads/downloads_menubar', () => { expect(saveMenuItem).toHaveProperty('enabled', true); } finally { - await app.close(); - await waitForLockFileRelease(userDataDir); + await closeElectronAppFast(app, userDataDir); fs.rmSync(downloadsLocation, {recursive: true, force: true}); } }); @@ -156,8 +154,7 @@ test.describe('downloads/downloads_menubar', () => { const isVisible = await downloadsWindow.isVisible('.DownloadsDropdown'); expect(isVisible).toBe(true); } finally { - await app.close(); - await waitForLockFileRelease(userDataDir); + await closeElectronAppFast(app, userDataDir); fs.rmSync(downloadsLocation, {recursive: true, force: true}); } }); @@ -186,8 +183,7 @@ test.describe('downloads/downloads_menubar', () => { const isVisible = await downloadsWindow.isVisible('.DownloadsDropdown'); expect(isVisible).toBe(true); } finally { - await app.close(); - await waitForLockFileRelease(userDataDir); + await closeElectronAppFast(app, userDataDir); fs.rmSync(downloadsLocation, {recursive: true, force: true}); } }); diff --git a/e2e/specs/downloads/video_download.test.ts b/e2e/specs/downloads/video_download.test.ts new file mode 100644 index 00000000000..7528d9ff887 --- /dev/null +++ b/e2e/specs/downloads/video_download.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as fs from 'fs'; +import * as http from 'http'; +import * as path from 'path'; + +import {test, expect} from '../../fixtures/index'; +import {waitForAppReady} from '../../helpers/appReadiness'; +import {electronBinaryPath, appDir, emptyConfig} from '../../helpers/config'; +import {closeElectronAppFast} from '../../helpers/electronApp'; + +// ── MM-T1538: Download a video ──────────────────────────────────────── +// Verifies a real end-to-end download path for a video MIME type: +// 1. Local HTTP server serves a fake .mp4 (small binary buffer) +// 2. A BrowserWindow loaded inside the Electron app clicks the link +// 3. DownloadsManager (src/main/downloadsManager.ts) handles will-download +// 4. We assert: the file lands on disk AND downloads.json records it +// with state "completed" +// +// Pattern mirrors download_completion.test.ts so the two tests differ only +// in MIME type and content — keeping the download flow exercised for the +// file type MM-T1538 specifically targets (video) without duplicating the +// scaffolding. + +function readJsonFile(filePath: string): T | undefined { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T; + } catch { + return undefined; + } +} + +async function startVideoServer(filename: string, body: Buffer) { + const server = http.createServer((request, response) => { + if (request.url === '/video.mp4') { + response.writeHead(200, { + 'Content-Type': 'video/mp4', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': body.length, + }); + response.end(body); + return; + } + + response.writeHead(200, {'Content-Type': 'text/html'}); + response.end(` + + + + Download video + + + `); + }); + + await new Promise((resolve) => + server.listen(0, '127.0.0.1', () => resolve()), + ); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to start local video download server'); + } + + return { + server, + url: `http://127.0.0.1:${address.port}`, + }; +} + +test( + 'MM-T1538 Download a video', + {tag: ['@P2', '@all']}, + async ({}, testInfo) => { + const filename = 'sample-video.mp4'; + + // Minimal .mp4 — magic bytes are enough for the download-manager + // pipeline; we never play the file, only assert it landed on disk. + const videoBody = Buffer.from([ + 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, + 0x6d, 0x70, 0x34, 0x32, 0x00, 0x00, 0x00, 0x00, + 0x6d, 0x70, 0x34, 0x32, 0x69, 0x73, 0x6f, 0x6d, + ]); + + const {server, url} = await startVideoServer(filename, videoBody); + + const userDataDir = path.join(testInfo.outputDir, 'userdata'); + const downloadsDir = path.join(testInfo.outputDir, 'Downloads'); + const config = { + ...emptyConfig, + downloadLocation: downloadsDir, + }; + + fs.mkdirSync(userDataDir, {recursive: true}); + fs.mkdirSync(downloadsDir, {recursive: true}); + fs.writeFileSync( + path.join(userDataDir, 'config.json'), + JSON.stringify(config), + ); + + const {_electron: electron} = await import('playwright'); + const app = await electron.launch({ + executablePath: electronBinaryPath, + args: [ + appDir, + `--user-data-dir=${userDataDir}`, + '--no-sandbox', + '--disable-gpu', + ], + env: {...process.env, NODE_ENV: 'test'}, + timeout: 60_000, + }); + + const savedPath = path.join(downloadsDir, filename); + + try { + await waitForAppReady(app); + const mainWindow = app.windows().find((window) => window.url().includes('index')); + expect(mainWindow).toBeDefined(); + await mainWindow!.waitForLoadState(); + + const popupPromise = app.waitForEvent('window', { + predicate: (window) => window.url().startsWith(url), + timeout: 15_000, + }); + + await app.evaluate(async ({BrowserWindow}, popupUrl) => { + const popup = new BrowserWindow({ + show: true, + width: 900, + height: 700, + }); + await popup.loadURL(popupUrl); + (global as any).__videoDownloadPopup = popup; + }, url); + + const popupWindow = await popupPromise; + await popupWindow.waitForLoadState(); + await popupWindow.click('#download-link'); + + await expect. + poll(() => fs.existsSync(savedPath), {timeout: 15_000}). + toBe(true); + + // Verify the downloaded bytes match what we served + await expect. + poll(() => fs.readFileSync(savedPath).equals(videoBody), {timeout: 15_000}). + toBe(true); + + // DownloadsManager must record the download as completed + await expect. + poll( + () => { + const downloads = readJsonFile>( + path.join(userDataDir, 'downloads.json'), + ); + return downloads?.[filename]?.state; + }, + {timeout: 15_000}, + ). + toBe('completed'); + } finally { + try { + await closeElectronAppFast(app, userDataDir); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + fs.rmSync(downloadsDir, {recursive: true, force: true}); + } + } + }, +); From 711060b137a0d76dd4c95e72f1d9253e8f5bced9 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Wed, 1 Jul 2026 05:07:41 +0530 Subject: [PATCH 2/2] Address CodeRabbit review on downloads E2E helpers and specs Handle download server bind errors, use closeElectronAppFast, close the dropdown via IPC, guard teardown with allSettled, and store openPath captures on __e2eTestRefs. Co-authored-by: Cursor --- e2e/helpers/downloads.ts | 10 +++++---- e2e/helpers/downloadsDropdown.ts | 25 ++++++++------------- e2e/specs/downloads/download_cancel.test.ts | 6 +++-- e2e/specs/downloads/download_open.test.ts | 21 ++++++++++++----- 4 files changed, 34 insertions(+), 28 deletions(-) diff --git a/e2e/helpers/downloads.ts b/e2e/helpers/downloads.ts index f06673ef791..b77102af902 100644 --- a/e2e/helpers/downloads.ts +++ b/e2e/helpers/downloads.ts @@ -9,8 +9,8 @@ import {expect} from '@playwright/test'; import type {ElectronApplication} from 'playwright'; import {waitForAppReady} from './appReadiness'; -import {waitForLockFileRelease} from './cleanup'; import {electronBinaryPath, appDir, emptyConfig} from './config'; +import {closeElectronAppFast} from './electronApp'; export type DownloadServer = { server: http.Server; @@ -93,7 +93,10 @@ export async function startDownloadServer( `); }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve()); + }); const address = server.address(); if (!address || typeof address === 'string') { throw new Error('Failed to start local download server'); @@ -223,8 +226,7 @@ export async function closeDownloadTestApp(app: ElectronApplication, userDataDir delete (global as any).__e2eDownloadPopup; }).catch(() => {}); - await app.close().catch(() => {}); - await waitForLockFileRelease(userDataDir).catch(() => {}); + await closeElectronAppFast(app, userDataDir).catch(() => {}); if (!fs.existsSync(downloadLocation)) { return; diff --git a/e2e/helpers/downloadsDropdown.ts b/e2e/helpers/downloadsDropdown.ts index e020b46d61c..0eecf875d07 100644 --- a/e2e/helpers/downloadsDropdown.ts +++ b/e2e/helpers/downloadsDropdown.ts @@ -3,28 +3,21 @@ import type {ElectronApplication} from 'playwright'; +const CLOSE_DOWNLOADS_DROPDOWN = 'close-downloads-dropdown'; +const CLOSE_DOWNLOADS_DROPDOWN_MENU = 'close-downloads-dropdown-menu'; + /** - * Close the downloads dropdown BrowserWindow if it is open. - * Parallel download specs can leave this window focused and block other UI flows. + * Close the downloads dropdown WebContentsView if it is open. + * Parallel download specs can leave this overlay focused and block other UI flows. */ export async function closeDownloadsDropdownIfOpen(app: ElectronApplication): Promise { const deadline = Date.now() + 15_000; while (Date.now() < deadline) { try { - await app.evaluate(({BrowserWindow}) => { - for (const win of BrowserWindow.getAllWindows()) { - if (win.isDestroyed()) { - continue; - } - try { - if (win.webContents.getURL().includes('downloadsDropdown.html')) { - win.close(); - } - } catch { - // Ignore windows that disappear while iterating. - } - } - }); + await app.evaluate(({ipcMain}, channels) => { + ipcMain.emit(channels.menu); + ipcMain.emit(channels.dropdown); + }, {dropdown: CLOSE_DOWNLOADS_DROPDOWN, menu: CLOSE_DOWNLOADS_DROPDOWN_MENU}); return; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/e2e/specs/downloads/download_cancel.test.ts b/e2e/specs/downloads/download_cancel.test.ts index 6d668a3709c..a1c5afa8a41 100644 --- a/e2e/specs/downloads/download_cancel.test.ts +++ b/e2e/specs/downloads/download_cancel.test.ts @@ -50,8 +50,10 @@ test( {timeout: 15_000, message: 'Cancelled download should be marked cancelled in downloads.json'}, ).toMatch(/cancelled|interrupted/); } finally { - await closeDownloadTestApp(app, userDataDir, downloadLocation); - await close(); + await Promise.allSettled([ + closeDownloadTestApp(app, userDataDir, downloadLocation), + close(), + ]); } }, ); diff --git a/e2e/specs/downloads/download_open.test.ts b/e2e/specs/downloads/download_open.test.ts index 1c1a3d5797c..10ba9353328 100644 --- a/e2e/specs/downloads/download_open.test.ts +++ b/e2e/specs/downloads/download_open.test.ts @@ -30,9 +30,10 @@ test( await waitForDownloadFile(userDataDir, downloadLocation, filename); await app.evaluate(({shell}) => { - (global as any).__e2eOpenedPaths = [] as string[]; + const refs = (global as any).__e2eTestRefs; + refs.__e2eOpenedPaths = [] as string[]; shell.openPath = async (targetPath: string) => { - (global as any).__e2eOpenedPaths.push(targetPath); + refs.__e2eOpenedPaths.push(targetPath); return ''; }; }); @@ -41,14 +42,22 @@ test( await downloadsWindow.click('.DownloadsDropdown__File'); await expect.poll(async () => { - return app.evaluate(() => ((global as any).__e2eOpenedPaths as string[] | undefined)?.length ?? 0); + return app.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + return (refs.__e2eOpenedPaths as string[] | undefined)?.length ?? 0; + }); }, {timeout: 10_000}).toBeGreaterThan(0); - const openedPath = await app.evaluate(() => ((global as any).__e2eOpenedPaths as string[])[0]); + const openedPath = await app.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + return (refs.__e2eOpenedPaths as string[])[0]; + }); expect(openedPath).toContain(filename); } finally { - await closeDownloadTestApp(app, userDataDir, downloadLocation); - await close(); + await Promise.allSettled([ + closeDownloadTestApp(app, userDataDir, downloadLocation), + close(), + ]); } }, );