camofox-browser/tests/unit/tabLeak.test.js

400 lines
13 KiB
JavaScript

/**
* Tests for tab leak fixes: safePageClose, getTotalTabCount, and orphan page reaper.
*
* Validates:
* 1. safePageClose force-closes pages on timeout and cleans up listeners
* 2. getTotalTabCount uses real Playwright page count for backpressure
* 3. Orphan page reaper identifies and closes untracked pages
*/
import { describe, test, expect } from '@jest/globals';
import { jest } from '@jest/globals';
// ============================================================================
// safePageClose (extracted logic)
// ============================================================================
const PAGE_CLOSE_TIMEOUT_MS = 5000;
/**
* Mirrors the safePageClose logic from server.js.
* Returns: { action: 'skipped'|'closed'|'force_closed', removeAllListenersCalled: boolean }
*/
async function safePageClose(page) {
if (!page || page.isClosed()) return { action: 'skipped', removeAllListenersCalled: false };
try {
await Promise.race([
page.close({ runBeforeUnload: false }),
new Promise((_, reject) => setTimeout(() => reject(new Error('page close timed out')), PAGE_CLOSE_TIMEOUT_MS)),
]);
return { action: 'closed', removeAllListenersCalled: false };
} catch (e) {
try { await page.close({ runBeforeUnload: false }); } catch (_) {}
page.removeAllListeners();
return { action: 'force_closed', removeAllListenersCalled: true };
}
}
// ============================================================================
// getTotalTabCount (extracted logic)
// ============================================================================
/**
* Mirrors getTotalTabCount from server.js.
* Uses context.pages().length when available, falls back to bookkeeping.
*/
function getTotalTabCount(sessions) {
let total = 0;
for (const session of sessions.values()) {
try {
total += session.context.pages().length;
} catch (_) {
for (const group of session.tabGroups.values()) total += group.size;
}
}
return total;
}
// ============================================================================
// Orphan page reaper (extracted logic)
// ============================================================================
/**
* Mirrors the orphan reaper interval logic from server.js.
* Returns array of pages that were reaped.
*/
function findOrphanPages(sessions) {
const orphans = [];
for (const session of sessions.values()) {
if (session._closing) continue;
let contextPages;
try {
contextPages = session.context.pages();
} catch (_) {
continue;
}
const registered = new Set();
for (const group of session.tabGroups.values()) {
for (const tabState of group.values()) registered.add(tabState.page);
}
for (const page of contextPages) {
if (!registered.has(page)) {
orphans.push(page);
}
}
}
return orphans;
}
// ============================================================================
// Mock helpers
// ============================================================================
function createMockPage({ closeDelay = 0, closeFails = false, isClosed = false } = {}) {
let closed = isClosed;
let removeAllListenersCalled = false;
return {
isClosed: () => closed,
close: jest.fn(async ({ runBeforeUnload } = {}) => {
if (closeFails) throw new Error('Target closed');
if (closeDelay > 0) {
await new Promise(resolve => setTimeout(resolve, closeDelay));
}
closed = true;
}),
removeAllListeners: jest.fn(() => { removeAllListenersCalled = true; }),
_removeAllListenersCalled: () => removeAllListenersCalled,
};
}
// ============================================================================
// Tests: safePageClose
// ============================================================================
describe('safePageClose', () => {
test('skips null page', async () => {
const result = await safePageClose(null);
expect(result.action).toBe('skipped');
});
test('skips undefined page', async () => {
const result = await safePageClose(undefined);
expect(result.action).toBe('skipped');
});
test('skips already-closed page', async () => {
const page = createMockPage({ isClosed: true });
const result = await safePageClose(page);
expect(result.action).toBe('skipped');
expect(page.close).not.toHaveBeenCalled();
});
test('closes page successfully on happy path', async () => {
const page = createMockPage();
const result = await safePageClose(page);
expect(result.action).toBe('closed');
expect(page.close).toHaveBeenCalledWith({ runBeforeUnload: false });
expect(result.removeAllListenersCalled).toBe(false);
});
test('force-closes and removes listeners when close throws', async () => {
let callCount = 0;
const page = {
isClosed: () => false,
close: jest.fn(async () => {
callCount++;
if (callCount === 1) throw new Error('close failed');
// Second call succeeds (force-close)
}),
removeAllListeners: jest.fn(),
};
const result = await safePageClose(page);
expect(result.action).toBe('force_closed');
expect(result.removeAllListenersCalled).toBe(true);
expect(page.close).toHaveBeenCalledTimes(2);
expect(page.removeAllListeners).toHaveBeenCalled();
});
test('force-closes when page.close hangs past timeout', async () => {
// Use a very short timeout for test speed
const SHORT_TIMEOUT = 50;
async function safePageCloseShort(page) {
if (!page || page.isClosed()) return { action: 'skipped', removeAllListenersCalled: false };
try {
await Promise.race([
page.close({ runBeforeUnload: false }),
new Promise((_, reject) => setTimeout(() => reject(new Error('page close timed out')), SHORT_TIMEOUT)),
]);
return { action: 'closed', removeAllListenersCalled: false };
} catch (e) {
try { await page.close({ runBeforeUnload: false }); } catch (_) {}
page.removeAllListeners();
return { action: 'force_closed', removeAllListenersCalled: true };
}
}
// Simulate a page whose close() never resolves (hung Firefox process)
let callCount = 0;
const page = {
isClosed: () => false,
close: jest.fn(() => {
callCount++;
if (callCount === 1) return new Promise(() => {}); // never resolves
return Promise.resolve(); // force-close succeeds
}),
removeAllListeners: jest.fn(),
};
const result = await safePageCloseShort(page);
expect(result.action).toBe('force_closed');
expect(result.removeAllListenersCalled).toBe(true);
expect(page.removeAllListeners).toHaveBeenCalled();
expect(page.close).toHaveBeenCalledTimes(2);
});
test('handles force-close also failing gracefully', async () => {
const page = {
isClosed: () => false,
close: jest.fn(async () => { throw new Error('always fails'); }),
removeAllListeners: jest.fn(),
};
const result = await safePageClose(page);
expect(result.action).toBe('force_closed');
expect(page.removeAllListeners).toHaveBeenCalled();
});
test('passes runBeforeUnload: false to skip unload handlers', async () => {
const page = createMockPage();
await safePageClose(page);
expect(page.close).toHaveBeenCalledWith({ runBeforeUnload: false });
});
});
// ============================================================================
// Tests: getTotalTabCount
// ============================================================================
describe('getTotalTabCount', () => {
test('returns 0 for empty sessions map', () => {
expect(getTotalTabCount(new Map())).toBe(0);
});
test('uses context.pages().length when context is alive', () => {
const sessions = new Map([
['user1', {
context: { pages: () => [{}, {}, {}] }, // 3 real pages
tabGroups: new Map([['list1', new Map([['tab1', {}]])]]), // only 1 tracked
}],
]);
// Should use real count (3), not bookkeeping (1)
expect(getTotalTabCount(sessions)).toBe(3);
});
test('falls back to bookkeeping when context.pages() throws', () => {
const sessions = new Map([
['user1', {
context: { pages: () => { throw new Error('context dead'); } },
tabGroups: new Map([
['list1', new Map([['tab1', {}], ['tab2', {}]])],
['list2', new Map([['tab3', {}]])],
]),
}],
]);
expect(getTotalTabCount(sessions)).toBe(3);
});
test('sums across multiple sessions', () => {
const sessions = new Map([
['user1', { context: { pages: () => [{}, {}] }, tabGroups: new Map() }],
['user2', { context: { pages: () => [{}] }, tabGroups: new Map() }],
['user3', { context: { pages: () => [{}, {}, {}, {}] }, tabGroups: new Map() }],
]);
expect(getTotalTabCount(sessions)).toBe(7);
});
test('mixed: some contexts alive, some dead', () => {
const sessions = new Map([
['user1', { context: { pages: () => [{}, {}] }, tabGroups: new Map() }],
['user2', {
context: { pages: () => { throw new Error('dead'); } },
tabGroups: new Map([['list1', new Map([['t1', {}], ['t2', {}], ['t3', {}]])]]),
}],
]);
// user1: 2 (real), user2: 3 (fallback)
expect(getTotalTabCount(sessions)).toBe(5);
});
test('leaked pages are visible in real count but not bookkeeping', () => {
const trackedPage = { id: 'tracked' };
const leakedPage1 = { id: 'leaked1' };
const leakedPage2 = { id: 'leaked2' };
const sessions = new Map([
['user1', {
context: { pages: () => [trackedPage, leakedPage1, leakedPage2] },
tabGroups: new Map([['list1', new Map([['tab1', { page: trackedPage }]])]]),
}],
]);
// Real count = 3 (includes leaks), bookkeeping would say 1
expect(getTotalTabCount(sessions)).toBe(3);
});
});
// ============================================================================
// Tests: orphan page reaper
// ============================================================================
describe('findOrphanPages (orphan page reaper)', () => {
test('returns empty when no sessions', () => {
expect(findOrphanPages(new Map())).toEqual([]);
});
test('returns empty when all pages are tracked', () => {
const page1 = { id: 'p1' };
const page2 = { id: 'p2' };
const sessions = new Map([
['user1', {
_closing: false,
context: { pages: () => [page1, page2] },
tabGroups: new Map([
['list1', new Map([['tab1', { page: page1 }], ['tab2', { page: page2 }]])],
]),
}],
]);
expect(findOrphanPages(sessions)).toEqual([]);
});
test('identifies orphan pages not in tabGroups', () => {
const tracked = { id: 'tracked' };
const orphan1 = { id: 'orphan1' };
const orphan2 = { id: 'orphan2' };
const sessions = new Map([
['user1', {
_closing: false,
context: { pages: () => [tracked, orphan1, orphan2] },
tabGroups: new Map([['list1', new Map([['tab1', { page: tracked }]])]]),
}],
]);
const orphans = findOrphanPages(sessions);
expect(orphans).toHaveLength(2);
expect(orphans).toContain(orphan1);
expect(orphans).toContain(orphan2);
});
test('skips sessions that are closing', () => {
const orphan = { id: 'orphan' };
const sessions = new Map([
['user1', {
_closing: true,
context: { pages: () => [orphan] },
tabGroups: new Map(),
}],
]);
expect(findOrphanPages(sessions)).toEqual([]);
});
test('skips sessions where context.pages() throws', () => {
const sessions = new Map([
['user1', {
_closing: false,
context: { pages: () => { throw new Error('context destroyed'); } },
tabGroups: new Map(),
}],
]);
expect(findOrphanPages(sessions)).toEqual([]);
});
test('finds orphans across multiple sessions', () => {
const tracked1 = { id: 't1' };
const tracked2 = { id: 't2' };
const orphan1 = { id: 'o1' };
const orphan2 = { id: 'o2' };
const sessions = new Map([
['user1', {
_closing: false,
context: { pages: () => [tracked1, orphan1] },
tabGroups: new Map([['list1', new Map([['tab1', { page: tracked1 }]])]]),
}],
['user2', {
_closing: false,
context: { pages: () => [tracked2, orphan2] },
tabGroups: new Map([['list1', new Map([['tab1', { page: tracked2 }]])]]),
}],
]);
const orphans = findOrphanPages(sessions);
expect(orphans).toHaveLength(2);
expect(orphans).toContain(orphan1);
expect(orphans).toContain(orphan2);
});
test('handles session with empty tabGroups (all pages are orphans)', () => {
const page1 = { id: 'p1' };
const page2 = { id: 'p2' };
const sessions = new Map([
['user1', {
_closing: false,
context: { pages: () => [page1, page2] },
tabGroups: new Map(),
}],
]);
const orphans = findOrphanPages(sessions);
expect(orphans).toHaveLength(2);
});
test('handles multiple tabGroups per session correctly', () => {
const p1 = { id: 'p1' };
const p2 = { id: 'p2' };
const p3 = { id: 'p3' };
const orphan = { id: 'orphan' };
const sessions = new Map([
['user1', {
_closing: false,
context: { pages: () => [p1, p2, p3, orphan] },
tabGroups: new Map([
['list1', new Map([['tab1', { page: p1 }]])],
['list2', new Map([['tab2', { page: p2 }], ['tab3', { page: p3 }]])],
]),
}],
]);
const orphans = findOrphanPages(sessions);
expect(orphans).toEqual([orphan]);
});
});