Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
db20b56
E2E: Playwright harness, fixtures, and CI wiring (1/10).
yasserfaraazkhan Jun 19, 2026
f46ebbc
E2E: Main-process test hooks and shared launch helpers (2/10).
yasserfaraazkhan Jun 19, 2026
c1a0bea
E2E: Notifications, focus, and Calls specs (8/10).
yasserfaraazkhan Jun 19, 2026
28ce357
Merge origin/master into e2e/08-notifications-focus-calls
yasserfaraazkhan Jun 30, 2026
d9cc1ba
Rename triggerFlashEffects to triggerNotificationEffects in specs
yasserfaraazkhan Jun 30, 2026
081a3b5
E2E: Menu bar and permissions IPC specs (9/10). (#3863)
yasserfaraazkhan Jul 1, 2026
3c11505
Address CodeRabbit review on notifications, menu bar, and calls E2E s…
yasserfaraazkhan Jul 1, 2026
dd9ae3a
fix(e2e): fix duplicate cross-PR bugs, unsafe private-field cast, bar…
yasserfaraazkhan Jul 1, 2026
bad91be
fix(e2e): restore downloadsDropdown helper and address CodeRabbit nit…
yasserfaraazkhan Jul 1, 2026
19a1f77
fix(e2e): restore missing mattermostShell helper for window_menu tests
yasserfaraazkhan Jul 1, 2026
cdba900
fix(e2e): fail CI status when Playwright collects zero tests
yasserfaraazkhan Jul 1, 2026
300ab3c
Merge origin/master into e2e/08-notifications-focus-calls
yasserfaraazkhan Jul 2, 2026
a52c702
clean up
yasserfaraazkhan Jul 2, 2026
cff4fce
clean up
yasserfaraazkhan Jul 2, 2026
dd1e922
remove unwanted artifacts and fix failures
yasserfaraazkhan Jul 2, 2026
487b5cf
fix badge tests
yasserfaraazkhan Jul 2, 2026
c3c1dc3
fix tests
yasserfaraazkhan Jul 2, 2026
411e3c5
clean up hooks
yasserfaraazkhan Jul 2, 2026
17077d0
move hookes to e2e/hooks
yasserfaraazkhan Jul 2, 2026
ca1c50c
move hookes to e2e/hooks
yasserfaraazkhan Jul 3, 2026
8181131
clean up label
yasserfaraazkhan Jul 3, 2026
e309707
revert production app changes
yasserfaraazkhan Jul 3, 2026
2d2bb51
clean up label
yasserfaraazkhan Jul 3, 2026
1422f63
clean up label
yasserfaraazkhan Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 14 additions & 21 deletions .github/workflows/e2e-functional-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ on:
SKIPPED_WINDOWS:
description: "Number of skipped tests on Windows"
value: ${{ jobs.e2e.outputs.SKIPPED_WINDOWS }}
COLLECTION_FAILED_LINUX:
description: "Whether Playwright collected zero tests on Linux"
value: ${{ jobs.e2e.outputs.COLLECTION_FAILED_LINUX }}
COLLECTION_FAILED_MACOS:
description: "Whether Playwright collected zero tests on macOS"
value: ${{ jobs.e2e.outputs.COLLECTION_FAILED_MACOS }}
COLLECTION_FAILED_WINDOWS:
description: "Whether Playwright collected zero tests on Windows"
value: ${{ jobs.e2e.outputs.COLLECTION_FAILED_WINDOWS }}
STATUS_WINDOWS:
description: "The status of the windows test"
value: ${{ jobs.e2e.outputs.STATUS_WINDOWS }}
Expand Down Expand Up @@ -182,6 +191,9 @@ jobs:
SKIPPED_LINUX: ${{ steps.analyze-flaky-tests.outputs.SKIPPED_LINUX }}
SKIPPED_MACOS: ${{ steps.analyze-flaky-tests.outputs.SKIPPED_MACOS }}
SKIPPED_WINDOWS: ${{ steps.analyze-flaky-tests.outputs.SKIPPED_WINDOWS }}
COLLECTION_FAILED_LINUX: ${{ steps.analyze-flaky-tests.outputs.COLLECTION_FAILED_LINUX }}
COLLECTION_FAILED_MACOS: ${{ steps.analyze-flaky-tests.outputs.COLLECTION_FAILED_MACOS }}
COLLECTION_FAILED_WINDOWS: ${{ steps.analyze-flaky-tests.outputs.COLLECTION_FAILED_WINDOWS }}
steps:
- name: e2e/set-required-variables
id: variables
Expand Down Expand Up @@ -393,7 +405,7 @@ jobs:
script: |
process.chdir('./e2e');
const { analyzeFlakyTests } = require('./utils/analyze-flaky-test.js');
const { failureCount, passCount, skipCount, totalCount, os, testStatus } = analyzeFlakyTests();
const { failureCount, passCount, skipCount, totalCount, os, testStatus, collectionFailed } = analyzeFlakyTests();
const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
const reportUrl = process.env.PER_OS_REPORT_URL || runUrl;
const setOSOutputs = (suffix) => {
Expand All @@ -403,6 +415,7 @@ jobs:
core.setOutput(`PASSED_${suffix}`, String(passCount));
core.setOutput(`SKIPPED_${suffix}`, String(skipCount));
core.setOutput(`TOTAL_${suffix}`, String(totalCount));
core.setOutput(`COLLECTION_FAILED_${suffix}`, String(collectionFailed));
};
switch (os) {
case 'linux':
Expand All @@ -417,23 +430,3 @@ jobs:
default:
throw new Error(`Unsupported OS: ${os}`);
}

- name: Upload Playwright blob report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: blob-report-${{ env.RUNNER_OS }}
path: |
e2e/blob-report
if-no-files-found: ignore
retention-days: 7

- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-results-${{ env.RUNNER_OS }}
path: |
e2e/test-results
if-no-files-found: ignore
retention-days: 7
31 changes: 21 additions & 10 deletions .github/workflows/e2e-functional.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,13 @@ jobs:
platforms: ${{ steps.generate.outputs.platforms }}
steps:
- id: generate
env:
INSTANCE_DETAILS: ${{ inputs.instance_details }}
run: |
echo "platforms=$(echo '${{ inputs.instance_details }}' | jq -c)" >> $GITHUB_OUTPUT
# Matterwick still dispatches macos-latest; pin explicitly so the job
# does not drift when GitHub retargets that label to macOS 26.
platforms=$(echo "${INSTANCE_DETAILS}" | jq -c 'map(if .runner == "macos-latest" then .runner = "macos-26" else . end)')
echo "platforms=${platforms}" >> "$GITHUB_OUTPUT"

update-initial-status:
name: Update initial status
Expand Down Expand Up @@ -181,12 +186,19 @@ jobs:
}

if (prNumber) {
await github.rest.issues.removeLabel({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
name: 'E2E/Run',
});
try {
await github.rest.issues.removeLabel({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
name: 'E2E/Run',
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
console.log('E2E/Run label already removed');
}
} else {
console.log('Label removal skipped - could not find associated PR');
}
Expand Down Expand Up @@ -359,16 +371,15 @@ jobs:
process.chdir('./e2e');
const { analyzeFlakyTests } = require('./utils/analyze-flaky-test.js');
const { formatStatusDescription } = require('./utils/github-actions.js');
const { newFailedTests, failureCount, passCount, skipCount, totalCount } = analyzeFlakyTests();
const { newFailedTests, failureCount, passCount, skipCount, totalCount, collectionFailed } = analyzeFlakyTests();

const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
const platform = process.platform === 'darwin' ? 'macos' : 'windows';
const hasFailed = newFailedTests && newFailedTests.length > 0;
const description = formatStatusDescription({
passed: passCount,
failed: failureCount,
skipped: skipCount,
total: totalCount,
collectionFailed,
});

try {
Expand Down
1 change: 1 addition & 0 deletions e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export const test = base.extend<Fixtures, WorkerFixtures>({
await use(app);

await closeElectronApp(app, userDataDir, FAST_TEARDOWN);
await fs.rm(userDataDir, {recursive: true, force: true}).catch(() => {});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curios why need to remove data dir. Maybe would be good to add comment.

},

appReady: async ({electronApp}, use) => {
Expand Down
143 changes: 143 additions & 0 deletions e2e/helpers/badge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// 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 {evaluateInMainProcess, evaluateInMainProcessWithArg} from './testRefs';

export type OsBadgeState = {
count: number;
symbol: 'mention' | 'unread' | 'expired' | 'none';
hasOverlay: boolean;
};

export async function waitForBadgeInfrastructure(app: ElectronApplication): Promise<void> {
await expect.poll(
async () => app.evaluate(() => {
const refs = (global as any).__e2eTestRefs;
return Boolean(refs?.AppState && refs?.ServerManager);
}),
{timeout: 30_000, message: 'AppState and ServerManager must be exposed on __e2eTestRefs'},
).toBe(true);
}

export async function setUnreadBadgeSetting(app: ElectronApplication, enabled: boolean): Promise<void> {
await evaluateInMainProcessWithArg(app, (_electron, showUnreadBadge) => {
const refs = (global as any).__e2eTestRefs;
if (!refs?.setUnreadBadgeSetting) {
throw new Error('setUnreadBadgeSetting missing from __e2eTestRefs');
}
refs.Config?.set?.('showUnreadBadge', showUnreadBadge);
refs.setUnreadBadgeSetting(showUnreadBadge);
}, enabled);
}

export async function updateServerBadgeViaAppState(
app: ElectronApplication,
serverName: string,
mentions: number,
unreads: boolean,
): Promise<void> {
await evaluateInMainProcessWithArg(app, (_electron, {serverName: name, mentions: mentionCount, unreads: hasUnreads}) => {
const refs = (global as any).__e2eTestRefs;
const AppState = refs?.AppState;
const ServerManager = refs?.ServerManager;
if (!AppState || !ServerManager) {
throw new Error('AppState or ServerManager missing from __e2eTestRefs');
}
const server = ServerManager.getAllServers().find((s: {name: string}) => s.name === name);
if (!server) {
throw new Error(`Server not found: ${name}`);
}
AppState.updateUnreadsAndMentionsPerServer(server.id, mentionCount, hasUnreads);
}, {serverName, mentions, unreads});
}

export async function setServerExpiredViaAppState(
app: ElectronApplication,
serverName: string,
expired: boolean,
): Promise<void> {
await evaluateInMainProcessWithArg(app, (_electron, {serverName: name, expired: isExpired}) => {
const refs = (global as any).__e2eTestRefs;
const AppState = refs?.AppState;
const ServerManager = refs?.ServerManager;
if (!AppState || !ServerManager) {
throw new Error('AppState or ServerManager missing from __e2eTestRefs');
}
const server = ServerManager.getAllServers().find((s: {name: string}) => s.name === name);
if (!server) {
throw new Error(`Server not found: ${name}`);
}
AppState.updateExpired(server.id, isExpired);
}, {serverName, expired});
}

export async function clearAllBadgesViaAppState(app: ElectronApplication): Promise<void> {
await evaluateInMainProcess(app, () => {
const refs = (global as any).__e2eTestRefs;
const AppState = refs?.AppState;
const ServerManager = refs?.ServerManager;
if (!AppState || !ServerManager) {
throw new Error('AppState or ServerManager missing from __e2eTestRefs');
}
for (const server of ServerManager.getAllServers()) {
AppState.updateUnreadsAndMentionsPerServer(server.id, 0, false);
AppState.updateExpired(server.id, false);
}
refs.Config?.set?.('showUnreadBadge', false);
refs.setUnreadBadgeSetting?.(false);
});
}

export async function readOsBadge(electronApp: ElectronApplication): Promise<OsBadgeState> {
return evaluateInMainProcess(electronApp, ({app}) => {
const testState = (global as any).__testBadgeState;

if (process.platform === 'darwin') {
const badge = app.dock?.getBadge() ?? '';
if (badge === '•') {
return {count: 0, symbol: 'unread' as const, hasOverlay: false};
}
if (badge === '!') {
return {count: 0, symbol: 'expired' as const, hasOverlay: false};
}
if (badge === '') {
return {count: 0, symbol: 'none' as const, hasOverlay: false};
}
return {count: parseInt(badge, 10), symbol: 'mention' as const, hasOverlay: false};
}

if (process.platform === 'linux') {
// app.getBadgeCount()/setBadgeCount() are no-ops without a running Unity
// desktop (true in headless CI), so fall back to re-deriving the count
// from the same inputs showBadgeLinux() would have passed to
// setBadgeCount() — mentionCount plus 1 for an expired session.
let count: number;
if (app.isUnityRunning()) {
count = app.getBadgeCount();
} else if (testState) {
count = testState.mentionCount + (testState.sessionExpired ? 1 : 0);
} else {
count = 0;
}
const symbol = testState?.resolvedType ?? (count > 0 ? 'mention' : 'none');
return {count, symbol, hasOverlay: false};
}

// Electron exposes setOverlayIcon() but no getOverlayIcon(); __testBadgeState
// records what showBadgeWindows() decided to pass to setOverlayIcon().
const symbol = testState?.resolvedType ?? 'none';
return {
count: testState?.mentionCount ?? 0,
symbol,
hasOverlay: testState?.hasOverlay ?? false,
};
});
}

export async function readBadgeCount(app: ElectronApplication): Promise<number> {
const state = await readOsBadge(app);
return state.count;
}
37 changes: 37 additions & 0 deletions e2e/helpers/callsWidget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import type {Page} from '@playwright/test';
import type {ElectronApplication} from 'playwright';

export function findCallsWidgetWindow(electronApp: ElectronApplication): Page | null {
return electronApp.windows().find((w) => {
try {
const url = w.url();
return url.includes('/plugins/com.mattermost.calls/standalone/widget.html');
} catch {
return false;
}
}) ?? null;
}

export async function waitForCallsWidgetWindow(
electronApp: ElectronApplication,
timeoutMs = 20_000,
): Promise<Page | null> {
const existing = findCallsWidgetWindow(electronApp);
if (existing) {
return existing;
}

return electronApp.waitForEvent('window', {
predicate: (w) => {
try {
return w.url().includes('/plugins/com.mattermost.calls/standalone/widget.html');
} catch {
return false;
}
},
timeout: timeoutMs,
}).catch(() => null);
}
22 changes: 22 additions & 0 deletions e2e/helpers/downloadsDropdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import type {ElectronApplication} from 'playwright';

import {CLOSE_DOWNLOADS_DROPDOWN, CLOSE_DOWNLOADS_DROPDOWN_MENU} from './ipcChannels';

import {evaluateInMainProcessWithArg, isTransientNavigationError} from './testRefs';

/**
* Close the downloads dropdown WebContentsView if it is open.
* Parallel download specs can leave this overlay focused and block other UI flows.
*/
export async function closeDownloadsDropdownIfOpen(app: ElectronApplication): Promise<void> {
await evaluateInMainProcessWithArg(app, ({ipcMain}, channels) => {
ipcMain.emit(channels.menu);
ipcMain.emit(channels.dropdown);
}, {dropdown: CLOSE_DOWNLOADS_DROPDOWN, menu: CLOSE_DOWNLOADS_DROPDOWN_MENU}, {
timeoutMs: 15_000,
isRetryable: isTransientNavigationError,
});
}
Loading
Loading