Skip to main content
Glama
psi.ts42.2 kB
import puppeteer, { Browser, Page } from 'puppeteer'; import { PSITimesheetEntry } from '../types/index.js'; export class PSITimesheetIntegration { private url: string; private username: string; private password: string; private browser: Browser | null = null; constructor(url: string, username: string, password: string) { this.url = url; this.username = username; this.password = password; } async initialize(): Promise<void> { this.browser = await puppeteer.launch({ headless: false, // Set to true for production args: ['--no-sandbox', '--disable-setuid-sandbox'] }); } async authenticate(page: Page): Promise<void> { // Navigate to the PSI timesheet page await page.goto(this.url, { waitUntil: 'networkidle2' }); // Handle browser authentication dialog await page.authenticate({ username: this.username, password: this.password }); // Wait for page to load after authentication await page.waitForSelector('body', { timeout: 30000 }); } /** * Get available tasks from PSI for a specific date * Returns hierarchical task list for user to select from */ async getTasks(date: string): Promise<{ success: boolean; message: string; tasks?: any }> { if (!this.browser) { await this.initialize(); } const page = await this.browser!.newPage(); // Set a larger viewport to see the complete webpage await page.setViewport({ width: 1920, height: 1080 }); try { // Authenticate and land on the summary page await this.authenticate(page); await new Promise(resolve => setTimeout(resolve, 2000)); // Find and click the correct timesheet row for this date const clicked = await this.findAndClickTimesheetRow(page, date); if (!clicked) { return { success: false, message: `❌ Could not find timesheet period for date: ${date}` }; } // Wait for navigation to the detail page await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 10000 }).catch(() => {}); await new Promise(resolve => setTimeout(resolve, 3000)); // Get tasks from the dialog const taskResult = await this.extractTasksFromDialog(page); return taskResult; } catch (error) { console.error('Error getting tasks:', error); return { success: false, message: `Failed to get tasks: ${error instanceof Error ? error.message : String(error)}` }; } finally { await page.close(); } } /** * Fill timesheet with selected task (saves but does not submit for approval) * @param date - Date in YYYY-MM-DD format * @param taskIndex - Index of task from getTasks() result * @param description - Timesheet description (max 255 chars) * @param hours - Hours worked (e.g., "8h") */ async fillTimesheet(date: string, taskIndex: number, description: string, hours: string = '8h'): Promise<{ success: boolean; message: string }> { if (!this.browser) { await this.initialize(); } const page = await this.browser!.newPage(); // Set a larger viewport to see the complete webpage await page.setViewport({ width: 1920, height: 1080 }); try { // Authenticate and land on the summary page await this.authenticate(page); await new Promise(resolve => setTimeout(resolve, 2000)); // Find and click the correct timesheet row for this date const clicked = await this.findAndClickTimesheetRow(page, date); if (!clicked) { return { success: false, message: `❌ Could not find timesheet period for date: ${date}` }; } // Wait for navigation to the detail page await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 10000 }).catch(() => {}); await new Promise(resolve => setTimeout(resolve, 3000)); // Fill timesheet with selected task const fillResult = await this.fillTimesheetWithTask(page, taskIndex, description, hours); return fillResult; } catch (error) { console.error('Error filling timesheet:', error); return { success: false, message: `Failed to fill timesheet: ${error instanceof Error ? error.message : String(error)}` }; } finally { await page.close(); } } /** * Fill and submit timesheet for final approval in one step * @param date - Date in YYYY-MM-DD format * @param taskIndex - Index of task from getTasks() result * @param description - Timesheet description (max 255 chars) * @param hours - Hours worked (e.g., "8h") */ async fillAndSubmitTimesheet(date: string, taskIndex: number, description: string, hours: string = '8h'): Promise<{ success: boolean; message: string }> { if (!this.browser) { await this.initialize(); } const page = await this.browser!.newPage(); // Set a larger viewport to see the complete webpage await page.setViewport({ width: 1920, height: 1080 }); try { // Authenticate and land on the summary page await this.authenticate(page); await new Promise(resolve => setTimeout(resolve, 2000)); // Find and click the correct timesheet row for this date const clicked = await this.findAndClickTimesheetRow(page, date); if (!clicked) { return { success: false, message: `❌ Could not find timesheet period for date: ${date}` }; } // Wait for navigation to the detail page await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 10000 }).catch(() => {}); await new Promise(resolve => setTimeout(resolve, 3000)); // Fill timesheet with selected task const fillResult = await this.fillTimesheetWithTask(page, taskIndex, description, hours); if (!fillResult.success) { return fillResult; } // Now submit for approval const submitResult = await this.submitForApproval(page, description); return submitResult; } catch (error) { console.error('Error filling and submitting timesheet:', error); return { success: false, message: `Failed to fill and submit timesheet: ${error instanceof Error ? error.message : String(error)}` }; } finally { await page.close(); } } /** * Submit already-filled timesheet for final approval * @param date - Date in YYYY-MM-DD format * @param description - Submission comment (max 255 chars) */ async submitTimesheetForApproval(date: string): Promise<{ success: boolean; message: string }> { if (!this.browser) { await this.initialize(); } const page = await this.browser!.newPage(); // Set a larger viewport to see the complete webpage await page.setViewport({ width: 1920, height: 1080 }); try { // Authenticate and land on the summary page await this.authenticate(page); await new Promise(resolve => setTimeout(resolve, 2000)); // Find and click the correct timesheet row for this date const clicked = await this.findAndClickTimesheetRow(page, date); if (!clicked) { return { success: false, message: `❌ Could not find timesheet period for date: ${date}` }; } // Wait for navigation to the detail page await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 10000 }).catch(() => {}); await new Promise(resolve => setTimeout(resolve, 3000)); // Always read the comment from the existing timesheet const existingComment = await this.readTimesheetComment(page); if (!existingComment) { return { success: false, message: '❌ Could not read the comment/description from the timesheet. Please ensure the timesheet is filled with a description.' }; } // Submit for approval const submitResult = await this.submitForApproval(page, existingComment); return submitResult; } catch (error) { console.error('Error submitting timesheet for approval:', error); return { success: false, message: `Failed to submit timesheet for approval: ${error instanceof Error ? error.message : String(error)}` }; } finally { await page.close(); } } private async findAndClickTimesheetRow(page: Page, dateStr: string): Promise<boolean> { // Parse the date to create the expected pattern const [year, month, day] = dateStr.split('-').map(Number); const date = new Date(year, month - 1, day); const monthName = date.toLocaleString('en-US', { month: 'long' }); // Create the expected period text pattern const periodPattern = `${day}-${monthName}-${year} (${month}/${day}/${year} - ${month}/${day}/${year})`; // Find all cells with XmlGridWrappedCell class const cells = await page.$$('td.XmlGridWrappedCell'); for (const cell of cells) { // Check if this cell contains an 'a' tag with class XmlGridNonLinkCell const link = await cell.$('a.XmlGridNonLinkCell'); if (link) { const cellText = await page.evaluate(el => el.textContent?.trim() || '', link); // Check if the text matches our target period if (cellText === periodPattern || cellText.includes(`(${month}/${day}/${year}`)) { // Find the parent row and get the first cell's link using XPath const row = await cell.evaluateHandle((el) => { let current = el.parentElement; while (current && current.tagName !== 'TR') { current = current.parentElement; } return current; }); if (row && row.asElement()) { // Find the first 'a' tag in the row const links = await row.asElement()!.$$('a'); if (links.length > 0) { // Click the first link (should be the timesheet name link) await links[0].click(); return true; } } } } } return false; } /** * Extract tasks from the dialog - used by getTasks() */ private async extractTasksFromDialog(page: Page): Promise<{ success: boolean; message: string; tasks?: any }> { const logs: string[] = []; // Step 1: Click Timesheet tab to ensure we're on the right tab logs.push('🔍 Looking for Timesheet tab...'); const timesheetTab = await page.$('li[id*="Home-title"]'); if (timesheetTab) { const timesheetLink = await timesheetTab.$('a'); if (timesheetLink) { await timesheetLink.click(); logs.push('✅ Clicked Timesheet tab'); } } else { logs.push('⚠️ Could not find Timesheet tab, continuing anyway...'); } await new Promise(resolve => setTimeout(resolve, 3000)); // Wait 3 seconds for tab to load // Step 2: Click "Add Row" button logs.push('🔍 Looking for Add Row button...'); const addRowButton = await page.$('a[id*="AddLine-Large"]'); if (addRowButton) { logs.push('✅ Found Add Row button'); await addRowButton.click(); logs.push('✅ Clicked Add Row button'); } else { logs.push('❌ Could not find Add Row button'); } await new Promise(resolve => setTimeout(resolve, 1000)); // Step 3: Click "Select From Existing Assignments" logs.push('🔍 Looking for "Select From Existing Assignments" option...'); const existingAssignmentsButton = await page.$('a[id*="AddNewLine-Menu16"]'); if (existingAssignmentsButton) { logs.push('✅ Found "Select From Existing Assignments" button'); await existingAssignmentsButton.click(); logs.push('✅ Clicked "Select From Existing Assignments"'); } else { logs.push('❌ Could not find "Select From Existing Assignments" button'); } // Wait for dialog iframe to load logs.push('🔍 Waiting for task list dialog iframe to load...'); // Wait up to 10 seconds for iframe to appear let dialogFrame = null; for (let i = 0; i < 10; i++) { await new Promise(resolve => setTimeout(resolve, 1000)); // Find iframe with src containing SelectTaskDlg.aspx const frames = page.frames(); dialogFrame = frames.find(frame => frame.url().includes('SelectTaskDlg.aspx')); if (dialogFrame) { logs.push(`✅ Found dialog iframe after ${i + 1} seconds: ${dialogFrame.url()}`); break; } } // Step 4: Extract task list from dialog iframe if (dialogFrame) { // Wait for tree-results to appear inside the iframe let taskListDiv = null; for (let i = 0; i < 10; i++) { await new Promise(resolve => setTimeout(resolve, 1000)); taskListDiv = await dialogFrame.$('div.tree-results'); if (taskListDiv) { logs.push(`✅ Found tree-results div inside iframe after ${i + 1} seconds`); break; } } if (taskListDiv) { logs.push('✅ Found task list dialog, now expanding all tree nodes...'); // First, expand all accordion nodes recursively let expandedCount = 0; let previousCount = -1; let iterations = 0; const maxIterations = 20; // Prevent infinite loop while (expandedCount !== previousCount && iterations < maxIterations) { previousCount = expandedCount; iterations++; // Find all expand icons and click them const newlyExpanded = await dialogFrame.evaluate(() => { const expandIcons = Array.from(document.querySelectorAll('.ms-commentexpand-iconouter')); let clicked = 0; for (const icon of expandIcons) { const parent = icon.parentElement; if (!parent) continue; // Check if this node has children container const childrenContainer = parent.parentElement?.querySelector('.outer-children-container') as HTMLElement; if (childrenContainer && childrenContainer.style.display === 'none') { // This node is collapsed, click to expand (icon as HTMLElement).click(); clicked++; } } return clicked; }); expandedCount += newlyExpanded; logs.push(`🔄 Iteration ${iterations}: Expanded ${newlyExpanded} nodes (total: ${expandedCount})`); // Wait a bit for the DOM to update after expansion if (newlyExpanded > 0) { await new Promise(resolve => setTimeout(resolve, 500)); } } logs.push(`✅ Tree expansion complete after ${iterations} iterations. Total nodes expanded: ${expandedCount}`); // Now extract all checkboxes from the fully expanded tree const taskStructure = await dialogFrame.evaluate(() => { const treeResults = document.querySelector('div.tree-results'); if (!treeResults) return { html: '', tasks: [], totalCheckboxes: 0, hierarchicalView: '' }; // Get the full HTML structure (first 5000 chars for better visibility) const html = treeResults.outerHTML.substring(0, 5000); // Find ALL checkbox inputs recursively throughout the entire expanded tree const checkboxes = Array.from(treeResults.querySelectorAll('input[type="checkbox"]')); const totalCheckboxes = checkboxes.length; const tasks = checkboxes.map((checkbox, index) => { // Get the parent element const parent = checkbox.parentElement; // Find sibling span or label with text - look at immediate siblings first let labelText = ''; let allSiblingText = ''; if (parent) { // Get all text from next siblings (spans, labels, text nodes) let sibling = checkbox.nextSibling; const siblingTexts: string[] = []; while (sibling) { if (sibling.nodeType === Node.TEXT_NODE) { const text = sibling.textContent?.trim(); if (text) siblingTexts.push(text); } else if (sibling.nodeType === Node.ELEMENT_NODE) { const elem = sibling as Element; const text = elem.textContent?.trim(); if (text) siblingTexts.push(text); } sibling = sibling.nextSibling; } allSiblingText = siblingTexts.join(' '); // Also check spans within parent const spans = Array.from(parent.querySelectorAll('span')); const spanText = spans.map(span => span.textContent?.trim()).filter(t => t).join(' '); // Prefer sibling text, fallback to span text labelText = allSiblingText || spanText; // If still no text, get all text content from parent (excluding checkbox) if (!labelText && parent.textContent) { labelText = parent.textContent.trim(); } } // Calculate depth by counting .inner-children-container ancestors let depth = 0; let ancestor = parent; const ancestorPath: string[] = []; while (ancestor && ancestor !== treeResults) { if (ancestor.classList) { const classes = Array.from(ancestor.classList); ancestorPath.push(`${ancestor.tagName}.${classes.join('.')}`); if (classes.includes('inner-children-container')) { depth++; } } ancestor = ancestor.parentElement; } return { index: index, checkboxId: checkbox.id || '', checkboxName: (checkbox as HTMLInputElement).name || '', checkboxValue: (checkbox as HTMLInputElement).value || '', checkboxClass: checkbox.className || '', labelText: labelText, depth: depth, ancestorPath: ancestorPath.slice(0, 10).join(' > '), // Show first 10 ancestors parentTag: parent?.tagName || '', parentId: parent?.id || '', parentClass: parent?.className || '', disabled: checkbox.hasAttribute('disabled'), attributes: Array.from(checkbox.attributes).reduce((acc, attr) => { acc[attr.name] = attr.value; return acc; }, {} as Record<string, string>) }; }); // Create hierarchical text view const hierarchicalLines = tasks.map(task => { const indent = ' '.repeat(task.depth); const status = task.disabled ? '[DISABLED]' : '[ENABLED]'; return `${indent}[${task.index}] ${status} ${task.labelText}`; }); const hierarchicalView = hierarchicalLines.join('\n'); return { html, tasks, totalCheckboxes, hierarchicalView }; }); logs.push(`✅ Found ${taskStructure.totalCheckboxes} total checkboxes in fully expanded tree`); const message = `${logs.join('\n')}\n\n📋 Hierarchical Tree View:\n${taskStructure.hierarchicalView}\n\n📋 HTML Structure (first 5000 chars):\n${taskStructure.html}\n\n📋 All Task Checkboxes JSON (showing all ${taskStructure.tasks.length}):\n${JSON.stringify(taskStructure.tasks, null, 2)}`; return { success: true, message, tasks: taskStructure }; } else { logs.push('❌ Could not find tree-results div inside iframe after waiting 10 seconds'); return { success: false, message: logs.join('\n') }; } } else { logs.push('❌ Could not find dialog iframe with SelectTaskDlg.aspx after waiting 10 seconds'); return { success: false, message: logs.join('\n') }; } } /** * Fill timesheet with selected task and description */ private async fillTimesheetWithTask(page: Page, taskIndex: number, description: string, hours: string): Promise<{ success: boolean; message: string }> { const logs: string[] = []; // Step 1: Click all existing checkboxes and remove tasks const checkboxCount = await page.evaluate(() => { const checkboxes = document.querySelectorAll('input[type="checkbox"]'); checkboxes.forEach((checkbox) => { if (!(checkbox as HTMLInputElement).checked) { (checkbox as HTMLElement).click(); } }); return checkboxes.length; }); logs.push(`✅ Clicked ${checkboxCount} checkboxes to remove existing tasks`); await new Promise(resolve => setTimeout(resolve, 500)); // Step 2: Click Options tab logs.push('🔍 Looking for Options tab...'); const optionsTab = await page.$('li[id*="Options-title"]'); if (optionsTab) { const optionsLink = await optionsTab.$('a'); if (optionsLink) { await optionsLink.click(); logs.push('✅ Clicked Options tab'); } } await new Promise(resolve => setTimeout(resolve, 1000)); // Step 3: Click RemoveTask button logs.push('🔍 Looking for RemoveTask button...'); const allAnchors = await page.$$('a'); for (const anchor of allAnchors) { const anchorId = await page.evaluate(el => el.id, anchor); if (anchorId && anchorId.includes('RemoveTask')) { await anchor.click(); logs.push('✅ Clicked RemoveTask button'); break; } } await new Promise(resolve => setTimeout(resolve, 1000)); // Step 4: Switch back to Timesheet tab logs.push('🔍 Switching to Timesheet tab...'); const timesheetTab = await page.$('li[id*="Home-title"]'); if (timesheetTab) { const timesheetLink = await timesheetTab.$('a'); if (timesheetLink) { await timesheetLink.click(); logs.push('✅ Clicked Timesheet tab'); } } await new Promise(resolve => setTimeout(resolve, 1000)); // Step 5: Click "Add Row" button logs.push('🔍 Clicking Add Row button...'); const addRowButton = await page.$('a[id*="AddLine-Large"]'); if (addRowButton) { await addRowButton.click(); logs.push('✅ Clicked Add Row button'); } await new Promise(resolve => setTimeout(resolve, 1000)); // Step 6: Click "Select From Existing Assignments" logs.push('🔍 Clicking "Select From Existing Assignments"...'); const existingAssignmentsButton = await page.$('a[id*="AddNewLine-Menu16"]'); if (existingAssignmentsButton) { await existingAssignmentsButton.click(); logs.push('✅ Clicked "Select From Existing Assignments"'); } // Step 7: Wait for dialog iframe logs.push('🔍 Waiting for task dialog iframe...'); let dialogFrame = null; for (let i = 0; i < 10; i++) { await new Promise(resolve => setTimeout(resolve, 1000)); const frames = page.frames(); dialogFrame = frames.find(frame => frame.url().includes('SelectTaskDlg.aspx')); if (dialogFrame) { logs.push(`✅ Found dialog iframe after ${i + 1} seconds`); break; } } if (!dialogFrame) { return { success: false, message: `${logs.join('\n')}\n❌ Could not find dialog iframe` }; } // Step 8: Wait for tree-results and expand all nodes let taskListDiv = null; for (let i = 0; i < 10; i++) { await new Promise(resolve => setTimeout(resolve, 1000)); taskListDiv = await dialogFrame.$('div.tree-results'); if (taskListDiv) { logs.push(`✅ Found tree-results div after ${i + 1} seconds`); break; } } if (!taskListDiv) { return { success: false, message: `${logs.join('\n')}\n❌ Could not find tree-results div` }; } // Expand all nodes logs.push('🔍 Expanding all tree nodes...'); let expandedCount = 0; for (let iteration = 0; iteration < 20; iteration++) { const newlyExpanded = await dialogFrame.evaluate(() => { const expandIcons = Array.from(document.querySelectorAll('.ms-commentexpand-iconouter')); let clicked = 0; for (const icon of expandIcons) { const parent = icon.parentElement; if (!parent) continue; const childrenContainer = parent.parentElement?.querySelector('.outer-children-container') as HTMLElement; if (childrenContainer && childrenContainer.style.display === 'none') { (icon as HTMLElement).click(); clicked++; } } return clicked; }); expandedCount += newlyExpanded; if (newlyExpanded === 0) break; await new Promise(resolve => setTimeout(resolve, 500)); } logs.push(`✅ Expanded ${expandedCount} tree nodes`); // Step 9: Click the checkbox at taskIndex logs.push(`🔍 Selecting checkbox at index ${taskIndex}...`); const checkboxClicked = await dialogFrame.evaluate((index) => { const checkboxes = Array.from(document.querySelectorAll('input[type="checkbox"]')); if (index >= 0 && index < checkboxes.length) { const checkbox = checkboxes[index] as HTMLInputElement; if (!checkbox.disabled) { checkbox.click(); return true; } } return false; }, taskIndex); if (!checkboxClicked) { return { success: false, message: `${logs.join('\n')}\n❌ Could not click checkbox at index ${taskIndex}` }; } logs.push(`✅ Selected checkbox at index ${taskIndex}`); // Step 10: Fill description field logs.push('🔍 Filling description field...'); const descriptionField = await dialogFrame.$('input[type="TEXT"][id*="idComment"]'); if (descriptionField) { await descriptionField.click({ clickCount: 3 }); // Select all await descriptionField.type(description); logs.push(`✅ Filled description: ${description}`); } else { return { success: false, message: `${logs.join('\n')}\n❌ Could not find description field` }; } // Step 11: Click OK button logs.push('🔍 Clicking OK button...'); const okButton = await dialogFrame.$('input[type="button"][value="OK"]'); if (okButton) { await okButton.click(); logs.push('✅ Clicked OK button'); } else { return { success: false, message: `${logs.join('\n')}\n❌ Could not find OK button` }; } // Wait for dialog to close logs.push('⏳ Waiting for dialog to close...'); await new Promise(resolve => setTimeout(resolve, 2000)); // Step 12: Wait for timesheet table to fully load with date columns logs.push('🔍 Waiting for timesheet table to load with date columns...'); let tableReady = false; for (let i = 0; i < 15; i++) { const hasDateColumn = await page.evaluate(() => { const table = document.querySelector('table[id*="TimesheetPartJSGridControl_rightpane_mainTable"]'); if (!table) return false; const tbody = table.children[0]; if (!tbody) return false; const secondRow = tbody.children[1]; if (!secondRow) return false; // Check if second cell exists (date column) return secondRow.children.length >= 2; }); if (hasDateColumn) { tableReady = true; logs.push(`✅ Table ready with date column after ${i + 1} seconds`); break; } await new Promise(resolve => setTimeout(resolve, 1000)); } if (!tableReady) { return { success: false, message: `${logs.join('\n')}\n❌ Table did not load with date column after 15 seconds` }; } // Step 13: Find and click the FIRST "Actual" hours cell (empty cell in row 2) // Table structure: Row 1=header, Row 2=First Actual (EMPTY - this is what we need), Row 3=First Planned, Row 4=Second Actual (0h - auto-populated), Row 5=Second Planned logs.push('🔍 Looking for the first Actual hours cell (empty cell in row 2)...'); // Debug: Render complete table HTML const tableHTML = await page.evaluate(() => { const table = document.querySelector('table[id*="TimesheetPartJSGridControl_rightpane_mainTable"]'); if (!table) return 'Table not found'; return table.outerHTML; }); logs.push(`📋 Complete Table HTML (first 10000 chars):\n${tableHTML.substring(0, 10000)}`); // Debug: Log detailed table structure const tableInfo = await page.evaluate(() => { const table = document.querySelector('table[id*="TimesheetPartJSGridControl_rightpane_mainTable"]'); if (!table) return { rows: 0, structure: 'Table not found' }; const tbody = table.querySelector('tbody'); if (!tbody) return { rows: 0, structure: 'No tbody found' }; const rows = tbody.querySelectorAll('tr'); const structure = Array.from(rows).slice(0, 6).map((row, idx) => { const cells = row.querySelectorAll('td, th'); const cellContents = Array.from(cells).map((c, i) => { const text = c.textContent?.trim() || '(empty)'; return `Col${i + 1}:"${text}"`; }).join(', '); return `Row${idx + 1}: ${cells.length} cells [${cellContents}]`; }).join('; '); return { rows: rows.length, structure }; }); logs.push(`📊 Table structure: ${tableInfo.structure}`); // SharePoint JSGrid approach: Use direct table path and mousedown/mouseup events logs.push('🔍 Using SharePoint JSGrid approach with hidden input field...'); // Function to select cell and set value - needs to run TWICE for SharePoint grid const setCellValue = async (value: string, attempt: number) => { const result = await page.evaluate(async (val) => { const table = document.querySelector('table[id*="TimesheetPartJSGridControl_rightpane_mainTable"]'); if (!table) return { success: false, reason: 'Table not found' }; const tbody = table.children[0]; if (!tbody) return { success: false, reason: 'Tbody not found' }; const secondRow = tbody.children[1]; // Second row (first is header) if (!secondRow) return { success: false, reason: 'Second row not found' }; const secondCell = secondRow.children[1]; // Second cell if (!secondCell) return { success: false, reason: 'Second cell not found' }; // Simulate mousedown/mouseup to select cell const rect = secondCell.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; const mousedown = new MouseEvent('mousedown', { view: window, bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 }); const mouseup = new MouseEvent('mouseup', { view: window, bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 }); secondCell.dispatchEvent(mousedown); secondCell.dispatchEvent(mouseup); // Wait for edit mode to activate await new Promise(resolve => setTimeout(resolve, 500)); // Find and set the hidden input field value const input = document.getElementById('jsgrid_editbox') as HTMLInputElement; if (!input) return { success: false, reason: 'jsgrid_editbox input not found' }; // Focus the input first input.focus(); // Set the value input.value = val; // Trigger input event input.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); // Trigger keyup event (some grids need this) const keyupEvent = new KeyboardEvent('keyup', { bubbles: true, cancelable: true, key: val.charAt(val.length - 1) }); input.dispatchEvent(keyupEvent); return { success: true, reason: `Set input value to "${val}"`, inputValue: input.value }; }, value); logs.push(`✅ Attempt ${attempt}: ${result.reason}`); return result; }; // Run the set value operation TWICE (as discovered in manual testing) const result1 = await setCellValue(hours, 1); if (!result1.success) { return { success: false, message: `${logs.join('\n')}\n❌ First attempt failed: ${result1.reason}` }; } await new Promise(resolve => setTimeout(resolve, 500)); const result2 = await setCellValue(hours, 2); if (!result2.success) { return { success: false, message: `${logs.join('\n')}\n❌ Second attempt failed: ${result2.reason}` }; } logs.push('✅ Value set successfully after 2 attempts'); await new Promise(resolve => setTimeout(resolve, 1000)); // Step 14: Click Save button logs.push('🔍 Clicking Save button...'); const saveButton = await page.$('a[id*="Save-Large"]'); if (saveButton) { await saveButton.click(); logs.push('✅ Clicked Save button'); } else { return { success: false, message: `${logs.join('\n')}\n❌ Could not find Save button` }; } // Wait for save to complete await new Promise(resolve => setTimeout(resolve, 3000)); return { success: true, message: `${logs.join('\n')}\n\n✅ Timesheet filled successfully!` }; } private async readTimesheetComment(page: Page): Promise<string | null> { try { // Path: table > tbody > 2nd tr > 5th td > 1st span // This gives us the comment text directly from the span // Find the table containing the timesheet data const table = await page.$('table[id*="TimesheetPartJSGridControl"]'); if (!table) { console.log('Timesheet table not found'); return null; } // Get tbody const tbody = await table.$('tbody'); if (!tbody) { console.log('Table tbody not found'); return null; } // Get all rows const rows = await tbody.$$('tr[role="row"]'); if (rows.length < 2) { console.log('Not enough rows in table'); return null; } // Get the 2nd row (index 1) - first data row const dataRow = rows[1]; // Get all cells const cells = await dataRow.$$('td'); if (cells.length < 5) { console.log('Not enough cells in row'); return null; } // Get the 5th cell (index 4) - comment column const commentCell = cells[4]; // Get the first span in this cell const span = await commentCell.$('span.jsgrid-control-text'); if (!span) { console.log('Comment span not found'); return null; } // Read the text content from the span const commentText = await page.evaluate((el: any) => el.textContent?.trim(), span); if (commentText && commentText.length > 0) { console.log(`Successfully read comment from span: ${commentText}`); return commentText; } console.log('Comment span is empty'); return null; } catch (error) { console.error('Error reading timesheet comment:', error); return null; } } private async submitForApproval(page: Page, description: string): Promise<{ success: boolean; message: string }> { const logs: string[] = []; try { // Step 1: Click Timesheet tab to ensure we're on the right tab logs.push('🔍 Looking for Timesheet tab...'); const timesheetTab = await page.$('li[id*="Home-title"]'); if (timesheetTab) { const timesheetLink = await timesheetTab.$('a'); if (timesheetLink) { await timesheetLink.click(); logs.push('✅ Clicked Timesheet tab'); } } else { logs.push('⚠️ Could not find Timesheet tab, continuing anyway...'); } await new Promise(resolve => setTimeout(resolve, 1000)); // Step 2: Click Send button to open the menu logs.push('🔍 Looking for Send button...'); const sendButton = await page.$('a[id*="Ribbon.ContextualTabs.Timesheet.Home.Sheet.SubmitMenu-Large"]'); if (sendButton) { await sendButton.click(); logs.push('✅ Clicked Send button'); } else { return { success: false, message: `${logs.join('\n')}\n❌ Could not find Send button` }; } await new Promise(resolve => setTimeout(resolve, 1000)); // Step 3: Click "Turn in final timesheet" from the menu logs.push('🔍 Looking for "Turn in final timesheet" option...'); const submitOption = await page.$('a[id*="Ribbon.ContextualTabs.Timesheet.Home.Sheet.SubmitTimesheet-Menu32"]'); if (submitOption) { await submitOption.click(); logs.push('✅ Clicked "Turn in final timesheet"'); } else { return { success: false, message: `${logs.join('\n')}\n❌ Could not find "Turn in final timesheet" option` }; } // Step 4: Wait for dialog iframe to open logs.push('⏳ Waiting for submission dialog iframe...'); let dialogFrame = null; for (let i = 0; i < 10; i++) { await new Promise(resolve => setTimeout(resolve, 1000)); const frames = page.frames(); dialogFrame = frames.find(frame => frame.url().includes('SubmitTimesheet.aspx') || frame.url().includes('Submit')); if (dialogFrame) { logs.push(`✅ Found dialog iframe after ${i + 1} seconds`); break; } } // If no iframe found, try the main page if (!dialogFrame) { logs.push('⚠️ No iframe found, trying main page...'); dialogFrame = page.mainFrame(); } // Step 5: Find comment field in the dialog logs.push('🔍 Looking for comment field...'); let commentField = null; for (let i = 0; i < 5; i++) { await new Promise(resolve => setTimeout(resolve, 1000)); commentField = await dialogFrame.$('textarea[id*="idComment"]'); if (commentField) { logs.push(`✅ Found comment field after ${i + 1} seconds`); break; } } if (!commentField) { return { success: false, message: `${logs.join('\n')}\n❌ Could not find comment field` }; } // Step 6: Fill comment text area logs.push('🔍 Filling comment field...'); await commentField.click({ clickCount: 3 }); // Select all await commentField.type(description); logs.push(`✅ Filled comment: ${description}`); // Step 7: Click OK button logs.push('🔍 Looking for OK button...'); const okButton = await dialogFrame.$('input[id*="idOkButton"]'); if (okButton) { await okButton.click(); logs.push('✅ Clicked OK button'); } else { return { success: false, message: `${logs.join('\n')}\n❌ Could not find OK button` }; } // Wait for submission to complete await new Promise(resolve => setTimeout(resolve, 3000)); return { success: true, message: `${logs.join('\n')}\n\n✅ Timesheet submitted for approval successfully!` }; } catch (error) { return { success: false, message: `${logs.join('\n')}\n❌ Error during submission: ${error instanceof Error ? error.message : String(error)}` }; } } async close(): Promise<void> { if (this.browser) { await this.browser.close(); this.browser = null; } } // Helper method to inspect the page structure async inspectPageStructure(): Promise<string> { if (!this.browser) { await this.initialize(); } const page = await this.browser!.newPage(); try { await this.authenticate(page); await new Promise(resolve => setTimeout(resolve, 3000)); // Get all form elements and grid information const pageInfo = await page.evaluate(() => { const inputs = Array.from(document.querySelectorAll('input')).map(input => ({ type: input.type, name: input.name, id: input.id, class: input.className })); const textareas = Array.from(document.querySelectorAll('textarea')).map(textarea => ({ name: textarea.name, id: textarea.id, class: textarea.className })); const buttons = Array.from(document.querySelectorAll('button, input[type="button"], input[type="submit"]')).map(button => ({ text: button.textContent || (button as HTMLInputElement).value, id: button.id, class: button.className, name: (button as HTMLInputElement).name })); // Try to find grid objects const w = window as any; const gridInfo: any = { found: false, gridVariables: [] }; // Check for common SharePoint grid variable names const gridVarNames = ['idGrid', 'g_grid', 'pwaGrid', 'tsGrid', 'timesheetGrid']; for (const varName of gridVarNames) { if (w[varName]) { gridInfo.found = true; gridInfo.gridVariables.push({ name: varName, type: typeof w[varName], methods: Object.getOwnPropertyNames(Object.getPrototypeOf(w[varName])).filter( name => typeof w[varName][name] === 'function' ).slice(0, 50), // Limit to first 50 methods properties: Object.keys(w[varName]).slice(0, 20) // Limit to first 20 properties }); } } // Look for any grid-related elements in the DOM const gridElements = Array.from(document.querySelectorAll('[id*="grid"], [id*="Grid"], [class*="grid"], [class*="Grid"]')).map(el => ({ id: el.id, tagName: el.tagName, className: el.className })).slice(0, 10); return { inputs, textareas, buttons, gridInfo, gridElements }; }); return JSON.stringify({ pageTitle: await page.title(), pageUrl: page.url(), formElements: { inputs: pageInfo.inputs, textareas: pageInfo.textareas, buttons: pageInfo.buttons }, gridInformation: pageInfo.gridInfo, gridElements: pageInfo.gridElements }, null, 2); } finally { await page.close(); } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sharadmathuratthepsi/timesheet-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server