magnitude-core
Version:
Magnitude e2e testing agent
294 lines (293 loc) • 12 kB
JavaScript
// Keeps track of current tab, enables describing open tabs, and switching between open tabs
import logger from "@/logger";
import EventEmitter from "eventemitter3";
import { retryOnErrorIsSuccess } from "@/common";
export class TabManager {
context;
activePage;
options;
pollInterval;
events;
constructor(context, options = {}) {
this.context = context;
this.options = {
switchOnActivity: true,
...options
};
this.events = new EventEmitter();
// Track new pages
this.context.on('page', this.onPageCreated.bind(this));
}
async initialize() {
// Initialize existing pages and tracking
await this.initializeExistingPages();
}
async initializeExistingPages() {
const pages = this.context.pages();
logger.debug(`initializeExistingPages: found ${pages.length} existing pages`);
// Set up existing pages
for (const page of pages) {
logger.debug(`Setting up page: ${page.url()}`);
await this.onPageCreated(page);
}
// Start polling for activity if enabled
if (this.options.switchOnActivity) {
logger.debug('Starting activity polling');
this.startActivityPolling();
}
}
async onPageCreated(page) {
logger.debug(`onPageCreated called for: ${page.url()}`);
// If this is the only page or no active page set, make it active
if (!this.activePage || this.context.pages().length === 1) {
this.setActivePage(page);
}
// Only set up tracking if switchOnActivity is enabled
if (this.options.switchOnActivity) {
// Set up activity tracking immediately
await this.setupPageTracking(page);
// Re-inject tracking after navigation
page.on('load', async () => {
await this.setupPageTracking(page);
// If this is the active page and it navigated from about:blank, re-emit tabChanged
if (this.activePage === page && page.url() !== 'about:blank') {
logger.debug(`Re-emitting tabChanged after navigation to: ${page.url()}`);
this.events.emit('tabChanged', page);
}
});
// Also inject on DOMContentLoaded for faster setup
page.on('domcontentloaded', async () => {
await this.setupPageTracking(page);
});
}
// Don't automatically switch to new tabs - let the user decide
// This prevents unwanted switches when links open in new tabs
// Clean up when page closes
page.on('close', () => {
if (this.activePage === page) {
const pages = this.context.pages();
if (pages.length > 0) {
this.setActivePage(pages[0]);
}
}
});
// Start polling if this is the first page and switchOnActivity is enabled
if (this.context.pages().length === 1 && this.options.switchOnActivity && !this.pollInterval) {
logger.debug('Starting activity polling from onPageCreated (first page)');
this.startActivityPolling();
}
}
async setupPageTracking(page) {
// Inject the tracking script with retries
await retryOnErrorIsSuccess(async () => {
const currentUrl = page.url();
logger.debug(`Setting up tracking for: ${currentUrl}`);
// Inject activity tracking
await page.evaluate(() => {
// Initialize activity tracking storage
if (!window.__tabActivityTime) {
window.__tabActivityTime = 0;
}
// Set initial activity time to NOW since we're setting up tracking
// This ensures the initial page is considered active
window.__tabActivityTime = Date.now();
// Track any user interaction
const recordActivity = () => {
window.__tabActivityTime = Date.now();
};
// Remove any existing listeners to avoid duplicates
const listeners = window.__tabListeners;
if (listeners) {
listeners.forEach((l) => {
document.removeEventListener(l.event, l.handler, true);
window.removeEventListener(l.event, l.handler, true);
});
}
// Store references to our listeners
const newListeners = [];
// Helper to add tracked event listener
const addTrackedListener = (target, event, handler) => {
target.addEventListener(event, handler, true);
newListeners.push({ event, handler });
};
// Throttled mouse move handler
let lastMouseMoveTime = 0;
const throttledMouseMove = () => {
const now = Date.now();
if (now - lastMouseMoveTime > 500) {
lastMouseMoveTime = now;
recordActivity();
}
};
// Mouse events
addTrackedListener(document, 'mousedown', recordActivity);
addTrackedListener(document, 'click', recordActivity);
addTrackedListener(document, 'mousemove', throttledMouseMove);
addTrackedListener(document, 'mouseenter', recordActivity);
// Keyboard events
addTrackedListener(document, 'keydown', recordActivity);
// Focus events
addTrackedListener(window, 'focus', recordActivity);
// Scroll events
let lastScrollTime = 0;
const throttledScroll = () => {
const now = Date.now();
if (now - lastScrollTime > 500) {
lastScrollTime = now;
recordActivity();
}
};
addTrackedListener(document, 'scroll', throttledScroll);
// Visibility change
addTrackedListener(document, 'visibilitychange', () => {
if (!document.hidden) {
recordActivity();
}
});
// Store listeners for cleanup
window.__tabListeners = newListeners;
});
}, { mode: 'retry_all', delayMs: 200, retryLimit: 5 });
}
startActivityPolling() {
// Poll for activity every 200ms
this.pollInterval = setInterval(async () => {
const pages = this.context.pages();
let mostRecentPage = null;
let mostRecentTime = 0;
// Find the page with the most recent activity
for (const page of pages) {
if (page.isClosed())
continue;
try {
const lastActivity = await page.evaluate(() => {
// Initialize if it doesn't exist (defensive programming)
if (typeof window.__tabActivityTime === 'undefined') {
window.__tabActivityTime = 0;
// If tracking wasn't set up, return -1 as a signal
if (!window.__tabListeners) {
return -1;
}
}
return window.__tabActivityTime;
});
// If tracking isn't set up (-1), inject it now
if (lastActivity === -1) {
logger.debug(`Tracking not set up for ${page.url()}, injecting now...`);
await this.setupPageTracking(page);
continue; // Skip this page for this polling cycle
}
if (lastActivity > mostRecentTime) {
mostRecentTime = lastActivity;
mostRecentPage = page;
}
}
catch (e) {
// Page might be navigating
}
}
// Switch to the most recently active page if it's different and activity is recent
if (mostRecentPage &&
mostRecentPage !== this.activePage &&
mostRecentTime > Date.now() - 1000) { // Activity within last second
logger.trace(`Activity detected on ${mostRecentPage.url()}, switching...`);
this.setActivePage(mostRecentPage);
}
}, 200);
}
setActivePage(page) {
const isInitialPage = !this.activePage;
if (this.activePage === page && !isInitialPage) {
return;
}
logger.debug(`Active tab changed to: ${page.url()}`);
this.activePage = page;
this.events.emit('tabChanged', page);
page.removeAllListeners('framenavigated');
page.on('framenavigated', async (frame) => {
if (frame === page.mainFrame()) {
// const url = frame.url();
// await logNavigation(url);
}
});
}
async switchTab(index) {
const pages = this.context.pages();
if (index < 0 || index >= pages.length) {
throw new Error(`Invalid tab index: ${index}`);
}
const page = pages[index];
logger.debug(`Switching to tab ${index} (${page.url()})`);
await page.bringToFront();
this.setActivePage(page);
}
getActivePage() {
return this.activePage;
}
getPages() {
return this.context.pages();
}
async retrieveState() {
let activeIndex = -1;
let tabs = [];
for (const [i, page] of this.context.pages().entries()) {
if (page === this.activePage) {
activeIndex = i;
}
let title;
try {
title = await page.title();
}
catch {
logger.warn('Could not load page title while retrieving tab state');
title = '(could not load title)';
}
const url = page.url();
tabs.push({ title, url });
}
return {
activeTab: activeIndex,
tabs: tabs
};
}
async debugVisibilityStates() {
const states = await Promise.all(this.context.pages().map(async (page, i) => {
try {
const hasFocus = await page.evaluate(() => ({
hasFocus: document.hasFocus(),
hidden: document.hidden,
visibilityState: document.visibilityState
}));
return { index: i, url: page.url(), ...hasFocus };
}
catch {
return { index: i, url: page.url(), error: true };
}
}));
return states;
}
async debugFocusStates() {
const states = await Promise.all(this.context.pages().map(async (page, i) => {
try {
const state = await page.evaluate(() => ({
hasFocus: document.hasFocus(),
isVisible: document.visibilityState === 'visible',
isHidden: document.hidden,
url: window.location.href
}));
return { index: i, ...state };
}
catch {
return { index: i, url: page.url(), error: true };
}
}));
console.log('Focus states:', states);
return states;
}
// Clean up
destroy() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
}
}