diff --git a/.github/actions/cancel-e2e-runs/action.yml b/.github/actions/cancel-e2e-runs/action.yml new file mode 100644 index 00000000000..d6befddd976 --- /dev/null +++ b/.github/actions/cancel-e2e-runs/action.yml @@ -0,0 +1,54 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-action.json +name: Cancel E2E workflow runs +description: >- + Cancel active Electron Playwright Tests runs and mark E2E commit statuses as + cancelled so PR checks do not show a green "No tests ran" result. + +inputs: + pr_number: + description: Pull request number whose head SHA receives updated commit statuses + required: true + reason: + description: Commit status description when cancelling + required: false + default: E2E cancelled — tests skipped + cancel_workflow_runs: + description: When false, only update commit statuses (runs already stopped) + required: false + default: 'true' + +runs: + using: composite + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PR_NUMBER: ${{ inputs.pr_number }} + REASON: ${{ inputs.reason }} + CANCEL_WORKFLOW_RUNS: ${{ inputs.cancel_workflow_runs }} + with: + script: | + const {markE2EStatusesCancelled, cancelActiveE2ERuns} = require('./e2e/utils/github-actions.js'); + const prNumber = parseInt(process.env.PR_NUMBER, 10); + if (!Number.isFinite(prNumber)) { + throw new Error(`Invalid pr_number: ${process.env.PR_NUMBER}`); + } + + const reason = process.env.REASON || 'E2E cancelled — tests skipped'; + const shouldCancelRuns = process.env.CANCEL_WORKFLOW_RUNS !== 'false'; + + if (shouldCancelRuns) { + await cancelActiveE2ERuns({github, context, prNumber}); + } + + const {data: pr} = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + await markE2EStatusesCancelled({ + github, + context, + sha: pr.head.sha, + reason, + }); diff --git a/.github/workflows/e2e-functional-template.yml b/.github/workflows/e2e-functional-template.yml index f8a71a082b7..9aa8e3ea9d0 100644 --- a/.github/workflows/e2e-functional-template.yml +++ b/.github/workflows/e2e-functional-template.yml @@ -388,6 +388,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PER_OS_REPORT_URL: ${{ steps.upload-html-report-to-s3.outputs.report_url }} + JOB_STATUS: ${{ job.status }} with: script: | process.chdir('./e2e'); diff --git a/.github/workflows/e2e-functional.yml b/.github/workflows/e2e-functional.yml index 9f290235857..7ef15fc2fa9 100644 --- a/.github/workflows/e2e-functional.yml +++ b/.github/workflows/e2e-functional.yml @@ -67,11 +67,13 @@ jobs: - name: Update initial status for all platforms uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PLATFORMS: ${{ needs.prepare-matrix.outputs.platforms }} with: github-token: ${{ github.token }} script: | const { updateInitialStatus } = require('./e2e/utils/github-actions.js'); - const platforms = ${{ needs.prepare-matrix.outputs.platforms }}; + const platforms = JSON.parse(process.env.PLATFORMS); await updateInitialStatus({ github, context, platforms }); e2e-tests: @@ -113,13 +115,26 @@ jobs: - name: Update final status for all platforms uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PR_NUMBER: ${{ inputs.pr_number }} + PLATFORMS: ${{ needs.prepare-matrix.outputs.platforms }} + OUTPUTS: ${{ toJSON(needs.e2e-tests.outputs) }} + E2E_TESTS_RESULT: ${{ needs.e2e-tests.result }} with: github-token: ${{ github.token }} script: | const { updateFinalStatus } = require('./e2e/utils/github-actions.js'); - const platforms = ${{ needs.prepare-matrix.outputs.platforms }}; - const outputs = ${{ toJSON(needs.e2e-tests.outputs) }}; - await updateFinalStatus({ github, context, platforms, outputs }); + const platforms = JSON.parse(process.env.PLATFORMS); + const outputs = JSON.parse(process.env.OUTPUTS); + const prNumber = parseInt(process.env.PR_NUMBER, 10) || null; + await updateFinalStatus({ + github, + context, + platforms, + outputs, + e2eTestsResult: process.env.E2E_TESTS_RESULT, + prNumber, + }); remove-e2e-label: name: Remove E2E label from PR @@ -380,4 +395,5 @@ jobs: with: name: policy-test-results-${{ runner.os }} path: e2e/playwright-report + if-no-files-found: ignore retention-days: 7 diff --git a/.github/workflows/e2e-pr-trigger.yml b/.github/workflows/e2e-pr-trigger.yml index 36b2679e292..5578d92a6bc 100644 --- a/.github/workflows/e2e-pr-trigger.yml +++ b/.github/workflows/e2e-pr-trigger.yml @@ -5,13 +5,13 @@ name: E2E PR Trigger # the Electron Playwright Tests (e2e-functional.yml) workflow. # After tests complete, e2e-functional.yml / e2e-label-cleanup.yml remove the label. # -# On synchronize: in-progress Electron Playwright Tests runs are cancelled before -# re-adding the label so stale runs for the old commit don't block the new one. -# Matterwick dispatches e2e-functional.yml via workflow_dispatch using the default -# branch as the ref (so all E2E runs show head_branch: master). This makes it -# impossible to distinguish runs by PR branch, so all in-progress E2E runs are -# cancelled — which is correct behaviour since only one labelled PR triggers tests -# at a time in this project. +# E2E/Override (same contract as mattermost-mobile): when present on a PR, +# opened/synchronize events do not add E2E/Run, and applying the override label +# strips E2E/Run and cancels in-flight E2E runs. +# +# On synchronize: in-progress Electron Playwright Tests runs for this PR are +# cancelled before re-adding the label so stale runs for the old commit do not +# block the new one. Runs on other PR branches are left running. # # The concurrency group ensures rapid pushes to the same PR don't queue multiple # label operations: only the most recent push proceeds. @@ -23,14 +23,11 @@ on: - reopened - ready_for_review - synchronize + - labeled + - unlabeled branches: - master -permissions: - issues: write - pull-requests: write - actions: write - concurrency: group: e2e-pr-trigger-${{ github.event.pull_request.number }} cancel-in-progress: true @@ -39,58 +36,84 @@ jobs: add-e2e-label: name: Add E2E/Run label runs-on: ubuntu-22.04 - if: ${{ !github.event.pull_request.draft }} + permissions: + issues: write + pull-requests: write + actions: write + statuses: write + if: >- + !github.event.pull_request.draft + && contains(fromJSON('["opened", "reopened", "ready_for_review", "synchronize"]'), github.event.action) steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.pull_request.base.ref }} + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + if: github.event.pull_request.head.repo.full_name == github.repository + with: + ref: ${{ github.event.pull_request.head.sha }} + sparse-checkout: | + e2e/utils/github-actions.js + sparse-checkout-cone-mode: false + - name: Cancel running E2E tests and re-trigger uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ github.token }} script: | + const {cancelActiveE2ERuns, markE2EStatusesCancelled} = require('./e2e/utils/github-actions.js'); const { owner, repo } = context.repo; const issue_number = context.issue.number; + const pr = context.payload.pull_request; - // --- 1. Cancel in-progress / queued Electron Playwright Tests runs --- - // - // Matterwick dispatches e2e-functional.yml via workflow_dispatch using the - // default branch as the dispatch ref, so every E2E run has head_branch=master - // regardless of which PR triggered it. We therefore cancel ALL non-terminal - // runs of that workflow rather than trying to filter by branch. - // This is safe because only one labelled PR drives E2E tests at a time. - const { data: { workflows } } = await github.rest.actions.listRepoWorkflows({ + const { data: labels } = await github.rest.issues.listLabelsOnIssue({ owner, repo, + issue_number, }); + const labelNames = labels.map((label) => label.name); + + if (labelNames.includes('E2E/Override')) { + core.info(`PR #${issue_number} has E2E/Override — skipping E2E/Run refresh.`); - const e2eWorkflow = workflows.find((w) => w.name === 'Electron Playwright Tests'); - if (e2eWorkflow) { - for (const status of ['in_progress', 'queued', 'waiting']) { - const { data: { workflow_runs } } = await github.rest.actions.listWorkflowRuns({ - owner, - repo, - workflow_id: e2eWorkflow.id, - status, - per_page: 20, - }); - for (const run of workflow_runs) { - try { - await github.rest.actions.cancelWorkflowRun({ owner, repo, run_id: run.id }); - core.info(`Cancelled E2E run ${run.id} (status: ${status})`); - } catch (e) { - // A run may have finished between list and cancel — that's fine. - core.warning(`Could not cancel run ${run.id}: ${e.message}`); + if (labelNames.includes('E2E/Run')) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number, + name: 'E2E/Run', + }); + } catch (error) { + if (error.status !== 404) { + throw error; } } } - } else { - core.warning('Electron Playwright Tests workflow not found — skipping cancellation'); + + await cancelActiveE2ERuns({ + github, + context, + prNumber: issue_number, + headBranch: pr.head.ref, + }); + await markE2EStatusesCancelled({ + github, + context, + sha: pr.head.sha, + reason: 'E2E skipped (E2E/Override label active)', + }); + return; } - // --- 2. Remove + re-add E2E/Run label --- - // - // Remove first so re-adding always fires a pull_request:labeled webhook. - // Without this, if the label is already present (tests were running), - // addLabels is a no-op and Matterwick never sees a new event for the - // latest commit. + await cancelActiveE2ERuns({ + github, + context, + prNumber: issue_number, + headBranch: pr.head.ref, + }); + try { await github.rest.issues.removeLabel({ owner, @@ -98,9 +121,9 @@ jobs: issue_number, name: 'E2E/Run', }); - } catch (e) { - if (e.status !== 404) { - throw e; // 404 means label was absent; anything else is unexpected + } catch (error) { + if (error.status !== 404) { + throw error; } } @@ -110,3 +133,93 @@ jobs: issue_number, labels: ['E2E/Run'], }); + + honor-e2e-override: + name: Honor E2E/Override + runs-on: ubuntu-22.04 + permissions: + issues: write + pull-requests: write + actions: write + statuses: write + if: >- + github.event.action == 'labeled' + && github.event.label.name == 'E2E/Override' + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.pull_request.base.ref }} + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + if: github.event.pull_request.head.repo.full_name == github.repository + with: + ref: ${{ github.event.pull_request.head.sha }} + sparse-checkout: | + e2e/utils/github-actions.js + sparse-checkout-cone-mode: false + + - name: Strip E2E/Run and cancel in-flight E2E + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ github.token }} + script: | + const {cancelActiveE2ERuns, markE2EStatusesCancelled} = require('./e2e/utils/github-actions.js'); + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const pr = context.payload.pull_request; + + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number, + name: 'E2E/Run', + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + + await cancelActiveE2ERuns({ + github, + context, + prNumber: issue_number, + headBranch: pr.head.ref, + }); + await markE2EStatusesCancelled({ + github, + context, + sha: pr.head.sha, + reason: 'E2E cancelled (E2E/Override label applied)', + }); + + cancel-on-manual-unlabel: + name: Cancel E2E on manual label removal + runs-on: ubuntu-22.04 + permissions: + actions: write + statuses: write + pull-requests: read + if: >- + github.event.action == 'unlabeled' + && github.event.label.name == 'E2E/Run' + && github.event.sender.login != 'github-actions[bot]' + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.pull_request.base.ref }} + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + if: github.event.pull_request.head.repo.full_name == github.repository + with: + ref: ${{ github.event.pull_request.head.sha }} + sparse-checkout: | + e2e/utils/github-actions.js + sparse-checkout-cone-mode: false + + - name: Cancel E2E runs and mark statuses skipped + uses: ./.github/actions/cancel-e2e-runs + with: + pr_number: ${{ github.event.pull_request.number }} + reason: E2E cancelled (E2E/Run label removed) diff --git a/e2e/helpers/badServer.ts b/e2e/helpers/badServer.ts new file mode 100644 index 00000000000..669321ae7ab --- /dev/null +++ b/e2e/helpers/badServer.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; +import type {ElectronApplication, Page} from 'playwright'; + +export const UNREACHABLE_SERVER_URL = 'https://jhsgefhjsaeiuofhseifuphoauifdhjauiowijdfcpohuawoiudfjpdhauwodjahwdpojaoiwdhawhdiuawd.com'; +export const EXPIRED_CERT_URL = 'https://expired.badssl.com'; +export const TLS_1_0_URL = 'https://tls-v1-0.badssl.com:1010'; +export const TLS_1_1_URL = 'https://tls-v1-1.badssl.com'; +export const RC4_CIPHER_URL = 'https://rc4.badssl.com'; + +/** DNS / host resolution failures (stable across platforms). */ +export const DNS_FAILURE_ERROR = /ERR_NAME_NOT_RESOLVED/; + +/** Certificate expiry failures surfaced before retry exhaustion. */ +export const EXPIRED_CERT_ERROR = /ERR_CERT_DATE_INVALID/; + +/** Obsolete TLS versions rejected during handshake. */ +export const OBSOLETE_TLS_ERROR = /ERR_SSL_(VERSION_OR_CIPHER_MISMATCH|PROTOCOL_ERROR)/; + +/** + * Insecure cipher/protocol endpoints (e.g. RC4-only). Modern Chromium may reset the + * connection instead of completing a handshake with OBSOLETE_CIPHER, so accept that + * specific reset alongside the SSL-handshake errors. Deliberately does NOT include + * generic codes like ERR_CONNECTION_CLOSED or ERR_NETWORK_* — those can fire for + * unrelated causes (CI network blips, proxy resets) and would let this test pass + * without the app ever having rejected an insecure cipher. + */ +export const INSECURE_CIPHER_ERROR = /ERR_(SSL_(OBSOLETE_CIPHER|VERSION_OR_CIPHER_MISMATCH|PROTOCOL_ERROR)|CONNECTION_RESET)/; + +export function getMainWindow(app: ElectronApplication): Page { + const mainWindow = app.windows().find((window) => window.url().includes('index')); + expect(mainWindow, 'Main window (index) must exist').toBeDefined(); + return mainWindow!; +} + +/** + * Wait until the renderer MainPage has mounted (IPC listeners registered). + */ +export async function waitForRendererReady(mainWindow: Page): Promise { + await mainWindow.waitForSelector('.ServerDropdownButton', {timeout: 15_000}); +} + +/** + * Reload server views through the app's MattermostWebContentsView.reload() so + * LOAD_FAILED is emitted on certificate / connection errors. + */ +export async function reloadServerViewsFromMainProcess(app: ElectronApplication): Promise { + await app.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + if (!refs) { + // Not yet populated this early in boot — caller polls and retries. + return; + } + + // Deliberately NOT optional-chained past this point: if ServerManager/ + // ViewManager/WebContentsManager are renamed, this should throw immediately + // instead of silently reloading nothing and leaving the caller to time out + // 45s later with a generic "ErrorView did not appear" message. + const servers: Array<{id: string}> = refs.ServerManager.getAllServers(); + for (const server of servers) { + const views: Array<{id: string}> = refs.ViewManager.getViewsByServerId(server.id); + for (const view of views) { + const wcEntry = refs.WebContentsManager.getView(view.id); + wcEntry?.reload?.(); + } + } + }); +} + +async function readTerminalLoadFailure( + mainWindow: Page, + acceptedError: RegExp, +): Promise { + if (!(await mainWindow.isVisible('.ErrorView'))) { + return null; + } + + const errorInfo = await mainWindow.innerText('.ErrorView-techInfo'); + if ((/ERR_ABORTED/).test(errorInfo) && !acceptedError.test(errorInfo)) { + return null; + } + + return acceptedError.test(errorInfo) ? errorInfo : null; +} + +/** + * Wait for ErrorView with a terminal Chromium load error. Ignores transient + * ERR_ABORTED states that can appear while a reload is in flight. + */ +export async function waitForTerminalLoadFailure( + mainWindow: Page, + acceptedError: RegExp, + timeoutMs = 45_000, +): Promise { + let errorInfo = ''; + + await expect.poll(async () => { + errorInfo = await readTerminalLoadFailure(mainWindow, acceptedError) ?? ''; + return Boolean(errorInfo); + }, { + timeout: timeoutMs, + message: `ErrorView must show a terminal load failure matching ${acceptedError}`, + }).toBe(true); + + return errorInfo; +} + +export async function waitForRendererReadyThenReload( + app: ElectronApplication, + acceptedError: RegExp, +): Promise { + const mainWindow = getMainWindow(app); + await waitForRendererReady(mainWindow); + + if (!(await readTerminalLoadFailure(mainWindow, acceptedError))) { + await reloadServerViewsFromMainProcess(app); + } + + return mainWindow; +} + +export async function expectConnectionErrorView( + mainWindow: Page, + acceptedError: RegExp, + options?: {timeoutMs?: number}, +): Promise { + const errorInfo = await waitForTerminalLoadFailure( + mainWindow, + acceptedError, + options?.timeoutMs, + ); + expect(errorInfo).toMatch(acceptedError); +} diff --git a/e2e/helpers/channelMenu.ts b/e2e/helpers/channelMenu.ts new file mode 100644 index 00000000000..4f89a06e2f9 --- /dev/null +++ b/e2e/helpers/channelMenu.ts @@ -0,0 +1,443 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; +import type {ElectronApplication} from 'playwright'; + +import type {ServerView} from './serverView'; + +const CHANNEL_HEADER_MENU_TRIGGER = [ + 'button[aria-label*="channel menu" i]', + '#channelHeaderDropdownButton', + 'button[aria-controls="channelHeaderDropdownMenu"]', + '#channelHeaderTitle button', +].join(', '); + +export const COPY_LINK_SELECTORS = [ + '#channelCopyLink', + '[role="menuitem"]:has-text("Copy Link")', + '[role="menuitem"]:has-text("Copy link")', + 'button:has-text("Copy Link")', + 'button:has-text("Copy link")', + 'a:has-text("Copy Link")', + 'a:has-text("Copy link")', +]; + +/** + * Open the channel header ("⋮") menu for the current channel. + * The webapp migrated from #channelHeaderDropdownButton to Menu.Button + * with an aria-label like "off-topic channel menu". + */ +export async function openChannelHeaderMenu(win: ServerView): Promise { + await win.waitForSelector(CHANNEL_HEADER_MENU_TRIGGER, {state: 'visible', timeout: 15_000}); + await win.click(CHANNEL_HEADER_MENU_TRIGGER); + await win.waitForSelector('#channelHeaderDropdownMenu, .a11y__popup', {timeout: 5_000}); +} + +const SIDEBAR_CHANNEL_MENU_BUTTON = (channelItemSelector: string) => [ + `${channelItemSelector} button[aria-label*="channel menu" i]`, + `${channelItemSelector} button[aria-label*="channel options" i]`, + `${channelItemSelector} button[aria-label*="options" i]`, + `${channelItemSelector} button.SidebarMenu_menuButton`, + `${channelItemSelector} .SidebarMenu button`, + `${channelItemSelector} [data-testid="channel-options-dropdown"]`, +].join(', '); + +/** + * Open the per-channel sidebar options ("⋮") menu for a sidebar row. + * The trigger is hover-gated in the webapp; use native mouseMove so Electron + * updates :hover state (synthetic dispatchEvent is ignored on macOS/Windows). + */ +export async function openSidebarChannelMenu(win: ServerView, channelItemSelector: string): Promise { + await win.waitForSelector(channelItemSelector, {timeout: 15_000}); + + const menuButtonSelector = SIDEBAR_CHANNEL_MENU_BUTTON(channelItemSelector); + + const hoverPoint = await win.runInRenderer(` + const el = document.querySelector(${JSON.stringify(channelItemSelector)}); + if (!el) { + return null; + } + el.scrollIntoView({block: 'center', inline: 'center'}); + const rect = el.getBoundingClientRect(); + return { + x: Math.round(rect.right - 8), + y: Math.round(rect.top + (rect.height / 2)), + }; + `, true); + expect(hoverPoint, `Channel sidebar item must exist: ${channelItemSelector}`).toBeTruthy(); + + await win.app.evaluate(({webContents}, payload) => { + const wc = webContents.fromId(payload.id); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${payload.id} is not available`); + } + wc.focus(); + wc.sendInputEvent({type: 'mouseMove', x: payload.x, y: payload.y}); + }, {id: win.webContentsId, ...hoverPoint!}); + + await win.waitForSelector(menuButtonSelector, {state: 'visible', timeout: 15_000}); + await win.click(menuButtonSelector); + await waitForCopyLinkInMenu(win); +} + +/** Poll until a Copy Link item is present in the open webapp menu. */ +export async function waitForCopyLinkInMenu(win: ServerView): Promise { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + for (const selector of COPY_LINK_SELECTORS) { + const candidate = await win.$(selector); + if (candidate) { + return; + } + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error('"Copy Link" item not found in the channel menu'); +} + +/** Click Copy Link in an already-open channel menu. */ +export async function clickCopyLinkInMenu(win: ServerView): Promise { + await waitForCopyLinkInMenu(win); + for (const selector of COPY_LINK_SELECTORS) { + const candidate = await win.$(selector); + if (candidate) { + await win.click(selector); + return; + } + } + throw new Error('"Copy Link" item became unavailable before it could be clicked'); +} + +/** + * Enable the channel bookmarks bar via the channel header menu. + * Bookmarks saved while the bar is hidden may not appear in the bar UI. + * + * The bar container is not rendered until at least one bookmark exists, so this + * helper only toggles the preference — callers wait for bookmark items later. + */ +export async function enableBookmarksBar(win: ServerView): Promise { + const alreadyVisible = await win.runInRenderer(` + const container = document.querySelector('[data-testid="channel-bookmarks-container"]'); + if (!container) { + return false; + } + const rect = container.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + `); + if (alreadyVisible) { + return; + } + + await openChannelHeaderMenu(win); + const toggled = await win.runInRenderer(` + const items = Array.from(document.querySelectorAll( + '[role="menuitem"], .MenuItem, [id^="channel-menu-"]', + )); + const barItem = items.find((item) => /bookmarks bar/i.test((item.textContent || '').trim())); + if (!barItem) { + return false; + } + barItem.click(); + return true; + `, true); + if (!toggled) { + throw new Error('Bookmarks Bar menu item not found in channel header menu'); + } + await win.keyboard.press('Escape'); +} + +export const TEAM_SIDEBAR_BUTTON = [ + '#teamSidebarWrapper [id$="TeamButton"]', + '#teamSidebar button[class*="TeamButton"]', + 'button[aria-label$=" team"]', +].join(', '); + +const TEAM_MENU_ITEM_SELECTORS = [ + '.Menu .MenuItem', + '[role="menuitem"]', + '.dropdown-menu .MenuItem', + '#teamMenu .MenuItem', +]; + +/** + * Right-click a team sidebar button using native input events. + * Desktop app shows Chromium's native context menu here (not webapp .Menu). + */ +export async function openTeamSidebarContextMenu( + win: ServerView, + app: ElectronApplication, + webContentsId: number, +): Promise { + await win.waitForSelector(TEAM_SIDEBAR_BUTTON, {timeout: 15_000}); + const point = await win.runInRenderer(` + const selectors = ${JSON.stringify(TEAM_SIDEBAR_BUTTON.split(', '))}; + let buttons = []; + for (const selector of selectors) { + buttons = Array.from(document.querySelectorAll(selector)); + if (buttons.length > 0) { + break; + } + } + // Prefer the second match when multiple team buttons exist: the first is + // usually the active team or add-team control; ensureMultipleTeams adds a + // second team whose native context menu this spec exercises. + const target = buttons.length > 1 ? buttons[1] : buttons[0]; + if (!target) { + return null; + } + target.scrollIntoView({block: 'center', inline: 'center'}); + const rect = target.getBoundingClientRect(); + return { + x: Math.round(rect.left + (rect.width / 2)), + y: Math.round(rect.top + (rect.height / 2)), + }; + `, true); + expect(point, 'Team sidebar button must be available for context menu').toBeTruthy(); + + await app.evaluate(({webContents}, payload) => { + const wc = webContents.fromId(payload.id); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${payload.id} is not available`); + } + wc.focus(); + wc.sendInputEvent({type: 'mouseMove', x: payload.x, y: payload.y}); + wc.sendInputEvent({ + type: 'mouseDown', + x: payload.x, + y: payload.y, + button: 'right', + clickCount: 1, + }); + wc.sendInputEvent({ + type: 'mouseUp', + x: payload.x, + y: payload.y, + button: 'right', + clickCount: 1, + }); + }, {id: webContentsId, ...point!}); +} + +/** Register a listener for Chromium's native context-menu event on a server view. */ +export async function listenForNativeContextMenu( + app: ElectronApplication, + webContentsId: number, +): Promise { + await app.evaluate(({webContents}, id) => { + const wc = webContents.fromId(id); + if (!wc || wc.isDestroyed()) { + return; + } + delete (global as any).__e2eNativeContextMenu; + + const previousListener = (global as any).__e2eNativeContextMenuListener as + | ((event: unknown, params: unknown) => void) + | undefined; + if (previousListener) { + wc.off('context-menu', previousListener); + } + + const listener = (_event: unknown, params: unknown) => { + (global as any).__e2eNativeContextMenu = params; + }; + (global as any).__e2eNativeContextMenuListener = listener; + wc.on('context-menu', listener); + }, webContentsId); +} + +/** Poll until Chromium reports a native context menu for the server view. */ +export async function waitForNativeContextMenu(app: ElectronApplication): Promise { + await expect.poll(async () => app.evaluate(() => { + const params = (global as any).__e2eNativeContextMenu; + return Boolean(params); + }), {timeout: 10_000, message: 'Native context menu must open on team right-click'}).toBe(true); +} + +/** Poll until a webapp menu item is visible (channel sidebar menus). */ +export async function waitForWebappContextMenu(win: ServerView): Promise { + await expect.poll(async () => win.runInRenderer(` + const selectors = ${JSON.stringify(TEAM_MENU_ITEM_SELECTORS)}; + return selectors.some((selector) => { + const items = Array.from(document.querySelectorAll(selector)); + return items.some((item) => { + const rect = item.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + }); + `), {timeout: 10_000, message: 'Webapp context menu must appear'}).toBe(true); +} + +/** Selectors for channel bookmark bar items (webapp varies by version). */ +export const BOOKMARK_BAR_LINK_SELECTORS = [ + 'a[href*="utm_content=channel_bookmarks.item"]', + '[data-testid="channel-bookmarks-container"] [data-testid^="bookmark-item-"] a', + '[data-testid="channel-bookmarks-container"] a[href]', + '#channelBookmarksContainer a[href]', +]; + +export const BOOKMARK_BAR_ITEM_SELECTORS = [ + 'a[href*="utm_content=channel_bookmarks.item"]', + '[data-testid="channel-bookmarks-container"] [data-testid^="bookmark-item-"]', + '[data-testid="channel-bookmarks-container"] a[href]', + '#channelBookmarksContainer [data-testid^="bookmark-item-"]', +]; + +/** Poll until a bookmark item appears in the channel bookmarks bar. */ +export async function waitForBookmarkInBar(win: ServerView, urlPart?: string): Promise { + await expect.poll(async () => win.runInRenderer(` + const selectors = ${JSON.stringify(BOOKMARK_BAR_LINK_SELECTORS)}; + const needle = ${JSON.stringify(urlPart ?? '')}; + const links = []; + for (const selector of selectors) { + links.push(...document.querySelectorAll(selector)); + } + const visible = links.filter((link) => { + const rect = link.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + if (!needle) { + return visible.length > 0; + } + return visible.some((link) => link.href.includes(needle)); + `), {timeout: 30_000, message: 'Bookmark item must appear in the channel bookmarks bar'}).toBe(true); +} + +/** Click a bookmark link in the channel bookmarks bar. */ +export async function clickBookmarkInBar(win: ServerView, urlPart: string): Promise { + const clicked = await win.runInRenderer(` + const needle = ${JSON.stringify(urlPart)}; + const selectors = ${JSON.stringify(BOOKMARK_BAR_LINK_SELECTORS)}; + const links = []; + for (const selector of selectors) { + links.push(...document.querySelectorAll(selector)); + } + const target = [...links].reverse().find((link) => { + const rect = link.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && link.href.includes(needle); + }); + if (!target) { + return false; + } + target.scrollIntoView({block: 'center', inline: 'center'}); + target.click(); + return true; + `, true); + expect(clicked, `Bookmark link containing "${urlPart}" must be clickable`).toBe(true); +} + +/** Delete every bookmark currently shown in the channel bookmarks bar. */ +export async function deleteAllBookmarksInBar(win: ServerView): Promise { + for (let attempt = 0; attempt < 10; attempt += 1) { + const hasBookmark = await win.runInRenderer(` + const selectors = ${JSON.stringify(BOOKMARK_BAR_LINK_SELECTORS)}; + return selectors.some((selector) => document.querySelector(selector)); + `); + if (!hasBookmark) { + return; + } + + // The dot-menu trigger is only rendered while the bookmark item is hovered. + // Between `hoverBookmarkInBar()` and `win.click(...)` the renderer can unmount + // the trigger (hover lost, animation, or stale React subtree from a previous + // delete), producing "Element not found for click". Re-hover and click within + // a single renderer round-trip so the element can't disappear between the + // existence check and the click; on transient failure, retry the loop and + // re-check `hasBookmark` — if the item is already gone, we're done. + const clicked = await win.runInRenderer(` + const selectors = ${JSON.stringify(BOOKMARK_BAR_ITEM_SELECTORS)}; + let item = null; + for (const selector of selectors) { + item = document.querySelector(selector); + if (item) { + break; + } + } + if (!item) { + return false; + } + item.dispatchEvent(new MouseEvent('mouseenter', {bubbles: true})); + item.dispatchEvent(new MouseEvent('mouseover', {bubbles: true})); + const trigger = document.querySelector('[id^="channelBookmarksDotMenuButton-"]'); + if (!trigger) { + return false; + } + trigger.click(); + return true; + `, true); + if (!clicked) { + await sleep(200); + continue; + } + + try { + await win.waitForSelector('#channelBookmarksDelete', {state: 'visible', timeout: 5_000}); + await win.click('#channelBookmarksDelete'); + await win.waitForSelector('.GenericModal', {state: 'visible', timeout: 5_000}); + await win.click('button:has-text("Yes, delete")'); + await win.waitForSelector('.GenericModal', {state: 'hidden', timeout: 5_000}).catch(() => {}); + } catch { + // Menu/modal closed before we could finish the chain — re-loop and let + // the `hasBookmark` probe decide whether the bookmark was actually deleted. + await sleep(200); + } + } + + throw new Error('Failed to delete all bookmarks in the channel bookmarks bar after 10 attempts'); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Hover the first bookmark item so its dot-menu trigger is reachable. */ +export async function hoverBookmarkInBar(win: ServerView): Promise { + await win.runInRenderer(` + const selectors = ${JSON.stringify(BOOKMARK_BAR_ITEM_SELECTORS)}; + let item = null; + for (const selector of selectors) { + item = document.querySelector(selector); + if (item) { + break; + } + } + if (!item) { + return false; + } + item.dispatchEvent(new MouseEvent('mouseenter', {bubbles: true})); + item.dispatchEvent(new MouseEvent('mouseover', {bubbles: true})); + return true; + `, true); +} + +export async function submitBookmarkModal(win: ServerView): Promise { + await expect.poll(async () => win.runInRenderer(` + const buttons = Array.from(document.querySelectorAll( + '.GenericModal button, .GenericModal .GenericModal__button', + )); + const save = buttons.find((button) => { + const label = (button.textContent || '').trim().toLowerCase(); + return label.includes('add bookmark') + || (label.includes('save') && !label.includes('cancel')); + }); + return Boolean(save && (!(save instanceof HTMLButtonElement) || !save.disabled)); + `), {timeout: 15_000, message: 'Bookmark modal save button must become enabled'}).toBe(true); + + const clicked = await win.runInRenderer(` + const buttons = Array.from(document.querySelectorAll( + '.GenericModal button, .GenericModal .GenericModal__button', + )); + const save = buttons.find((button) => { + const label = (button.textContent || '').trim().toLowerCase(); + return label.includes('add bookmark') + || (label.includes('save') && !label.includes('cancel')); + }); + if (!save || (save instanceof HTMLButtonElement && save.disabled)) { + return false; + } + save.click(); + return true; + `, true); + expect(clicked, 'Bookmark modal save button must be clicked').toBe(true); + await win.waitForSelector('[data-testid="linkInput"]', {state: 'detached', timeout: 15_000}); +} diff --git a/e2e/helpers/downloads.ts b/e2e/helpers/downloads.ts new file mode 100644 index 00000000000..e6d4b5a6415 --- /dev/null +++ b/e2e/helpers/downloads.ts @@ -0,0 +1,272 @@ +// 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 type {Socket} from 'net'; +import * as path from 'path'; + +import {expect} from '@playwright/test'; +import type {ElectronApplication} from 'playwright'; + +import {waitForAppReady} from './appReadiness'; +import {electronBinaryPath, appDir, emptyConfig} from './config'; +import {closeElectronAppFast} from './electronApp'; + +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 connections = new Set(); + + 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 + + + `); + }); + + server.on('connection', (socket) => { + connections.add(socket); + socket.on('close', () => connections.delete(socket)); + }); + + 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'); + } + + return { + server, + url: `http://127.0.0.1:${address.port}`, + close: () => new Promise((resolve, reject) => { + for (const socket of connections) { + socket.destroy(); + } + 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 existing = (global as any).__e2eDownloadPopup; + if (existing && !existing.isDestroyed?.()) { + existing.close(); + } + + 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 closeElectronAppFast(app, userDataDir).catch(() => {}); + + const removeDirWithRetry = async (dir: string) => { + if (!fs.existsSync(dir)) { + 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(dir, {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 directory: ${dir}`); + }; + + await removeDirWithRetry(downloadLocation); + await removeDirWithRetry(userDataDir); +} diff --git a/e2e/helpers/mattermostShell.ts b/e2e/helpers/mattermostShell.ts new file mode 100644 index 00000000000..e0dcf0d1d1f --- /dev/null +++ b/e2e/helpers/mattermostShell.ts @@ -0,0 +1,349 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; + +import type {ServerView} from './serverView'; + +export const POST_TEXTBOX_CANDIDATES = [ + '[data-slate-editor="true"]', + '#post_textbox[contenteditable="true"]', + '[data-testid="post_textbox"][contenteditable="true"]', + '#post_textbox', + '[data-testid="post_textbox"]', + '.post-create__input [contenteditable="true"]', + '.post-create__input [role="textbox"]', + '.AdvancedTextEditor [contenteditable="true"]', + '[role="textbox"][contenteditable="true"]', + 'textarea#post_textbox', +] as const; + +export const POST_TEXTBOX_SELECTOR = POST_TEXTBOX_CANDIDATES.join(', '); + +const POST_TEXTBOX_CANDIDATES_JSON = JSON.stringify(POST_TEXTBOX_CANDIDATES); + +/** + * Wait until the Mattermost webapp shell is interactive in a server view. + */ +export async function waitForMattermostShell( + win: ServerView, + options?: {channelItem?: string; timeout?: number}, +) { + const channelItem = options?.channelItem ?? '#sidebarItem_town-square'; + const timeout = options?.timeout ?? 60_000; + + await expect.poll(async () => { + try { + await win.waitForSelector(channelItem, {timeout: 2_000}); + return true; + } catch { + return false; + } + }, {timeout, message: `Mattermost shell must expose ${channelItem}`}).toBe(true); +} + +/** + * Reload the server view when the channel shell failed to mount (blank hex background). + */ +export async function recoverServerViewIfNeeded( + win: ServerView, + options?: {channelItem?: string}, +) { + const channelItem = options?.channelItem ?? '#sidebarItem_town-square'; + const healthy = await win.runInRenderer(` + return Boolean( + document.querySelector('#channelHeaderTitle') + && document.querySelector(${JSON.stringify(channelItem)}), + ); + `).catch(() => false); + + if (healthy) { + return; + } + + await win.runInRenderer('window.location.reload(); return true;', true); + await waitForMattermostShell(win, {channelItem}); +} + +/** Wait until the channel post list finishes its initial load. */ +export async function waitForChannelPostListLoaded( + win: ServerView, + options?: {timeout?: number}, +): Promise { + const timeout = options?.timeout ?? 15_000; + await expect.poll( + async () => win.evaluate(() => !document.querySelector( + '.post-list__loading, .post-list__dynamic-loading, .loading-screen', + )), + {timeout, message: 'Channel post list must finish loading'}, + ).toBe(true); +} + +/** Read the current post textbox contents (textarea value or contenteditable text). */ +export async function getPostTextboxValue(win: ServerView): Promise { + return win.runInRenderer(` + const isVisible = (element) => { + if (!element || !element.isConnected) { + return false; + } + const style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const candidates = ${POST_TEXTBOX_CANDIDATES_JSON}.map((selector) => document.querySelector(selector)).filter(Boolean); + + for (const candidate of candidates) { + if (!isVisible(candidate)) { + continue; + } + const root = candidate.matches('[contenteditable="true"], textarea, input') + ? candidate + : candidate.querySelector('[contenteditable="true"], textarea, input'); + if (!root || !isVisible(root)) { + continue; + } + if (root instanceof HTMLTextAreaElement || root instanceof HTMLInputElement) { + return root.value ?? ''; + } + return root.innerText || root.textContent || ''; + } + return ''; + `, true) ?? ''; +} + +/** Press a keyboard shortcut on the post textbox. */ +export async function pressPostTextboxKey(win: ServerView, key: string): Promise { + await win.waitForSelector(POST_TEXTBOX_SELECTOR, {timeout: 10_000}); + await win.press(POST_TEXTBOX_SELECTOR, key); +} + +/** + * Type into the post textbox, preferring DOM insertion so Slate keeps text nodes. + */ +export async function typeIntoPostTextbox(win: ServerView, text: string): Promise { + await win.waitForSelector(POST_TEXTBOX_SELECTOR, {timeout: 10_000}); + await win.click(POST_TEXTBOX_SELECTOR); + + const inserted = await win.runInRenderer(` + const value = ${JSON.stringify(text)}; + + const isVisible = (element) => { + if (!element || !element.isConnected) { + return false; + } + const style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const candidates = ${POST_TEXTBOX_CANDIDATES_JSON}.map((selector) => document.querySelector(selector)).filter(Boolean); + + let root = null; + for (const candidate of candidates) { + if (!isVisible(candidate)) { + continue; + } + if (candidate.matches('[contenteditable="true"], textarea, input')) { + root = candidate; + break; + } + const nested = candidate.querySelector('[contenteditable="true"], textarea, input'); + if (nested && isVisible(nested)) { + root = nested; + break; + } + } + + if (!root) { + return false; + } + + root.focus?.(); + root.setAttribute('spellcheck', 'true'); + + if (root instanceof HTMLTextAreaElement || root instanceof HTMLInputElement) { + const descriptor = Object.getOwnPropertyDescriptor(root.constructor.prototype, 'value'); + descriptor?.set?.call(root, value); + root.dispatchEvent(new Event('input', {bubbles: true})); + root.dispatchEvent(new Event('change', {bubbles: true})); + return root.value.includes(value.slice(0, 8)); + } + + const selection = window.getSelection(); + selection?.removeAllRanges(); + document.execCommand('selectAll', false); + document.execCommand('delete', false); + const insertedText = document.execCommand('insertText', false, value); + root.dispatchEvent(new InputEvent('input', {bubbles: true, data: value, inputType: 'insertText'})); + const content = root.innerText || root.textContent || ''; + return insertedText && content.includes(value.slice(0, 8)); + `, true); + + if (!inserted) { + const mod = process.platform === 'darwin' ? 'Meta' : 'Control'; + await win.keyboard.press(`${mod}+A`); + await win.keyboard.press('Backspace'); + await win.keyboard.type(text); + } +} + +/** + * Select a word in the post textbox and return viewport coordinates for it. + * Native spell-check menus require the right-click to land on misspelled text. + */ +export async function getPostTextboxWordPoint( + win: ServerView, + word: string, +): Promise<{x: number; y: number} | null> { + return win.runInRenderer(` + const target = ${JSON.stringify(word)}; + + const isVisible = (element) => { + if (!element || !element.isConnected) { + return false; + } + const style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const resolveEditor = () => { + const candidates = ${POST_TEXTBOX_CANDIDATES_JSON}.map((selector) => document.querySelector(selector)).filter(Boolean); + + for (const candidate of candidates) { + if (!isVisible(candidate)) { + continue; + } + if (candidate.matches('[contenteditable="true"], textarea, input')) { + return candidate; + } + const nested = candidate.querySelector('[contenteditable="true"], textarea, input'); + if (nested && isVisible(nested)) { + return nested; + } + } + return null; + }; + + const getTextareaWordPoint = (textarea, needle) => { + const text = textarea.value || ''; + const index = text.indexOf(needle); + if (index < 0) { + return null; + } + + textarea.focus(); + textarea.setSelectionRange(index, index + needle.length); + + const mirror = document.createElement('div'); + const properties = [ + 'direction', 'boxSizing', 'width', 'height', 'overflowX', 'overflowY', + 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', + 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', + 'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', + 'fontSizeAdjust', 'lineHeight', 'fontFamily', 'textAlign', 'textTransform', + 'textIndent', 'textDecoration', 'letterSpacing', 'wordSpacing', 'whiteSpace', + ]; + const computed = window.getComputedStyle(textarea); + mirror.style.position = 'absolute'; + mirror.style.visibility = 'hidden'; + mirror.style.top = '0'; + mirror.style.left = '0'; + mirror.style.whiteSpace = 'pre-wrap'; + mirror.style.wordWrap = 'break-word'; + for (const property of properties) { + mirror.style[property] = computed[property]; + } + mirror.style.width = computed.width; + mirror.textContent = text.slice(0, index); + const marker = document.createElement('span'); + marker.textContent = text.slice(index, index + needle.length) || '.'; + mirror.appendChild(marker); + document.body.appendChild(mirror); + const mirrorRect = mirror.getBoundingClientRect(); + const markerRect = marker.getBoundingClientRect(); + const textareaRect = textarea.getBoundingClientRect(); + document.body.removeChild(mirror); + + // markerRect is relative to the mirror (anchored at 0,0 in body coords), + // so (markerRect - mirrorRect) gives the offset inside the mirror. Add that + // to the textarea's viewport position and subtract scroll for the final point. + return { + x: Math.round( + textareaRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft + (markerRect.width / 2), + ), + y: Math.round( + textareaRect.top + (markerRect.top - mirrorRect.top) - textarea.scrollTop + (markerRect.height / 2), + ), + }; + }; + + const findRangeInRoot = (root, needle) => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + const text = node.textContent || ''; + const index = text.indexOf(needle); + if (index >= 0) { + const range = document.createRange(); + range.setStart(node, index); + range.setEnd(node, index + needle.length); + const rect = range.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + return {range, rect}; + } + } + node = walker.nextNode(); + } + return null; + }; + + const root = resolveEditor(); + if (!root) { + return null; + } + + root.setAttribute('spellcheck', 'true'); + + if (root instanceof HTMLTextAreaElement || root instanceof HTMLInputElement) { + return getTextareaWordPoint(root, target); + } + + const match = findRangeInRoot(root, target); + if (!match) { + const fullText = root.innerText || root.textContent || ''; + const index = fullText.indexOf(target); + if (index < 0) { + return null; + } + + const rect = root.getBoundingClientRect(); + const ratio = (index + (target.length / 2)) / Math.max(fullText.length, 1); + root.focus?.(); + return { + x: Math.round(rect.left + Math.min(rect.width * ratio, Math.max(rect.width - 8, 8))), + y: Math.round(rect.top + Math.max(rect.height * 0.7, 20)), + }; + } + + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(match.range); + root.focus?.(); + + return { + x: Math.round(match.rect.left + (match.rect.width / 2)), + y: Math.round(match.rect.top + (match.rect.height / 2)), + }; + `, true); +} diff --git a/e2e/helpers/server_api/channel.ts b/e2e/helpers/server_api/channel.ts new file mode 100644 index 00000000000..f8224f2968f --- /dev/null +++ b/e2e/helpers/server_api/channel.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {apiLogin, apiRequest, ApiRequestError} from './client'; +import {getTestServerCredentials} from './credentials'; +import {apiGetTeamsForUser} from './team'; + +type Team = { + id: string; + name: string; +}; + +type Channel = { + id: string; + name: string; + team_id: string; +}; + +export type ResolvedChannel = { + id: string; + teamId: string; + name: string; + url: string; +}; + +export async function apiGetChannelByName( + baseUrl: string, + token: string, + teamId: string, + channelName: string, +): Promise { + return apiRequest(baseUrl, token, `/api/v4/teams/${teamId}/channels/name/${channelName}`); +} + +export function buildChannelUrl(baseUrl: string, teamName: string, channelName: string): string { + return `${baseUrl}/${teamName}/channels/${channelName}`; +} + +export async function resolveChannelByName( + channelName: string, + credentials = getTestServerCredentials(), +): Promise { + const token = await apiLogin(credentials.baseUrl, credentials.username, credentials.password); + const teams = await apiGetTeamsForUser(credentials.baseUrl, token) as Team[]; + + for (const team of teams) { + try { + const channel = await apiGetChannelByName(credentials.baseUrl, token, team.id, channelName); + return { + id: channel.id, + teamId: channel.team_id, + name: channel.name, + url: buildChannelUrl(credentials.baseUrl, team.name, channel.name), + }; + } catch (error) { + if (!(error instanceof ApiRequestError) || error.status !== 404) { + throw error; + } + } + } + + throw new Error(`Channel "${channelName}" not found on any team for the test user`); +} diff --git a/e2e/helpers/server_api/client.ts b/e2e/helpers/server_api/client.ts new file mode 100644 index 00000000000..c9ac0303aad --- /dev/null +++ b/e2e/helpers/server_api/client.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export class ApiRequestError extends Error { + readonly status: number; + + constructor(method: string, path: string, status: number, body: string) { + super(`${method} ${path} failed: ${status} ${body}`); + this.name = 'ApiRequestError'; + this.status = status; + } +} + +const DEFAULT_API_TIMEOUT_MS = 30_000; + +function normalizeHeaders(headers?: HeadersInit): Record { + if (!headers) { + return {}; + } + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } + if (Array.isArray(headers)) { + return Object.fromEntries(headers); + } + return {...headers}; +} + +async function fetchWithTimeout( + url: string, + init: RequestInit = {}, + timeoutMs = DEFAULT_API_TIMEOUT_MS, +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + if (init.signal) { + if (init.signal.aborted) { + clearTimeout(timeoutId); + throw init.signal.reason ?? new DOMException('The operation was aborted.', 'AbortError'); + } + init.signal.addEventListener('abort', () => controller.abort(), {once: true}); + } + + try { + return await fetch(url, {...init, signal: controller.signal}); + } catch (error) { + if (controller.signal.aborted && !init.signal?.aborted) { + throw new Error(`Request timed out after ${timeoutMs}ms: ${url}`); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +export async function apiLogin(baseUrl: string, loginId: string, password: string): Promise { + const path = '/api/v4/users/login'; + let response: Response; + try { + response = await fetchWithTimeout(`${baseUrl}${path}`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({login_id: loginId, password}), + }); + } catch (error) { + if (error instanceof Error && error.message.startsWith('Request timed out after')) { + throw new Error(`POST ${path} timed out after ${DEFAULT_API_TIMEOUT_MS}ms`); + } + throw error; + } + if (!response.ok) { + throw new ApiRequestError('POST', path, response.status, await response.text()); + } + + const headerToken = response.headers.get('Token') ?? response.headers.get('token'); + if (headerToken) { + return headerToken; + } + + const body = await response.json() as {token?: string}; + if (body.token) { + return body.token; + } + + throw new Error('POST /api/v4/users/login did not return a session token'); +} + +export async function apiRequest( + baseUrl: string, + token: string, + path: string, + init: RequestInit = {}, +): Promise { + let response: Response; + try { + response = await fetchWithTimeout(`${baseUrl}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...normalizeHeaders(init.headers), + }, + }); + } catch (error) { + if (error instanceof Error && error.message.startsWith('Request timed out after')) { + throw new ApiRequestError(init.method ?? 'GET', path, 408, `Request timed out after ${DEFAULT_API_TIMEOUT_MS}ms`); + } + throw error; + } + if (!response.ok) { + throw new ApiRequestError(init.method ?? 'GET', path, response.status, await response.text()); + } + + return response.json() as Promise; +} diff --git a/e2e/helpers/server_api/credentials.ts b/e2e/helpers/server_api/credentials.ts new file mode 100644 index 00000000000..1dd5f9ef506 --- /dev/null +++ b/e2e/helpers/server_api/credentials.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {mattermostURL} from '../config'; + +export type TestServerCredentials = { + baseUrl: string; + username: string; + password: string; +}; + +export function getTestServerCredentials(): TestServerCredentials { + const username = process.env.MM_TEST_USER_NAME; + const password = process.env.MM_TEST_PASSWORD; + const baseUrl = (process.env.MM_TEST_SERVER_URL ?? mattermostURL).replace(/\/$/, ''); + + if (!username || !password) { + throw new Error('MM_TEST_USER_NAME and MM_TEST_PASSWORD must be set'); + } + + return {baseUrl, username, password}; +} diff --git a/e2e/helpers/server_api/team.ts b/e2e/helpers/server_api/team.ts new file mode 100644 index 00000000000..ae92d7d14b7 --- /dev/null +++ b/e2e/helpers/server_api/team.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {apiLogin, apiRequest} from './client'; + +type TeamPayload = { + name: string; + display_name: string; + type: string; +}; + +type CreatedTeam = TeamPayload & {id: string}; + +export function generateRandomTeam(type = 'O', prefix = 'e2e'): TeamPayload { + const suffix = Math.floor(Math.random() * 1e9).toString(36); + + return { + name: `${prefix}-${suffix}`, + display_name: `E2E ${suffix}`, + type, + }; +} + +export async function apiGetTeamsForUser(baseUrl: string, token: string, userId = 'me'): Promise { + return apiRequest(baseUrl, token, `/api/v4/users/${userId}/teams`); +} + +export async function apiCreateTeam( + baseUrl: string, + token: string, + team: TeamPayload = generateRandomTeam(), +): Promise { + return apiRequest(baseUrl, token, '/api/v4/teams', { + method: 'POST', + body: JSON.stringify(team), + }); +} + +/** + * Delete a team created for a test, so the shared test server doesn't accumulate them. + * Permanent deletion requires ServiceSettings.EnableAPITeamDeletion on the server; if + * that's not enabled, falls back to a soft delete (archive), which still removes it + * from the user's active team list. + */ +export async function apiDeleteTeam(baseUrl: string, token: string, teamId: string): Promise { + try { + await apiRequest(baseUrl, token, `/api/v4/teams/${teamId}?permanent=true`, {method: 'DELETE'}); + } catch { + await apiRequest(baseUrl, token, `/api/v4/teams/${teamId}`, {method: 'DELETE'}); + } +} + +export async function ensureUserHasMultipleTeams( + baseUrl: string, + loginId: string, + password: string, +): Promise<{count: number; created: boolean; createdTeamId?: string}> { + const token = await apiLogin(baseUrl, loginId, password); + const existing = await apiGetTeamsForUser(baseUrl, token); + if (existing.length >= 2) { + return {count: existing.length, created: false}; + } + + const createdTeam = await apiCreateTeam(baseUrl, token); + const teams = await apiGetTeamsForUser(baseUrl, token); + return {count: teams.length, created: true, createdTeamId: createdTeam.id}; +} diff --git a/e2e/helpers/team.ts b/e2e/helpers/team.ts new file mode 100644 index 00000000000..2785e3c17fb --- /dev/null +++ b/e2e/helpers/team.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +import {apiLogin} from './server_api/client'; +import {getTestServerCredentials} from './server_api/credentials'; +import {apiDeleteTeam, ensureUserHasMultipleTeams} from './server_api/team'; +import type {ServerView} from './serverView'; + +export type EnsureMultipleTeamsResult = { + count: number; + + /** + * Deletes the team this call created, if any (no-op otherwise). The shared test + * server would otherwise accumulate an "e2e-" team every run against an + * account with fewer than 2 teams. Best-effort: logs rather than throws, so a + * cleanup failure doesn't mask the actual test's pass/fail result. + */ + cleanup: () => Promise; +}; + +/** + * Ensure the logged-in user belongs to at least 2 teams. + * + * Some tests assert on the team sidebar (`#teamSidebarWrapper`), which the + * webapp only renders when the user is in 2+ teams. Uses the Mattermost REST + * API (same pattern as mattermost-mobile detox server_api). + */ +export async function ensureMultipleTeams( + app: ElectronApplication, + win: ServerView, + webContentsId: number, +): Promise { + const {baseUrl, username, password} = getTestServerCredentials(); + + const result = await ensureUserHasMultipleTeams(baseUrl, username, password); + + if (result.count < 2) { + throw new Error(`Expected at least 2 teams after ensureMultipleTeams, got ${result.count}`); + } + + if (result.created) { + await app.evaluate(async ({webContents}, id) => { + const wc = webContents.fromId(id); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${id} is not available`); + } + await wc.executeJavaScript('window.location.reload()', true); + }, webContentsId); + } + + await win.waitForSelector('#sidebarItem_town-square', {timeout: 30_000}); + await win.waitForSelector('#teamSidebarWrapper', {state: 'visible', timeout: 30_000}); + + const createdTeamId = result.createdTeamId; + const cleanup = async (): Promise => { + if (!createdTeamId) { + return; + } + try { + const token = await apiLogin(baseUrl, username, password); + await apiDeleteTeam(baseUrl, token, createdTeamId); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `ensureMultipleTeams: failed to delete team ${createdTeamId} created for this test run — ` + + 'it will remain on the shared test server.', + error, + ); + } + }; + + return {count: result.count, cleanup}; +} diff --git a/e2e/specs/deep_linking/deeplink.test.ts b/e2e/specs/deep_linking/deeplink.test.ts index 5b0a2f61429..cb3af6577ae 100644 --- a/e2e/specs/deep_linking/deeplink.test.ts +++ b/e2e/specs/deep_linking/deeplink.test.ts @@ -86,18 +86,11 @@ test.describe('application', () => { return resolvedServerMap[serverName]?.length ?? 0; }, {timeout: 15_000}).toBeGreaterThanOrEqual(1); - // Poll the server view's URL directly via webContents.fromId() instead - // of navigating contentView.children. On newer Electron versions the - // WebContentsView tree layout differs between platforms, but - // webContents.fromId() works universally. - // Re-resolve the serverMap on each poll iteration in case the webContentsId - // changes (e.g., a new view was created by openLinkInPrimaryTab). await expect.poll(async () => { const freshMap = await buildServerMap(app!); const freshView = freshMap[serverName]?.[0]?.win; return freshView?.url() ?? ''; }, {timeout: 30_000, message: 'deep-linked webContents did not navigate to the expected URL'}).toContain('github.com/test/url'); - const dropdownButtonText = await mainWindow.innerText('.ServerDropdownButton'); - expect(dropdownButtonText).toBe('github'); + await expect(mainWindow.locator('.ServerDropdownButton')).toHaveText('github', {timeout: 15_000}); }); }); diff --git a/e2e/specs/downloads/download_cancel.test.ts b/e2e/specs/downloads/download_cancel.test.ts new file mode 100644 index 00000000000..a1c5afa8a41 --- /dev/null +++ b/e2e/specs/downloads/download_cancel.test.ts @@ -0,0 +1,59 @@ +// 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 Promise.allSettled([ + closeDownloadTestApp(app, userDataDir, downloadLocation), + 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..e0931ad6214 --- /dev/null +++ b/e2e/specs/downloads/download_clear_all.test.ts @@ -0,0 +1,74 @@ +// 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}); + await expect.poll( + () => downloadsWindow.locator('.DownloadsDropdown__File').count(), + {timeout: 10_000}, + ).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..10ba9353328 --- /dev/null +++ b/e2e/specs/downloads/download_open.test.ts @@ -0,0 +1,63 @@ +// 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}) => { + const refs = (global as any).__e2eTestRefs; + refs.__e2eOpenedPaths = [] as string[]; + shell.openPath = async (targetPath: string) => { + refs.__e2eOpenedPaths.push(targetPath); + return ''; + }; + }); + + const {downloadsWindow} = await openDownloadsDropdown(app); + await downloadsWindow.click('.DownloadsDropdown__File'); + + await expect.poll(async () => { + 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(() => { + const refs = (global as any).__e2eTestRefs; + return (refs.__e2eOpenedPaths as string[])[0]; + }); + expect(openedPath).toContain(filename); + } finally { + await Promise.allSettled([ + closeDownloadTestApp(app, userDataDir, downloadLocation), + 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..c53766dfab6 --- /dev/null +++ b/e2e/specs/downloads/video_download.test.ts @@ -0,0 +1,97 @@ +// 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 { + closeDownloadTestApp, + launchAppWithDownloadsDir, + readDownloadsState, + triggerDownloadFromPopup, +} from '../../helpers/downloads'; + +// ── 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" + +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, 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 video download server'); + } + + return { + url: `http://127.0.0.1:${address.port}`, + close: () => new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} + +test( + 'MM-T1538 Download a video', + {tag: ['@P2', '@all']}, + async ({}, testInfo) => { + const filename = 'sample-video.mp4'; + + 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 {url, close: closeVideoServer} = await startVideoServer(filename, videoBody); + + const userDataDir = path.join(testInfo.outputDir, 'userdata'); + const downloadLocation = path.join(testInfo.outputDir, 'Downloads'); + const app = await launchAppWithDownloadsDir(userDataDir, downloadLocation); + const savedPath = path.join(downloadLocation, filename); + + try { + await triggerDownloadFromPopup(app, url); + + await expect.poll(() => fs.existsSync(savedPath), {timeout: 15_000}).toBe(true); + await expect.poll(() => fs.readFileSync(savedPath).equals(videoBody), {timeout: 15_000}).toBe(true); + await expect.poll( + () => readDownloadsState(userDataDir)[filename]?.state, + {timeout: 15_000}, + ).toBe('completed'); + } finally { + await closeDownloadTestApp(app, userDataDir, downloadLocation); + await closeVideoServer(); + } + }, +); diff --git a/e2e/specs/mattermost/alt_enter.test.ts b/e2e/specs/mattermost/alt_enter.test.ts new file mode 100644 index 00000000000..ecce2dd2fb4 --- /dev/null +++ b/e2e/specs/mattermost/alt_enter.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoMattermostConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import { + getPostTextboxValue, + pressPostTextboxKey, + POST_TEXTBOX_SELECTOR, + typeIntoPostTextbox, + waitForChannelPostListLoaded, + waitForMattermostShell, +} from '../../helpers/mattermostShell'; + +// ── MM-T2023: ALT+ENTER ─────────────────────────────────────────────── +// Alt/Option + Enter inserts a newline in the post textbox without +// submitting the message. This is webapp textbox behaviour (implemented +// in the textbox component via MM-14177, merged in v5.24.0). + +test.describe('mattermost/alt_enter', () => { + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(120_000); + + test('MM-T2023 ALT+ENTER inserts a newline without sending the message', + {tag: ['@P2', '@all']}, + async ({serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + return; + } + + const firstServer = serverMap[demoMattermostConfig.servers[0].name]?.[0]?.win; + expect(firstServer, 'Server view must exist').toBeTruthy(); + + await loginToMattermost(firstServer!); + await waitForMattermostShell(firstServer!, {channelItem: '#sidebarItem_off-topic'}); + await firstServer!.click('#sidebarItem_off-topic'); + await firstServer!.waitForSelector(POST_TEXTBOX_SELECTOR, {timeout: 15_000}); + await waitForChannelPostListLoaded(firstServer!); + + const postsBefore = await firstServer!.evaluate(() => + document.querySelectorAll('.post-message__text').length, + ); + + await typeIntoPostTextbox(firstServer!, 'Line one'); + await pressPostTextboxKey(firstServer!, 'Alt+Enter'); + await firstServer!.keyboard.type('Line two'); + + const textboxValue = await getPostTextboxValue(firstServer!); + expect(textboxValue, 'Textbox must contain both lines after Alt+Enter').toContain('Line one'); + expect(textboxValue, 'Textbox must contain second line').toContain('Line two'); + expect(textboxValue, 'Textbox must have a newline between lines').toMatch(/Line one\nLine two/); + + const postsAfter = await firstServer!.evaluate(() => + document.querySelectorAll('.post-message__text').length, + ); + expect(postsAfter, 'Alt+Enter must NOT send the message').toBe(postsBefore); + + const sendButtonClicked = await firstServer!.evaluate(() => { + const sendButton = document.querySelector( + '#channelHeaderSubmitButton, button[aria-label*="Send" i], [data-testid="SendMessageButton"]', + ) as HTMLButtonElement | null; + if (!sendButton) { + return false; + } + sendButton.click(); + return true; + }); + expect(sendButtonClicked, 'Send button must be present before posting').toBe(true); + + await expect.poll( + () => firstServer!.evaluate(() => + document.querySelectorAll('.post-message__text').length, + ), + {timeout: 10_000, message: 'Send button must post the composed message'}, + ).toBeGreaterThan(postsBefore); + + const lastPostText = await firstServer!.evaluate(() => { + const posts = document.querySelectorAll('.post-message__text'); + const lastPost = posts[posts.length - 1]; + return lastPost?.textContent ?? ''; + }); + expect(lastPostText, 'Sent message must contain both lines').toContain('Line one'); + expect(lastPostText, 'Sent message must contain second line').toContain('Line two'); + }, + ); +}); diff --git a/e2e/specs/mattermost/bookmarks.test.ts b/e2e/specs/mattermost/bookmarks.test.ts new file mode 100644 index 00000000000..47e8d274a81 --- /dev/null +++ b/e2e/specs/mattermost/bookmarks.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +import {test, expect, type ServerMap} from '../../fixtures/index'; +import {openChannelHeaderMenu, enableBookmarksBar, submitBookmarkModal, waitForBookmarkInBar, clickBookmarkInBar, deleteAllBookmarksInBar} from '../../helpers/channelMenu'; +import {demoMattermostConfig, type AppConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import {waitForMattermostShell, recoverServerViewIfNeeded} from '../../helpers/mattermostShell'; +import {closeOverlayWindowsIfOpen} from '../../helpers/overlayWindows'; +import {prepareMattermostServerView} from '../../helpers/prepareServerView'; + +const EXTERNAL_BOOKMARK_URL = 'https://mattermost.com/'; + +function openExternalUrlMatchesBookmark(openedUrl: string, bookmarkUrl: string): boolean { + try { + const opened = new URL(openedUrl); + const bookmark = new URL(bookmarkUrl); + return opened.origin === bookmark.origin && opened.pathname === bookmark.pathname; + } catch { + return false; + } +} + +const bookmarksConfig: AppConfig = { + ...demoMattermostConfig, + servers: demoMattermostConfig.servers.filter((server) => !server.url.includes('github.com')), +}; + +if (bookmarksConfig.servers.length === 0) { + throw new Error('bookmarksConfig requires at least one non-github server'); +} + +async function loginToOffTopicChannel(serverMap: ServerMap, electronApp: ElectronApplication) { + const serverEntry = serverMap[bookmarksConfig.servers[0].name]?.[0]; + const firstServer = serverEntry?.win; + expect(firstServer, 'Mattermost server view should exist').toBeTruthy(); + await prepareMattermostServerView(electronApp, serverEntry!.webContentsId); + await loginToMattermost(firstServer!); + await waitForMattermostShell(firstServer!, {channelItem: '#sidebarItem_off-topic'}); + await firstServer!.click('#sidebarItem_off-topic'); + await firstServer!.waitForSelector('#channelHeaderTitle', {timeout: 15_000}); + await recoverServerViewIfNeeded(firstServer!, {channelItem: '#sidebarItem_off-topic'}); + return firstServer!; +} + +test.describe('mattermost/bookmarks', () => { + test.describe.configure({mode: 'serial'}); + test.use({appConfig: bookmarksConfig}); + test.setTimeout(120_000); + + test.beforeEach(async ({electronApp, serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + return; + } + + const serverEntry = serverMap[bookmarksConfig.servers[0].name]?.[0]; + if (!serverEntry) { + return; + } + + await closeOverlayWindowsIfOpen(electronApp); + await prepareMattermostServerView(electronApp, serverEntry.webContentsId); + }); + + // ── MM-T5600: Bookmarks Bar option in channel dropdown ────────────── + test('MM-T5600 Bookmarks Bar option IS shown in the channel drop-down menu on Enterprise and Professional licensed servers', + {tag: ['@P2', '@all']}, + async ({serverMap, electronApp}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + return; + } + + const firstServer = await loginToOffTopicChannel(serverMap, electronApp); + + await openChannelHeaderMenu(firstServer!); + + const bookmarksMenu = await firstServer!.$('[id^="channel-menu-"][id$="-bookmarks"]'); + if (!bookmarksMenu) { + test.skip(true, 'Bookmarks menu not available on this server license'); + return; + } + + await firstServer!.waitForSelector('[id^="channel-menu-"][id$="-bookmarks"]', {timeout: 5_000}); + + // Close the menu + await firstServer!.keyboard.press('Escape'); + }, + ); + + // ── MM-T5611: Open a bookmark URL/link ──────────────────────────── + test('MM-T5611 Open a bookmark URL/link (External and Internal links)', + {tag: ['@P2', '@darwin', '@win32']}, + async ({electronApp, serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + return; + } + + const firstServer = await loginToOffTopicChannel(serverMap, electronApp); + const serverEntry = serverMap[bookmarksConfig.servers[0].name]?.[0]; + + await enableBookmarksBar(firstServer!); + await deleteAllBookmarksInBar(firstServer!); + + // Intercept shell.openExternal — canonical pattern from external_links.test.ts + await electronApp.evaluate(({shell}) => { + (shell as any).__e2eOpenExternalCalls = []; + const originalOpenExternal = shell.openExternal.bind(shell); + (shell as any).__e2eOriginalOpenExternal = originalOpenExternal; + shell.openExternal = ((url: string) => { + (shell as any).__e2eOpenExternalCalls.push(url); + return Promise.resolve(); + }) as typeof shell.openExternal; + }); + + try { + // Open the channel dropdown → Bookmarks submenu → "Add a link". + // The submenu trigger and the "Add a link" item have stable ids + // `channel-menu--bookmarks` and `…-bookmarks-link` + // (see webapp/channels/src/components/channel_header_menu/menu_items/channel_bookmarks_submenu.tsx). + // The submenu opens on hover or trigger-click; dispatch mouseenter + // via the renderer since ServerLocator has no hover helper. + await openChannelHeaderMenu(firstServer!); + const bookmarksMenu = await firstServer!.$('[id^="channel-menu-"][id$="-bookmarks"]'); + if (!bookmarksMenu) { + test.skip(true, 'Bookmarks menu not available on this server license'); + return; + } + await firstServer!.waitForSelector('[id^="channel-menu-"][id$="-bookmarks"]', {timeout: 5_000}); + await firstServer!.evaluate(() => { + const trigger = document.querySelector('[id^="channel-menu-"][id$="-bookmarks"]') as HTMLElement | null; + trigger?.dispatchEvent(new MouseEvent('mouseenter', {bubbles: true})); + trigger?.dispatchEvent(new MouseEvent('mouseover', {bubbles: true})); + }); + await firstServer!.waitForSelector('[id^="channel-menu-"][id$="-bookmarks-link"]', {timeout: 5_000}); + await firstServer!.click('[id^="channel-menu-"][id$="-bookmarks-link"]'); + await firstServer!.keyboard.press('Escape'); + + // Bookmark create modal — fields have stable data-testids + // (see webapp/channels/src/components/channel_bookmarks/{channel_bookmarks_create_modal,create_modal_name_input}.tsx). + await firstServer!.waitForSelector('[data-testid="linkInput"]', {timeout: 5_000}); + await firstServer!.fill('[data-testid="linkInput"]', EXTERNAL_BOOKMARK_URL); + await firstServer!.fill('[data-testid="titleInput"]', 'E2E External Bookmark'); + + await submitBookmarkModal(firstServer!); + await prepareMattermostServerView(electronApp, serverEntry!.webContentsId); + await waitForBookmarkInBar(firstServer!, 'mattermost.com'); + + await clickBookmarkInBar(firstServer!, 'mattermost.com'); + + // Mattermost 11.7+ appends in-product UTM params to bookmark hrefs + // (utm_content=channel_bookmarks.item, uid, sid, etc.) before calling + // shell.openExternal — assert origin/path, not the raw saved URL. + await expect.poll(async () => { + const calls = await electronApp.evaluate( + ({shell}) => (shell as any).__e2eOpenExternalCalls ?? [], + ); + return calls.some((url: string) => openExternalUrlMatchesBookmark(url, EXTERNAL_BOOKMARK_URL)); + }, {timeout: 10_000}).toBe(true); + + // Verify no in-app server view navigated to the external URL. + // app.windows() only enumerates BrowserWindows — server panes are + // WebContentsView instances; query them via global.__e2eTestRefs. + const serverViewURLs = await electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + if (!refs?.ServerManager || !refs?.ViewManager || !refs?.WebContentsManager) { + return [] as string[]; + } + const urls: string[] = []; + for (const server of refs.ServerManager.getAllServers()) { + for (const view of refs.ViewManager.getViewsByServerId(server.id)) { + const wcv = refs.WebContentsManager.getView(view.id); + const wc = wcv?.webContents; + if (wc && !wc.isDestroyed()) { + urls.push(wc.getURL() ?? ''); + } + } + } + return urls; + }); + expect( + serverViewURLs.some((url) => openExternalUrlMatchesBookmark(url, EXTERNAL_BOOKMARK_URL)), + 'No in-app server view should have navigated to the external bookmark URL', + ).toBe(false); + } finally { + // Restore original shell.openExternal even if the test failed, + // otherwise later specs in the same Electron process see the stub. + await electronApp.evaluate(({shell}) => { + const original = (shell as any).__e2eOriginalOpenExternal; + if (original) { + shell.openExternal = original; + } + delete (shell as any).__e2eOpenExternalCalls; + delete (shell as any).__e2eOriginalOpenExternal; + }); + + // Cleanup: open the per-bookmark dot menu and click Delete. Both have + // stable element ids that don't depend on locale (see + // webapp/channels/src/components/channel_bookmarks/bookmark_dot_menu.tsx): + // trigger: id="channelBookmarksDotMenuButton-" + // delete item: id="channelBookmarksDelete" + await deleteAllBookmarksInBar(firstServer!); + } + }, + ); +}); diff --git a/e2e/specs/mattermost/context_menu.test.ts b/e2e/specs/mattermost/context_menu.test.ts new file mode 100644 index 00000000000..7fbc5958f7b --- /dev/null +++ b/e2e/specs/mattermost/context_menu.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {openSidebarChannelMenu, openTeamSidebarContextMenu, listenForNativeContextMenu, waitForNativeContextMenu} from '../../helpers/channelMenu'; +import {demoMattermostConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import {prepareMattermostServerView} from '../../helpers/prepareServerView'; +import type {ServerEntry} from '../../helpers/serverMap'; +import {ensureMultipleTeams} from '../../helpers/team'; +import type {ServerView} from '../../helpers/serverView'; + +// ── MM-T1307: Right-click a channel name / team name in LHS ──────────── +// Channel "Copy Link" lives in the webapp's sidebar channel-options menu, +// Channel menus use webapp .Menu components; team sidebar uses Chromium's +// native context menu since MM-57962 removed the webapp Copy Link menu. + +test.describe('mattermost/context_menu', () => { + test.describe.configure({mode: 'serial'}); + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(120_000); + + test.beforeAll(() => { + test.skip(!process.env.MM_TEST_SERVER_URL, 'MM_TEST_SERVER_URL required'); + }); + + let serverEntry: ServerEntry | undefined; + let firstServer: ServerView | undefined; + let cleanupCreatedTeam: (() => Promise) | undefined; + + test.afterAll(async () => { + await cleanupCreatedTeam?.(); + }); + + // serverMap is test-scoped; Playwright forbids it in beforeAll, so shared + // login runs in beforeEach (cheap once the session cookie is established). + test.beforeEach(async ({electronApp, serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + return; + } + + serverEntry = serverMap[demoMattermostConfig.servers[0].name]?.[0]; + firstServer = serverEntry?.win; + expect(firstServer, 'Server view must exist').toBeTruthy(); + + await prepareMattermostServerView(electronApp, serverEntry!.webContentsId); + await loginToMattermost(firstServer!); + await firstServer!.waitForSelector('#sidebarItem_town-square', {timeout: 30_000}); + }); + + test('MM-T1307 Right-click a channel name in LHS shows context menu', + {tag: ['@P2', '@all']}, + async ({electronApp}) => { + expect(serverEntry, 'Shared server entry must be initialized in beforeEach').toBeTruthy(); + expect(firstServer, 'Shared server view must be initialized in beforeEach').toBeTruthy(); + + await prepareMattermostServerView(electronApp, serverEntry!.webContentsId); + await openSidebarChannelMenu(firstServer!, '#sidebarItem_town-square'); + + const hasCopyLink = await firstServer!.evaluate(() => { + const items = document.querySelectorAll('.Menu .MenuItem, [role="menuitem"]'); + return Array.from(items).some( + (item) => (/^copy link$/i).test((item.textContent ?? '').trim()), + ); + }); + expect(hasCopyLink, '"Copy Link" must appear in channel context menu').toBe(true); + + await firstServer!.click('#channelHeaderTitle'); + }, + ); + + test('MM-T1307_2 Right-click a team name in LHS shows context menu', + {tag: ['@P2', '@all']}, + async ({electronApp}) => { + expect(serverEntry, 'Shared server entry must be initialized in beforeEach').toBeTruthy(); + expect(firstServer, 'Shared server view must be initialized in beforeEach').toBeTruthy(); + + await prepareMattermostServerView(electronApp, serverEntry!.webContentsId); + const {cleanup} = await ensureMultipleTeams(electronApp, firstServer!, serverEntry!.webContentsId); + cleanupCreatedTeam = cleanup; + await prepareMattermostServerView(electronApp, serverEntry!.webContentsId); + await firstServer!.waitForSelector('#teamSidebarWrapper, button[aria-label$=" team"]', {timeout: 15_000}); + + await listenForNativeContextMenu(electronApp, serverEntry!.webContentsId); + await openTeamSidebarContextMenu(firstServer!, electronApp, serverEntry!.webContentsId); + await waitForNativeContextMenu(electronApp); + + await firstServer!.click('#channelHeaderTitle'); + }, + ); +}); diff --git a/e2e/specs/mattermost/copy_link.test.ts b/e2e/specs/mattermost/copy_link.test.ts index 59c264de306..9a0fa78f2bc 100644 --- a/e2e/specs/mattermost/copy_link.test.ts +++ b/e2e/specs/mattermost/copy_link.test.ts @@ -2,96 +2,37 @@ // See LICENSE.txt for license information. import {test, expect} from '../../fixtures/index'; +import {clickCopyLinkInMenu, openSidebarChannelMenu} from '../../helpers/channelMenu'; import {demoMattermostConfig} from '../../helpers/config'; import {loginToMattermost} from '../../helpers/login'; +import {prepareMattermostServerView} from '../../helpers/prepareServerView'; test.describe('copylink', () => { test.use({appConfig: demoMattermostConfig}); test.skip(!process.env.MM_TEST_SERVER_URL, 'MM_TEST_SERVER_URL required'); - test.skip(process.platform === 'linux', 'Not supported on Linux'); - test('MM-T125 Copy Link can be used from channel LHS', {tag: ['@P2', '@all']}, async ({electronApp, serverMap}) => { - const firstServer = serverMap[demoMattermostConfig.servers[0].name]?.[0]?.win; - if (!firstServer) { + test('MM-T125 Copy Link can be used from channel LHS', {tag: ['@P2', '@darwin', '@win32']}, async ({electronApp, serverMap}) => { + const serverEntry = serverMap[demoMattermostConfig.servers[0].name]?.[0]; + if (!serverEntry?.win) { throw new Error('No server view available'); } + const firstServer = serverEntry.win; + await prepareMattermostServerView(electronApp, serverEntry.webContentsId); await loginToMattermost(firstServer); - // Clear clipboard to prevent pollution from other tests await electronApp.evaluate(({clipboard}) => { clipboard.writeText(''); }); - // "Copy Link" for a channel lives in the webapp's channel options ("⋮") menu, - // which is a normal DOM menu we can drive. It is NOT in the desktop app's native - // right-click context menu — that is a native Electron Menu, invisible to DOM - // queries, which is why right-clicking and waiting for the item never worked. await firstServer.waitForSelector('#sidebarItem_town-square', {timeout: 30_000}); - - // The channel options ("⋮") button is only rendered/shown while the row is - // hovered, so dispatch hover events on the row first. The ServerView click path - // uses a synthetic element.click(), which fires the handler even if the button - // is still visually hidden — so we only need the button present in the DOM. - await firstServer.evaluate(`(() => { - const el = document.querySelector('#sidebarItem_town-square'); - if (!el) { - return; - } - for (const type of ['pointerover', 'mouseover', 'mouseenter', 'pointermove', 'mousemove']) { - el.dispatchEvent(new MouseEvent(type, {bubbles: true, cancelable: true})); - } - })()`); - - // Open the channel options menu. Markup varies across webapp versions, so match - // by aria-label/class with fallbacks; wait for "attached" (not "visible") since - // the button can be hover-gated by CSS opacity. - const menuButtonSelector = [ - '#sidebarItem_town-square button[aria-label*="channel menu" i]', - '#sidebarItem_town-square button[aria-label*="channel options" i]', - '#sidebarItem_town-square button[aria-label*="options" i]', - '#sidebarItem_town-square button.SidebarMenu_menuButton', - '#sidebarItem_town-square .SidebarMenu button', - ].join(', '); - await firstServer.waitForSelector(menuButtonSelector, {state: 'attached', timeout: 15_000}); - await firstServer.click(menuButtonSelector); - - // Click "Copy Link" in the opened menu. The custom selector engine only supports - // a single trailing :has-text, so try each candidate (id / role+text / button+text, - // both capitalizations) separately until one resolves. - const copyLinkCandidates = [ - '#channelCopyLink', - '[role="menuitem"]:has-text("Copy Link")', - '[role="menuitem"]:has-text("Copy link")', - 'button:has-text("Copy Link")', - 'button:has-text("Copy link")', - 'a:has-text("Copy Link")', - 'a:has-text("Copy link")', - ]; - let copyLinkClicked = false; - const copyLinkDeadline = Date.now() + 15_000; - while (!copyLinkClicked && Date.now() < copyLinkDeadline) { - for (const selector of copyLinkCandidates) { - // ServerView.$ returns the locator only when at least one node matches, - // and null otherwise — so a non-null result means the item is present. - const candidate = await firstServer.$(selector); - if (candidate) { - await firstServer.click(selector); - copyLinkClicked = true; - break; - } - } - if (!copyLinkClicked) { - await new Promise((resolve) => setTimeout(resolve, 200)); - } - } - if (!copyLinkClicked) { - throw new Error('"Copy Link" item not found in the channel options menu'); - } - - const clipboardText = await electronApp.evaluate(({clipboard}) => { - return clipboard.readText(); - }); - expect(clipboardText).toContain('/channels/town-square'); + await prepareMattermostServerView(electronApp, serverEntry.webContentsId); + await openSidebarChannelMenu(firstServer, '#sidebarItem_town-square'); + await clickCopyLinkInMenu(firstServer); + + await expect.poll( + async () => electronApp.evaluate(({clipboard}) => clipboard.readText()), + {timeout: 10_000, message: 'Clipboard must contain the town-square channel link'}, + ).toContain('/channels/town-square'); }); }); diff --git a/e2e/specs/mattermost/custom_groups.test.ts b/e2e/specs/mattermost/custom_groups.test.ts new file mode 100644 index 00000000000..41b628521c6 --- /dev/null +++ b/e2e/specs/mattermost/custom_groups.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoMattermostConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; + +// ── MM-T5584: Viewing and Unarchiving Custom Groups ─────────────────── +// Custom Groups (User Groups) are an Enterprise feature. The desktop app +// hosts the webapp UI but the feature availability is gated by the server +// license. This test verifies the desktop correctly renders the User Groups +// view when the feature is available. + +test.describe('mattermost/custom_groups', () => { + test.describe.configure({mode: 'serial'}); + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(120_000); + + // NOTE: `serverMap` is a test-scoped fixture and Playwright forbids accessing + // test-scoped fixtures from `test.beforeAll`. Login runs in `beforeEach` so the + // fixture is requested at the correct scope; subsequent tests are cheap because + // the underlying session cookie is already established. + test.beforeEach(async ({serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + return; + } + + if (!process.env.MM_TEST_USER_NAME || !process.env.MM_TEST_PASSWORD) { + test.skip(true, 'MM_TEST_USER_NAME and MM_TEST_PASSWORD required'); + return; + } + + const firstServer = serverMap[demoMattermostConfig.servers[0].name]?.[0]?.win; + expect(firstServer, 'Mattermost server view should exist').toBeTruthy(); + + await loginToMattermost(firstServer!); + await firstServer!.waitForSelector('#sidebarItem_town-square', {timeout: 30_000}); + }); + + test('MM-T5584 Viewing and Unarchiving Custom Groups', + {tag: ['@P2', '@all']}, + async ({serverMap}) => { + const firstServer = serverMap[demoMattermostConfig.servers[0].name]?.[0]?.win; + expect(firstServer, 'Server view must exist').toBeTruthy(); + + // Check if User Groups are accessible via the product switcher + const productSwitcher = await firstServer!.$('#product_switcher, .product-switcher, [class*="product-switcher"]'); + if (!productSwitcher) { + test.skip(true, 'Product switcher not available — may require Enterprise license'); + return; + } + + // Open the product switcher + await productSwitcher.click(); + await firstServer!.waitForSelector('.product-switcher-menu, .Menu', {timeout: 5_000}); + + // Look for User Groups in the product switcher menu + const hasGroupsMenuItem = await firstServer!.evaluate(() => { + const items = document.querySelectorAll('.product-switcher-menu button, .Menu .MenuItem'); + return Array.from(items).some( + (item) => { + const text = (item.textContent ?? '').toLowerCase(); + return text.includes('user groups') || text.includes('groups'); + }, + ); + }); + + if (!hasGroupsMenuItem) { + // Close menu and skip + await firstServer!.click('#channelHeaderTitle'); + test.skip(true, 'User Groups not available in product switcher'); + return; + } + + // Click User Groups + const groupsClicked = await firstServer!.evaluate(() => { + const items = document.querySelectorAll('.product-switcher-menu button, .Menu .MenuItem'); + const groupsItem = Array.from(items).find( + (item) => { + const text = (item.textContent ?? '').toLowerCase(); + return text.includes('user groups') || text.includes('groups'); + }, + ); + if (groupsItem) { + (groupsItem as HTMLElement).click(); + return true; + } + return false; + }); + expect(groupsClicked, 'User Groups menu item must be clickable').toBe(true); + + // Scope assertions to the actual User Groups surface (modal or page). + // Webapp renders user groups inside `.user-groups-modal` / `#user-groups-modal` + // (with `_heading`/`_body`), so prefer those selectors over substring wildcards + // that can match unrelated sidebar/channel elements. + const groupsSurfaceSelector = [ + '.user-groups-modal', + '#user-groups-modal', + '#user-groups-modal_body', + '[id^="user-groups-modal"]', + '[class*="UserGroupsModal"]', + '[class*="user-groups-modal"]', + ].join(', '); + + await firstServer!.waitForSelector(groupsSurfaceSelector, {timeout: 10_000}); + + const viewHasStructure = await firstServer!.evaluate((selector) => { + const roots = document.querySelectorAll(selector); + for (const root of roots) { + if (root.querySelector('ul, ol, table, [role="list"], [role="table"], [role="grid"]')) { + return true; + } + } + return false; + }, groupsSurfaceSelector); + expect(viewHasStructure, 'User Groups view must have a list/table structure').toBe(true); + + // Return to channels + await firstServer!.click('#sidebarItem_town-square'); + await firstServer!.waitForSelector('#channelHeaderTitle', {timeout: 10_000}); + }, + ); +}); diff --git a/e2e/specs/mattermost/external_links.test.ts b/e2e/specs/mattermost/external_links.test.ts index 14b68dc507e..6330c874106 100644 --- a/e2e/specs/mattermost/external_links.test.ts +++ b/e2e/specs/mattermost/external_links.test.ts @@ -16,11 +16,7 @@ const externalLinksConfig: AppConfig = { test.describe('external_links', () => { test.use({appConfig: externalLinksConfig}); - test('MM-T_EL_1 clicking an external URL opens the system browser, not the app', {tag: ['@P2', '@all']}, async ({electronApp, serverMap}) => { - if (process.platform === 'linux') { - test.skip(true, 'Linux not supported'); - return; - } + test('MM-T_EL_1 clicking an external URL opens the system browser, not the app', {tag: ['@P2', '@darwin', '@win32']}, async ({electronApp, serverMap}) => { if (!process.env.MM_TEST_SERVER_URL) { test.skip(true, 'MM_TEST_SERVER_URL required'); return; @@ -81,11 +77,7 @@ test.describe('external_links', () => { }); }); - test('MM-T_EL_2 clicking an internal Mattermost channel link stays in the app', {tag: ['@P2', '@all']}, async ({electronApp, serverMap}) => { - if (process.platform === 'linux') { - test.skip(true, 'Linux not supported'); - return; - } + test('MM-T_EL_2 clicking an internal Mattermost channel link stays in the app', {tag: ['@P2', '@darwin', '@win32']}, async ({electronApp, serverMap}) => { if (!process.env.MM_TEST_SERVER_URL) { test.skip(true, 'MM_TEST_SERVER_URL required'); return; diff --git a/e2e/specs/mattermost/window_close.test.ts b/e2e/specs/mattermost/window_close.test.ts new file mode 100644 index 00000000000..26241a6b3f4 --- /dev/null +++ b/e2e/specs/mattermost/window_close.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoConfig} from '../../helpers/config'; +import {buildServerMap} from '../../helpers/serverMap'; + +test.describe('mattermost/window_close', () => { + test( + 'MM-67909 window.close() in a server view does not crash the app', + {tag: ['@P1', '@all']}, + async ({electronApp, mainWindow, serverMap}) => { + const serverName = demoConfig.servers[0].name; + const serverView = serverMap[serverName]?.[0]?.win; + expect(serverView).toBeDefined(); + + await serverView!.evaluate(() => { + window.close(); + }); + + expect(mainWindow).toBeDefined(); + await expect.poll( + () => mainWindow.evaluate(() => document.readyState === 'complete'), + {timeout: 10_000}, + ).toBe(true); + + const refreshedMap = await buildServerMap(electronApp); + expect(refreshedMap[serverName]?.length ?? 0).toBeGreaterThan(0); + }, + ); + + test( + 'MM-67909 app can be blurred and refocused after window.close() in a server view', + {tag: ['@P1', '@all']}, + async ({electronApp, mainWindow, serverMap}) => { + const serverName = demoConfig.servers[0].name; + const serverView = serverMap[serverName]?.[0]?.win; + expect(serverView).toBeDefined(); + + await serverView!.evaluate(() => { + window.close(); + }); + + expect(mainWindow).toBeDefined(); + const browserWindow = await electronApp.browserWindow(mainWindow); + await browserWindow.evaluate((win) => win.blur()); + await browserWindow.evaluate((win) => win.focus()); + + await expect.poll( + () => mainWindow.evaluate(() => document.readyState === 'complete'), + {timeout: 10_000}, + ).toBe(true); + + const refreshedMap = await buildServerMap(electronApp); + expect(refreshedMap[serverName]?.length ?? 0).toBeGreaterThan(0); + }, + ); +}); diff --git a/e2e/specs/notification_trigger/notification_click.test.ts b/e2e/specs/notification_trigger/notification_click.test.ts index 108ba4256cf..bb337721394 100644 --- a/e2e/specs/notification_trigger/notification_click.test.ts +++ b/e2e/specs/notification_trigger/notification_click.test.ts @@ -5,6 +5,7 @@ import {test, expect} from '../../fixtures/index'; import {demoMattermostConfig} from '../../helpers/config'; import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; import {loginToMattermost} from '../../helpers/login'; +import {resolveChannelByName} from '../../helpers/server_api/channel'; import {NOTIFICATION_CLICKED} from '../../../src/common/communication'; test.use({appConfig: demoMattermostConfig}); @@ -21,37 +22,15 @@ test( const releaseLock = await acquireExclusiveLock('notification-state'); try { - const serverWin = serverMap[demoMattermostConfig.servers[0].name]?.[0]?.win; + const serverEntry = serverMap[demoMattermostConfig.servers[0].name]?.[0]; + const serverWin = serverEntry?.win; expect(serverWin, 'No server view available').toBeTruthy(); await loginToMattermost(serverWin!); await serverWin!.waitForSelector('#sidebarItem_town-square', {timeout: 30_000}); - const targetChannel = await serverWin!.evaluate(() => { - const link = document.querySelector('#sidebarItem_off-topic') as HTMLAnchorElement | null; - const store = (window as any).store; - const state = store?.getState?.(); - const channels = state?.entities?.channels?.channels; - if (!channels) { - return undefined; - } - - const channel = Object.values(channels).find((value: any) => value?.name === 'off-topic'); - if (!channel) { - return undefined; - } - - return { - id: channel.id, - teamId: channel.team_id, - name: channel.name, - url: link?.href, - }; - }); - - expect(targetChannel, 'Could not get target channel from webapp store').toBeTruthy(); - expect(targetChannel?.url, 'Could not resolve off-topic sidebar URL').toBeTruthy(); - const targetPathname = new URL(targetChannel!.url!).pathname; + const targetChannel = await resolveChannelByName('off-topic'); + const targetPathname = new URL(targetChannel.url).pathname; await electronApp.evaluate(({webContents}, payload) => { const wc = webContents.fromId(payload.webContentsId); @@ -61,11 +40,11 @@ test( wc.send(payload.channel, payload.channelId, payload.teamId, payload.url); }, { - webContentsId: serverMap[demoMattermostConfig.servers[0].name]![0]!.webContentsId, + webContentsId: serverEntry!.webContentsId, channel: NOTIFICATION_CLICKED, - channelId: targetChannel!.id, - teamId: targetChannel!.teamId, - url: targetChannel!.url!, + channelId: targetChannel.id, + teamId: targetChannel.teamId, + url: targetChannel.url, }); await expect.poll( diff --git a/e2e/specs/server_management/bad_servers.test.ts b/e2e/specs/server_management/bad_servers.test.ts index 0c0d7b2c071..302023132b9 100644 --- a/e2e/specs/server_management/bad_servers.test.ts +++ b/e2e/specs/server_management/bad_servers.test.ts @@ -6,17 +6,25 @@ import * as path from 'path'; import {test, expect} from '../../fixtures/index'; import {waitForAppReady} from '../../helpers/appReadiness'; +import { + DNS_FAILURE_ERROR, + EXPIRED_CERT_ERROR, + EXPIRED_CERT_URL, + INSECURE_CIPHER_ERROR, + OBSOLETE_TLS_ERROR, + RC4_CIPHER_URL, + TLS_1_0_URL, + TLS_1_1_URL, + UNREACHABLE_SERVER_URL, + expectConnectionErrorView, + getMainWindow, + waitForRendererReadyThenReload, +} from '../../helpers/badServer'; import {electronBinaryPath, appDir, demoConfig, demoMattermostConfig} from '../../helpers/config'; import {waitForLockFileRelease} from '../../helpers/cleanup'; import {loginToMattermost} from '../../helpers/login'; import {buildServerMap} from '../../helpers/serverMap'; -const UNREACHABLE_SERVER_URL = 'https://jhsgefhjsaeiuofhseifuphoauifdhjauiowijdfcpohuawoiudfjpdhauwodjahwdpojaoiwdhawhdiuawd.com'; -const EXPIRED_CERT_URL = 'https://expired.badssl.com'; -const TLS_1_0_URL = 'https://tls-v1-0.badssl.com:1010'; -const TLS_1_1_URL = 'https://tls-v1-1.badssl.com'; -const RC4_CIPHER_URL = 'https://rc4.badssl.com'; - async function launchWithConfig(testInfo: {outputDir: string}, config: object) { const {mkdirSync} = await import('fs'); const userDataDir = path.join(testInfo.outputDir, 'custom-userdata'); @@ -34,8 +42,8 @@ async function launchWithConfig(testInfo: {outputDir: string}, config: object) { } async function openAddServerModal(app: Awaited>['app']) { - const mainView = app.windows().find((w) => w.url().includes('index')); - await mainView!.click('.ServerDropdownButton'); + const mainView = getMainWindow(app); + await mainView.click('.ServerDropdownButton'); let dropdownView = app.windows().find((w) => w.url().includes('dropdown')); if (!dropdownView) { dropdownView = await app.waitForEvent('window', { @@ -51,54 +59,9 @@ async function openAddServerModal(app: Awaited>['app']) { - const mainWindow = app.windows().find((w) => w.url().includes('index')); - if (!mainWindow) { - return; - } - - // ServerDropdownButton renders once componentDidMount has finished and IPC listeners - // are registered, so waiting for it is a reliable proxy for "renderer is ready". - await mainWindow.waitForSelector('.ServerDropdownButton', {timeout: 15_000}).catch(() => {}); - - // Reload server views so the load-failure fires after IPC listeners are registered. - // Use getAllServers() (same API as buildServerMap) — getCurrentServerId() does not exist. - // - // IMPORTANT: reload through the MattermostWebContentsView (wcEntry.reload()), NOT the - // raw webContents.reload(). The app only emits LOAD_FAILED (which drives the ErrorView) - // from its own load() promise's .catch() on ERR_CERT_*. A raw webContents.reload() - // re-triggers the Chromium load outside that promise, so the certificate rejection is - // never surfaced to the renderer and the ErrorView never appears. - await app.evaluate(() => { - const refs = (global as any).__e2eTestRefs; - if (!refs) { - return; - } - const servers: Array<{id: string}> = refs.ServerManager?.getAllServers?.() ?? []; - for (const server of servers) { - const views: Array<{id: string}> = refs.ViewManager?.getViewsByServerId?.(server.id) ?? []; - for (const view of views) { - const wcEntry = refs.WebContentsManager?.getView?.(view.id); - wcEntry?.reload?.(); - } - } - }); -} - async function openServerDropdown(app: Awaited>['app']) { - const mainView = app.windows().find((w) => w.url().includes('index')); - expect(mainView).toBeDefined(); - - await mainView!.click('.ServerDropdownButton'); + const mainView = getMainWindow(app); + await mainView.click('.ServerDropdownButton'); let dropdownView = app.windows().find((w) => w.url().includes('dropdown')); if (!dropdownView) { @@ -128,16 +91,9 @@ test.describe('Bad Server Configurations', () => { await expect.poll(() => { const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); return cfg.servers.find((s: {name: string}) => s.name === 'Unreachable Server'); - }, {timeout: 10000}).toBeDefined(); - - const mainWindow = app.windows().find((w) => w.url().includes('index')); - expect(mainWindow).toBeDefined(); - await mainWindow!.waitForSelector('.ErrorView', {timeout: 30000}); - const errorView = await mainWindow!.$('.ErrorView'); - expect(errorView).toBeDefined(); + }, {timeout: 10_000}).toBeDefined(); - const errorInfo = await mainWindow!.innerText('.ErrorView-techInfo'); - expect(errorInfo).toContain('ERR_NAME_NOT_RESOLVED'); + await expectConnectionErrorView(getMainWindow(app), DNS_FAILURE_ERROR); } finally { await app.close(); await waitForLockFileRelease(userDataDir); @@ -156,16 +112,9 @@ test.describe('Bad Server Configurations', () => { await expect.poll(() => { const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); return cfg.servers.find((s: {name: string}) => s.name === 'Expired Cert Server'); - }, {timeout: 10000}).toBeDefined(); + }, {timeout: 10_000}).toBeDefined(); - const mainWindow = app.windows().find((w) => w.url().includes('index')); - expect(mainWindow).toBeDefined(); - await mainWindow!.waitForSelector('.ErrorView', {timeout: 30000}); - const errorView = await mainWindow!.$('.ErrorView'); - expect(errorView).toBeDefined(); - - const errorInfo = await mainWindow!.innerText('.ErrorView-techInfo'); - expect(errorInfo).toContain('ERR_CERT_DATE_INVALID'); + await expectConnectionErrorView(getMainWindow(app), EXPIRED_CERT_ERROR); } finally { await app.close(); await waitForLockFileRelease(userDataDir); @@ -184,16 +133,9 @@ test.describe('Bad Server Configurations', () => { await expect.poll(() => { const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); return cfg.servers.find((s: {name: string}) => s.name === 'TLS 1.0 Server'); - }, {timeout: 10000}).toBeDefined(); - - const mainWindow = app.windows().find((w) => w.url().includes('index')); - expect(mainWindow).toBeDefined(); - await mainWindow!.waitForSelector('.ErrorView', {timeout: 30000}); - const errorView = await mainWindow!.$('.ErrorView'); - expect(errorView).toBeDefined(); + }, {timeout: 10_000}).toBeDefined(); - const errorInfo = await mainWindow!.innerText('.ErrorView-techInfo'); - expect(errorInfo).toMatch(/ERR_SSL_(VERSION_OR_CIPHER_MISMATCH|PROTOCOL_ERROR)/); + await expectConnectionErrorView(getMainWindow(app), OBSOLETE_TLS_ERROR); } finally { await app.close(); await waitForLockFileRelease(userDataDir); @@ -212,16 +154,9 @@ test.describe('Bad Server Configurations', () => { await expect.poll(() => { const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); return cfg.servers.find((s: {name: string}) => s.name === 'RC4 Cipher Server'); - }, {timeout: 10000}).toBeDefined(); + }, {timeout: 10_000}).toBeDefined(); - const mainWindow = app.windows().find((w) => w.url().includes('index')); - expect(mainWindow).toBeDefined(); - await mainWindow!.waitForSelector('.ErrorView', {timeout: 30000}); - const errorView = await mainWindow!.$('.ErrorView'); - expect(errorView).toBeDefined(); - - const errorInfo = await mainWindow!.innerText('.ErrorView-techInfo'); - expect(errorInfo).toMatch(/ERR_SSL_(OBSOLETE_CIPHER|VERSION_OR_CIPHER_MISMATCH)/); + await expectConnectionErrorView(getMainWindow(app), INSECURE_CIPHER_ERROR); } finally { await app.close(); await waitForLockFileRelease(userDataDir); @@ -245,14 +180,7 @@ test.describe('Bad Server Configurations', () => { }; const {app, userDataDir} = await launchWithConfig(testInfo, badConfig); try { - const mainWindow = app.windows().find((w) => w.url().includes('index')); - expect(mainWindow).toBeDefined(); - await mainWindow!.waitForSelector('.ErrorView', {timeout: 30000}); - const errorView = await mainWindow!.$('.ErrorView'); - expect(errorView).toBeDefined(); - - const errorInfo = await mainWindow!.innerText('.ErrorView-techInfo'); - expect(errorInfo).toContain('ERR_NAME_NOT_RESOLVED'); + await expectConnectionErrorView(getMainWindow(app), DNS_FAILURE_ERROR); } finally { await app.close(); await waitForLockFileRelease(userDataDir); @@ -278,15 +206,7 @@ test.describe('Bad Server Configurations', () => { }; const {app, userDataDir} = await launchWithConfig(testInfo, badConfig); try { - const mainWindow = app.windows().find((w) => w.url().includes('index')); - expect(mainWindow).toBeDefined(); - - await mainWindow!.waitForSelector('.ErrorView', {timeout: 30000}); - const errorView = await mainWindow!.$('.ErrorView'); - expect(errorView).toBeDefined(); - - const errorInfo = await mainWindow!.innerText('.ErrorView-techInfo'); - expect(errorInfo).toContain('ERR_NAME_NOT_RESOLVED'); + await expectConnectionErrorView(getMainWindow(app), DNS_FAILURE_ERROR); const dropdownView = await openServerDropdown(app); await dropdownView!.click('.ServerDropdown .ServerDropdown__button:nth-child(2)'); @@ -319,18 +239,8 @@ test.describe('Bad Server Configurations', () => { }; const {app, userDataDir: badCertUserDataDir} = await launchWithConfig(testInfo, badConfig); try { - // Ensure the renderer has mounted its IPC listeners before the load failure - // fires, then reload to re-trigger the failure so it reaches the UI. - await waitForRendererThenReload(app); - - const mainWindow = app.windows().find((w) => w.url().includes('index')); - expect(mainWindow).toBeDefined(); - await mainWindow!.waitForSelector('.ErrorView', {timeout: 30000}); - const errorView = await mainWindow!.$('.ErrorView'); - expect(errorView).toBeDefined(); - - const errorInfo = await mainWindow!.innerText('.ErrorView-techInfo'); - expect(errorInfo).toContain('ERR_CERT_DATE_INVALID'); + const mainWindow = await waitForRendererReadyThenReload(app, EXPIRED_CERT_ERROR); + await expectConnectionErrorView(mainWindow, EXPIRED_CERT_ERROR); } finally { await app.close(); await waitForLockFileRelease(badCertUserDataDir); @@ -376,18 +286,13 @@ test.describe('Bad Server Configurations', () => { try { await waitForAppReady(app); - // app.windows() can briefly lag behind app readiness while Playwright - // registers the freshly-shown BrowserWindow as a Page, so poll for the - // index window instead of reading it once. await expect.poll( () => app.windows().some((w) => w.url().includes('index')), {timeout: 15_000}, ).toBe(true); - const mainWindow = app.windows().find((w) => w.url().includes('index')); - expect(mainWindow).toBeDefined(); + const mainWindow = getMainWindow(app); - const errorView = await mainWindow!.$('.ErrorView'); - expect(errorView).toBeNull(); + await expect(mainWindow.locator('.ErrorView')).toHaveCount(0); } finally { await app.close(); await waitForLockFileRelease(userDataDir); @@ -409,14 +314,7 @@ test.describe('Bad Server Configurations', () => { }; const {app, userDataDir: tls11UserDataDir} = await launchWithConfig(testInfo, badConfig); try { - const mainWindow = app.windows().find((w) => w.url().includes('index')); - expect(mainWindow).toBeDefined(); - await mainWindow!.waitForSelector('.ErrorView', {timeout: 30000}); - const errorView = await mainWindow!.$('.ErrorView'); - expect(errorView).toBeDefined(); - - const errorInfo = await mainWindow!.innerText('.ErrorView-techInfo'); - expect(errorInfo).toMatch(/ERR_SSL_(VERSION_OR_CIPHER_MISMATCH|PROTOCOL_ERROR)/); + await expectConnectionErrorView(getMainWindow(app), OBSOLETE_TLS_ERROR); } finally { await app.close(); await waitForLockFileRelease(tls11UserDataDir); @@ -438,18 +336,8 @@ test.describe('Bad Server Configurations', () => { }; const {app, userDataDir: rc4UserDataDir} = await launchWithConfig(testInfo, badConfig); try { - // Ensure the renderer has mounted its IPC listeners before the load failure - // fires, then reload to re-trigger the failure so it reaches the UI. - await waitForRendererThenReload(app); - - const mainWindow = app.windows().find((w) => w.url().includes('index')); - expect(mainWindow).toBeDefined(); - await mainWindow!.waitForSelector('.ErrorView', {timeout: 30000}); - const errorView = await mainWindow!.$('.ErrorView'); - expect(errorView).toBeDefined(); - - const errorInfo = await mainWindow!.innerText('.ErrorView-techInfo'); - expect(errorInfo).toMatch(/ERR_SSL_(OBSOLETE_CIPHER|VERSION_OR_CIPHER_MISMATCH)/); + const mainWindow = await waitForRendererReadyThenReload(app, INSECURE_CIPHER_ERROR); + await expectConnectionErrorView(mainWindow, INSECURE_CIPHER_ERROR); } finally { await app.close(); await waitForLockFileRelease(rc4UserDataDir); diff --git a/e2e/specs/server_management/drag_and_drop.test.ts b/e2e/specs/server_management/drag_and_drop.test.ts index 109452c08fd..21e2cdc2905 100644 --- a/e2e/specs/server_management/drag_and_drop.test.ts +++ b/e2e/specs/server_management/drag_and_drop.test.ts @@ -11,6 +11,8 @@ import {waitForAppReady} from '../../helpers/appReadiness'; import {electronBinaryPath, appDir, demoMattermostConfig, writeConfigFile} from '../../helpers/config'; import {waitForLockFileRelease} from '../../helpers/cleanup'; import {loginToMattermost} from '../../helpers/login'; +import {waitForMattermostShell, recoverServerViewIfNeeded} from '../../helpers/mattermostShell'; +import {prepareMattermostServerView} from '../../helpers/prepareServerView'; import {buildServerMap} from '../../helpers/serverMap'; if (!process.env.MM_TEST_SERVER_URL) { @@ -130,8 +132,11 @@ async function resetState() { mainWindow = await waitForWindow(electronApp, 'index'); await mainWindow.bringToFront().catch(() => {}); await mainWindow.keyboard.press('Escape').catch(() => {}); + const mmServer = await getMattermostServer(); - await mmServer.waitForSelector('#sidebarItem_town-square', {timeout: 15_000}); + await prepareMattermostServerView(electronApp, mmServer.webContentsId); + await waitForMattermostShell(mmServer, {timeout: 45_000}); + await recoverServerViewIfNeeded(mmServer); await mmServer.click('#sidebarItem_town-square').catch(() => {}); } diff --git a/e2e/utils/analyze-flaky-test.js b/e2e/utils/analyze-flaky-test.js index 16c5ae2bcc6..d32ecde26f3 100644 --- a/e2e/utils/analyze-flaky-test.js +++ b/e2e/utils/analyze-flaky-test.js @@ -214,6 +214,18 @@ function analyzeFlakyTests() { const hasJunit = fs.existsSync(JUNIT_REPORT_PATH); if (!hasJunit) { + if (process.env.JOB_STATUS === 'cancelled') { + return { + failureCount: 0, + passCount: 0, + skipCount: 0, + totalCount: 0, + newFailedTests: [], + os: process.platform, + testStatus: 'error', + }; + } + const failureCount = exitCode === 0 ? 0 : 1; return { failureCount, diff --git a/e2e/utils/github-actions.js b/e2e/utils/github-actions.js index a1551547e4c..62646ac51c9 100644 --- a/e2e/utils/github-actions.js +++ b/e2e/utils/github-actions.js @@ -2,6 +2,18 @@ // See LICENSE.txt for license information. /* eslint-disable no-console -- Logging is intentional in CI utility scripts */ +const E2E_STATUS_CONTEXTS = [ + 'e2e/linux', + 'e2e/macos', + 'e2e/windows', + 'policy-test/macos', + 'policy-test/windows', +]; + +const E2E_WORKFLOW_NAME = 'Electron Playwright Tests'; +const ACTIVE_RUN_STATUSES = ['in_progress', 'queued', 'waiting']; +const CANCELLED_STATUS_DESCRIPTION = 'E2E cancelled — tests skipped'; + /** * Update initial pending status for all platforms * @param {Object} params - Parameters object @@ -25,14 +37,6 @@ async function updateInitialStatus({github, context, platforms}) { )); } -/** - * Update final status for all platforms based on test results - * @param {Object} params - Parameters object - * @param {Object} params.github - GitHub API client from actions/github-script - * @param {Object} params.context - GitHub Actions context - * @param {Array} params.platforms - Array of platform objects from matrix - * @param {Object} params.outputs - Test outputs from e2e-tests job - */ /** * Build the short description shown in the PR status check. * Only counts tests that actually ran on this platform (passed + failed). @@ -53,12 +57,35 @@ function formatStatusDescription({passed, failed}) { return `${ran} ran, ${passed} passed, ${failed} failed`; } -async function updateFinalStatus({github, context, platforms, outputs}) { +async function resolveStatusSha({github, context, prNumber}) { + if (prNumber) { + const {data: pr} = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + return pr.head.sha; + } + + return context.payload.pull_request?.head?.sha || context.sha; +} + +/** + * Update final status for all platforms based on test results + * @param {Object} params - Parameters object + * @param {Object} params.github - GitHub API client from actions/github-script + * @param {Object} params.context - GitHub Actions context + * @param {Array} params.platforms - Array of platform objects from matrix + * @param {Object} params.outputs - Test outputs from e2e-tests job + * @param {string} [params.e2eTestsResult] - needs.e2e-tests.result from the workflow + * @param {number} [params.prNumber] - PR number for status SHA lookup + */ +async function updateFinalStatus({github, context, platforms, outputs, e2eTestsResult, prNumber}) { const workflowUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const sha = await resolveStatusSha({github, context, prNumber}); + const workflowCancelled = e2eTestsResult === 'cancelled'; await Promise.all(platforms.map((platform) => { - // Determine OS key based on runner. Each platform's REPORT_LINK_* is its - // own single-OS Playwright HTML report (uploaded per-OS in the template). let osKey; if (platform.runner.includes('ubuntu')) { osKey = 'LINUX'; @@ -70,23 +97,139 @@ async function updateFinalStatus({github, context, platforms, outputs}) { const failed = Number(outputs[`NEW_FAILURES_${osKey}`] || 0); const passed = Number(outputs[`PASSED_${osKey}`] || 0); - const skipped = Number(outputs[`SKIPPED_${osKey}`] || 0); - const total = Number(outputs[`TOTAL_${osKey}`] || 0); - const status = outputs[`STATUS_${osKey}`] || 'failure'; + const platformStatus = outputs[`STATUS_${osKey}`] || ''; const reportLink = outputs[`REPORT_LINK_${osKey}`] || workflowUrl; + const ran = passed + failed; + + let state; + let description; + + if (platformStatus === 'error' || (workflowCancelled && ran === 0)) { + state = 'error'; + description = CANCELLED_STATUS_DESCRIPTION; + } else if (ran === 0 && (platformStatus === 'success' || platformStatus === '')) { + state = 'error'; + description = workflowCancelled ? CANCELLED_STATUS_DESCRIPTION : 'E2E incomplete — no tests ran'; + } else if (failed > 0 || platformStatus === 'failure') { + state = 'failure'; + description = formatStatusDescription({passed, failed}); + } else { + state = 'success'; + description = formatStatusDescription({passed, failed}); + } return github.rest.repos.createCommitStatus({ owner: context.repo.owner, repo: context.repo.repo, - sha: context.payload.pull_request?.head?.sha || context.sha, - state: status, + sha, + state, context: `e2e/${platform.platform}`, - description: formatStatusDescription({passed, failed, skipped, total}), + description, target_url: reportLink, }); })); } +/** + * Mark standard E2E commit statuses as cancelled/skipped on a SHA. + * GitHub commit statuses have no "skipped" state — `error` matches mobile E2E. + */ +async function markE2EStatusesCancelled({github, context, sha, reason = CANCELLED_STATUS_DESCRIPTION}) { + const description = String(reason).substring(0, 140); + + await Promise.all(E2E_STATUS_CONTEXTS.map((statusContext) => + github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha, + state: 'error', + context: statusContext, + description, + }).catch((error) => { + console.log(`Could not update ${statusContext} on ${sha}: ${error.message}`); + }), + )); +} + +/** + * Return true when a workflow run belongs to the given PR. + * Matterwick dispatches with version_name set to the PR head branch, so + * head_branch on the run matches pull_request.head.ref. + */ +function runBelongsToPr(run, headBranch) { + return Boolean(headBranch && run.head_branch === headBranch); +} + +async function resolvePrHeadBranch({github, context, prNumber, headBranch}) { + if (headBranch) { + return headBranch; + } + + if (!prNumber) { + return null; + } + + const {data: pr} = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + return pr.head.ref; +} + +/** + * Cancel active Electron Playwright Tests runs for a single PR. + * Only runs whose head_branch matches the PR branch are cancelled so concurrent + * E2E runs on other PRs are not interrupted. + */ +async function cancelActiveE2ERuns({github, context, prNumber, headBranch}) { + const {owner, repo} = context.repo; + const branch = await resolvePrHeadBranch({github, context, prNumber, headBranch}); + + if (!branch) { + console.log('cancelActiveE2ERuns: no PR branch resolved — skipping cancellation'); + return 0; + } + + const {data: {workflows}} = await github.rest.actions.listRepoWorkflows({owner, repo}); + const e2eWorkflow = workflows.find((workflow) => workflow.name === E2E_WORKFLOW_NAME); + + if (!e2eWorkflow) { + console.log(`${E2E_WORKFLOW_NAME} workflow not found — skipping cancellation`); + return 0; + } + + let cancelled = 0; + + for (const status of ACTIVE_RUN_STATUSES) { + const {data: {workflow_runs: workflowRuns}} = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: e2eWorkflow.id, + branch, + status, + per_page: 20, + }); + + for (const run of workflowRuns) { + if (!runBelongsToPr(run, branch)) { + console.log(`Skipping E2E run ${run.id} (branch ${run.head_branch ?? 'unknown'} != ${branch})`); + continue; + } + + try { + await github.rest.actions.cancelWorkflowRun({owner, repo, run_id: run.id}); + console.log(`Cancelled E2E run ${run.id} for branch ${branch} (status: ${status})`); + cancelled += 1; + } catch (error) { + console.log(`Could not cancel run ${run.id}: ${error.message}`); + } + } + } + + return cancelled; +} + /** * Remove E2E/Run label when workflow triggered via Matterwick * @param {Object} params - Parameters object @@ -95,30 +238,24 @@ async function updateFinalStatus({github, context, platforms, outputs}) { */ async function removeE2ELabel({github, context}) { try { - // Get the current run to check if it was triggered by workflow_dispatch const run = await github.rest.actions.getWorkflowRun({ owner: context.repo.owner, repo: context.repo.repo, run_id: context.runId, }); - // Only remove the label if this was triggered via workflow_dispatch (Matterwick) if (run.data.event !== 'workflow_dispatch') { console.log('Label removal skipped - workflow run is not triggered by workflow_dispatch (Matterwick)'); return; } - // Try to find associated PR let prNumber = null; - // First try: check run.data.pull_requests (reliable for pull_request events) if (run.data.pull_requests && run.data.pull_requests.length > 0) { prNumber = run.data.pull_requests[0].number; } else { - // Second try: query PRs by head branch (more reliable for workflow_dispatch) const branchName = run.data.head_branch; if (branchName) { - // Use the actual head repository owner (supports fork PRs) const headOwner = run.data.head_repository?.owner?.login || context.repo.owner; const prs = await github.rest.pulls.list({ owner: context.repo.owner, @@ -127,15 +264,10 @@ async function removeE2ELabel({github, context}) { head: `${headOwner}:${branchName}`, }); if (prs.data && prs.data.length > 0) { - // Prefer the PR whose head SHA matches the workflow run's head SHA const matchingPr = prs.data.find( (pr) => pr.head && pr.head.sha === run.data.head_sha, ); - if (matchingPr) { - prNumber = matchingPr.number; - } else { - prNumber = prs.data[0].number; - } + prNumber = (matchingPr || prs.data[0]).number; } } } @@ -166,4 +298,8 @@ module.exports = { updateFinalStatus, removeE2ELabel, formatStatusDescription, + markE2EStatusesCancelled, + cancelActiveE2ERuns, + E2E_STATUS_CONTEXTS, + CANCELLED_STATUS_DESCRIPTION, };