Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .changelog/pr-2329.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix: Ensure all notification methods return a Notification-like stub for Teams. - by @IsmaelMartinez (#2329)
35 changes: 31 additions & 4 deletions app/browser/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,35 @@ ipcRenderer.invoke("get-config").then((config) => {
console.error("Preload: Failed to load config for notifications:", err);
});

// Create a Notification-like stub so Teams can manage lifecycle without errors.
// Without addEventListener/close/dispatchEvent, Teams' internal state machine
// breaks after the first notification, causing subsequent ones to stop firing.
function createNotificationStub() {
const stub = {
onclick: null,
onclose: null,
onerror: null,
onshow: null,
close() { if (this.onclose) this.onclose(); },
addEventListener(type, listener) {
if (type === 'click') this.onclick = listener;
else if (type === 'close') this.onclose = listener;
else if (type === 'show') this.onshow = listener;
else if (type === 'error') this.onerror = listener;
},
removeEventListener(type, listener) {
if (type === 'click' && (!listener || this.onclick === listener)) this.onclick = null;
else if (type === 'close' && (!listener || this.onclose === listener)) this.onclose = null;
else if (type === 'show' && (!listener || this.onshow === listener)) this.onshow = null;
else if (type === 'error' && (!listener || this.onerror === listener)) this.onerror = null;
},
dispatchEvent() { return true; },
};
// Fire the show event asynchronously like a real Notification
setTimeout(() => { if (stub.onshow) stub.onshow(); }, 0);
return stub;
}

// Helper functions for notification handling (extracted to reduce cognitive complexity)
function playNotificationSound(notifSound) {
if (globalThis.electronAPI?.playNotificationSound) {
Expand Down Expand Up @@ -195,8 +224,7 @@ function createElectronNotification(options) {
console.debug("showNotification failed", e);
}
}
// Return stub object for Electron notifications
return { onclick: null, onclose: null, onerror: null };
return createNotificationStub();
}

function createCustomNotification(title, options) {
Expand Down Expand Up @@ -229,8 +257,7 @@ function createCustomNotification(title, options) {
console.error("Failed to send custom notification:", e);
}

// Return stub object
return { onclick: null, onclose: null, onerror: null };
return createNotificationStub();
}

// Override window.Notification immediately before Teams loads
Expand Down
340 changes: 340 additions & 0 deletions tests/e2e/notifications.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
import { test, expect } from '@playwright/test';
import { _electron as electron } from 'playwright';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

/**
* Notification lifecycle tests.
*
* Verifies that the preload.js Notification override returns objects with
* the lifecycle methods Teams expects (addEventListener, removeEventListener,
* close, dispatchEvent). Without these, Teams' internal state machine breaks
* after the first notification call, causing subsequent ones to silently fail.
*
* These tests launch the app with a clean profile (no login session), so they
* exercise the preload in a real Electron renderer without needing Microsoft
* authentication. They work both locally and inside Docker cross-distro
* containers.
*/

const LIFECYCLE_METHODS = ['addEventListener', 'removeEventListener', 'close', 'dispatchEvent'];
const CALLBACK_PROPERTIES = ['onclick', 'onclose', 'onerror', 'onshow'];

async function launchApp(notificationMethod) {
const userDataDir = mkdtempSync(join(tmpdir(), 'teams-e2e-notif-'));
const args = [
'./app/index.js',
`--notificationMethod=${notificationMethod}`,
...(process.env.CI ? ['--no-sandbox'] : []),
];

const electronApp = await electron.launch({
args,
env: { ...process.env, E2E_USER_DATA_DIR: userDataDir },
timeout: 30000,
});

return { electronApp, userDataDir };
}

async function getMainWindow(electronApp) {
await electronApp.firstWindow({ timeout: 30000 });
// Wait for windows to settle
await new Promise(resolve => setTimeout(resolve, 4000));

const windows = electronApp.windows();
// Find a window with a real page (not about:blank)
return windows.find(w => {
const url = w.url();
try {
const hostname = new URL(url).hostname;
return hostname === 'teams.cloud.microsoft' ||
hostname === 'teams.microsoft.com' ||
hostname === 'teams.live.com' ||
hostname === 'login.microsoftonline.com';
} catch {
return false;
}
});
}

async function cleanup(electronApp, userDataDir) {
if (electronApp) {
try {
await Promise.race([
electronApp.close(),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000)),
]);
} catch {
try {
const pid = electronApp.process()?.pid;
if (pid) process.kill(pid, 'SIGKILL');
} catch { /* ignore */ }
}
}
if (userDataDir) {
try { rmSync(userDataDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}

test.describe('Notification override', () => {
test('window.Notification is overridden with correct static API', async () => {
let electronApp, userDataDir;
try {
({ electronApp, userDataDir } = await launchApp('electron'));
const mainWindow = await getMainWindow(electronApp);
expect(mainWindow).toBeTruthy();

await mainWindow.waitForLoadState('load', { timeout: 30000 });

// Check static API
const permissionValue = await mainWindow.evaluate(() => window.Notification.permission);

Check warning on line 92 in tests/e2e/notifications.spec.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=IsmaelMartinez_teams-for-linux&issues=AZzm5EsdlpXvOsy3EyGO&open=AZzm5EsdlpXvOsy3EyGO&pullRequest=2329
expect(permissionValue).toBe('granted');

const requestResult = await mainWindow.evaluate(() => window.Notification.requestPermission());

Check warning on line 95 in tests/e2e/notifications.spec.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=IsmaelMartinez_teams-for-linux&issues=AZzm5EsdlpXvOsy3EyGP&open=AZzm5EsdlpXvOsy3EyGP&pullRequest=2329
expect(requestResult).toBe('granted');
} finally {
await cleanup(electronApp, userDataDir);
}
});

test('electron method returns stub with lifecycle methods', async () => {
let electronApp, userDataDir;
try {
({ electronApp, userDataDir } = await launchApp('electron'));
const mainWindow = await getMainWindow(electronApp);
expect(mainWindow).toBeTruthy();

await mainWindow.waitForLoadState('load', { timeout: 30000 });

// Create a notification and inspect the returned object
const stubShape = await mainWindow.evaluate(() => {
const n = new window.Notification('Test', { body: 'test body' });

Check warning on line 113 in tests/e2e/notifications.spec.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=IsmaelMartinez_teams-for-linux&issues=AZzm5EsdlpXvOsy3EyGQ&open=AZzm5EsdlpXvOsy3EyGQ&pullRequest=2329
return {
hasAddEventListener: typeof n.addEventListener === 'function',
hasRemoveEventListener: typeof n.removeEventListener === 'function',
hasClose: typeof n.close === 'function',
hasDispatchEvent: typeof n.dispatchEvent === 'function',
hasOnclick: 'onclick' in n,
hasOnclose: 'onclose' in n,
hasOnerror: 'onerror' in n,
hasOnshow: 'onshow' in n,
};
});

expect(stubShape.hasAddEventListener).toBe(true);
expect(stubShape.hasRemoveEventListener).toBe(true);
expect(stubShape.hasClose).toBe(true);
expect(stubShape.hasDispatchEvent).toBe(true);
expect(stubShape.hasOnclick).toBe(true);
expect(stubShape.hasOnclose).toBe(true);
expect(stubShape.hasOnerror).toBe(true);
expect(stubShape.hasOnshow).toBe(true);
} finally {
await cleanup(electronApp, userDataDir);
}
});

test('electron stub addEventListener wires up callbacks correctly', async () => {
let electronApp, userDataDir;
try {
({ electronApp, userDataDir } = await launchApp('electron'));
const mainWindow = await getMainWindow(electronApp);
expect(mainWindow).toBeTruthy();

await mainWindow.waitForLoadState('load', { timeout: 30000 });

const result = await mainWindow.evaluate(() => {
const n = new window.Notification('Test', { body: 'test' });

Check warning on line 149 in tests/e2e/notifications.spec.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=IsmaelMartinez_teams-for-linux&issues=AZzm5EsdlpXvOsy3EyGR&open=AZzm5EsdlpXvOsy3EyGR&pullRequest=2329
let clickFired = false;
let closeFired = false;

n.addEventListener('click', () => { clickFired = true; });
n.addEventListener('close', () => { closeFired = true; });

// Simulate Teams calling onclick/onclose
if (n.onclick) n.onclick();
if (n.onclose) n.onclose();

return { clickFired, closeFired };
});

expect(result.clickFired).toBe(true);
expect(result.closeFired).toBe(true);
} finally {
await cleanup(electronApp, userDataDir);
}
});

test('electron stub removeEventListener clears callbacks', async () => {
let electronApp, userDataDir;
try {
({ electronApp, userDataDir } = await launchApp('electron'));
const mainWindow = await getMainWindow(electronApp);
expect(mainWindow).toBeTruthy();

await mainWindow.waitForLoadState('load', { timeout: 30000 });

const result = await mainWindow.evaluate(() => {
const n = new window.Notification('Test', { body: 'test' });

Check warning on line 180 in tests/e2e/notifications.spec.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=IsmaelMartinez_teams-for-linux&issues=AZzm5EsdlpXvOsy3EyGS&open=AZzm5EsdlpXvOsy3EyGS&pullRequest=2329
const handler = () => {};

n.addEventListener('click', handler);
const hadHandler = n.onclick === handler;

n.removeEventListener('click', handler);
const cleared = n.onclick === null;

return { hadHandler, cleared };
});

expect(result.hadHandler).toBe(true);
expect(result.cleared).toBe(true);
} finally {
await cleanup(electronApp, userDataDir);
}
});

test('electron stub close() triggers onclose callback', async () => {
let electronApp, userDataDir;
try {
({ electronApp, userDataDir } = await launchApp('electron'));
const mainWindow = await getMainWindow(electronApp);
expect(mainWindow).toBeTruthy();

await mainWindow.waitForLoadState('load', { timeout: 30000 });

const closeFired = await mainWindow.evaluate(() => {
const n = new window.Notification('Test', { body: 'test' });

Check warning on line 209 in tests/e2e/notifications.spec.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=IsmaelMartinez_teams-for-linux&issues=AZzm5EsdlpXvOsy3EyGT&open=AZzm5EsdlpXvOsy3EyGT&pullRequest=2329
let fired = false;
n.addEventListener('close', () => { fired = true; });
n.close();
return fired;
});

expect(closeFired).toBe(true);
} finally {
await cleanup(electronApp, userDataDir);
}
});

test('electron stub fires show event asynchronously', async () => {
let electronApp, userDataDir;
try {
({ electronApp, userDataDir } = await launchApp('electron'));
const mainWindow = await getMainWindow(electronApp);
expect(mainWindow).toBeTruthy();

await mainWindow.waitForLoadState('load', { timeout: 30000 });

const showFired = await mainWindow.evaluate(async () => {
const n = new window.Notification('Test', { body: 'test' });

Check warning on line 232 in tests/e2e/notifications.spec.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=IsmaelMartinez_teams-for-linux&issues=AZzm5EsdlpXvOsy3EyGU&open=AZzm5EsdlpXvOsy3EyGU&pullRequest=2329
let fired = false;
n.addEventListener('show', () => { fired = true; });
// Wait for the async setTimeout(0) to fire
await new Promise(resolve => setTimeout(resolve, 50));
return fired;
});

expect(showFired).toBe(true);
} finally {
await cleanup(electronApp, userDataDir);
}
});

test('custom method returns stub with lifecycle methods', async () => {
let electronApp, userDataDir;
try {
({ electronApp, userDataDir } = await launchApp('custom'));
const mainWindow = await getMainWindow(electronApp);
expect(mainWindow).toBeTruthy();

await mainWindow.waitForLoadState('load', { timeout: 30000 });

const stubShape = await mainWindow.evaluate(() => {
const n = new window.Notification('Test', { body: 'test body' });

Check warning on line 256 in tests/e2e/notifications.spec.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=IsmaelMartinez_teams-for-linux&issues=AZzm5EsdlpXvOsy3EyGV&open=AZzm5EsdlpXvOsy3EyGV&pullRequest=2329
return {
hasAddEventListener: typeof n.addEventListener === 'function',
hasRemoveEventListener: typeof n.removeEventListener === 'function',
hasClose: typeof n.close === 'function',
hasDispatchEvent: typeof n.dispatchEvent === 'function',
};
});

expect(stubShape.hasAddEventListener).toBe(true);
expect(stubShape.hasRemoveEventListener).toBe(true);
expect(stubShape.hasClose).toBe(true);
expect(stubShape.hasDispatchEvent).toBe(true);
} finally {
await cleanup(electronApp, userDataDir);
}
});

test('web method does not break native Notification lifecycle', async () => {
let electronApp, userDataDir;
try {
({ electronApp, userDataDir } = await launchApp('web'));
const mainWindow = await getMainWindow(electronApp);
expect(mainWindow).toBeTruthy();

await mainWindow.waitForLoadState('load', { timeout: 30000 });

// For web method, verify the override exists and returns something
// (either a real Notification or a bare fallback). The key assertion is
// that calling new Notification() does not throw.
const result = await mainWindow.evaluate(() => {
try {
const n = new window.Notification('Test', { body: 'test body' });

Check warning on line 288 in tests/e2e/notifications.spec.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=IsmaelMartinez_teams-for-linux&issues=AZzm5EsdlpXvOsy3EyGW&open=AZzm5EsdlpXvOsy3EyGW&pullRequest=2329
return {
didNotThrow: true,
type: typeof n,
hasOnclick: 'onclick' in n,
};
} catch (e) {
return { didNotThrow: false, error: e.message };
}
});

expect(result.didNotThrow).toBe(true);
expect(result.type).toBe('object');
expect(result.hasOnclick).toBe(true);
} finally {
await cleanup(electronApp, userDataDir);
}
});

test('multiple notifications can be created sequentially', async () => {
let electronApp, userDataDir;
try {
({ electronApp, userDataDir } = await launchApp('electron'));
const mainWindow = await getMainWindow(electronApp);
expect(mainWindow).toBeTruthy();

await mainWindow.waitForLoadState('load', { timeout: 30000 });

// This is the core regression test: Teams creates multiple notifications
// in sequence. If the stub is missing lifecycle methods, Teams' state
// machine breaks and stops calling new Notification() after the first.
const result = await mainWindow.evaluate(() => {
const stubs = [];
for (let i = 0; i < 5; i++) {
const n = new window.Notification(`Test ${i}`, { body: `body ${i}` });

Check warning on line 322 in tests/e2e/notifications.spec.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=IsmaelMartinez_teams-for-linux&issues=AZzm5EsdlpXvOsy3EyGX&open=AZzm5EsdlpXvOsy3EyGX&pullRequest=2329
stubs.push({
hasAddEventListener: typeof n.addEventListener === 'function',
hasClose: typeof n.close === 'function',
});
}
return stubs;
});

expect(result).toHaveLength(5);
for (const stub of result) {
expect(stub.hasAddEventListener).toBe(true);
expect(stub.hasClose).toBe(true);
}
} finally {
await cleanup(electronApp, userDataDir);
}
});
});
Loading