feat(logging-p0): add screenshot capture and browser console diagnostics for ERP errors
Enhance ERP automation error diagnostics by capturing PNG screenshots on every error and forwarding browser console warnings/errors to the structured logger. Includes automatic cleanup of old screenshots aligned with the configured log retention period. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { chromium } from 'playwright'
|
||||
import type { ErpConfig, ErpSession } from '../../types/erp.types'
|
||||
import { createLogger } from '../logger'
|
||||
import { capturePageContext } from './erp-error-context'
|
||||
import { attachPageDiagnostics, attachContextDiagnostics } from './page-diagnostics'
|
||||
|
||||
const log = createLogger('ErpAuthService')
|
||||
|
||||
@@ -55,6 +56,8 @@ export class ErpAuthService {
|
||||
})
|
||||
|
||||
const page = await context.newPage()
|
||||
attachPageDiagnostics(page)
|
||||
attachContextDiagnostics(context)
|
||||
|
||||
// Navigate to login page (use actual login URL from Python code)
|
||||
const loginUrl = `${this.config.url}/yonbip/resources/uap/rbac/login/main/index.html`
|
||||
|
||||
@@ -6,12 +6,61 @@
|
||||
*/
|
||||
|
||||
import type { Page } from 'playwright'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { getLogDir } from '../logger/shared'
|
||||
|
||||
export interface ErpErrorContext {
|
||||
pageUrl?: string
|
||||
frameHierarchy?: Array<{ name: string; url: string }>
|
||||
targetSelector?: string
|
||||
step?: string
|
||||
screenshotPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a step name for use as a filename component.
|
||||
* Replaces non-alphanumeric characters with underscores and truncates.
|
||||
*/
|
||||
function sanitizeForFilename(step: string | undefined): string {
|
||||
if (!step) return 'unknown'
|
||||
return step.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 40)
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a screenshot of the page for error diagnostics.
|
||||
* Stored as PNG under <logDir>/screenshots/.
|
||||
* Defensive: never throws.
|
||||
*/
|
||||
async function captureScreenshot(page: Page, step?: string): Promise<string | undefined> {
|
||||
try {
|
||||
if (page.isClosed()) return undefined
|
||||
|
||||
const screenshotDir = path.join(getLogDir(), 'screenshots')
|
||||
fs.mkdirSync(screenshotDir, { recursive: true })
|
||||
|
||||
const now = new Date()
|
||||
const timestamp = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
'_',
|
||||
String(now.getHours()).padStart(2, '0'),
|
||||
String(now.getMinutes()).padStart(2, '0'),
|
||||
String(now.getSeconds()).padStart(2, '0')
|
||||
].join('')
|
||||
|
||||
const filename = `err_${timestamp}_${sanitizeForFilename(step)}.png`
|
||||
const filePath = path.join(screenshotDir, filename)
|
||||
|
||||
const buffer = await page.screenshot({ type: 'png', timeout: 5000 })
|
||||
fs.writeFileSync(filePath, buffer)
|
||||
|
||||
return filePath
|
||||
} catch {
|
||||
// screenshot failure must not propagate
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,5 +98,7 @@ export async function capturePageContext(
|
||||
ctx.step = step
|
||||
}
|
||||
|
||||
ctx.screenshotPath = await captureScreenshot(page, step)
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
50
src/main/services/erp/page-diagnostics.ts
Normal file
50
src/main/services/erp/page-diagnostics.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Page Diagnostics
|
||||
*
|
||||
* Attaches browser console and error listeners to Playwright pages
|
||||
* so that ERP-side JS errors are visible in the application log.
|
||||
*
|
||||
* Only warning and error level console messages are captured —
|
||||
* ERP (YonBIP) outputs large volumes of info-level messages that
|
||||
* would drown the log.
|
||||
*/
|
||||
|
||||
import type { Page, BrowserContext } from 'playwright'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const log = createLogger('BrowserDiagnostics')
|
||||
|
||||
/**
|
||||
* Attach console and error listeners to a single page.
|
||||
*/
|
||||
export function attachPageDiagnostics(page: Page): void {
|
||||
page.on('console', (msg) => {
|
||||
const type = msg.type()
|
||||
if (type !== 'warning' && type !== 'error') return
|
||||
|
||||
const location = msg.location()
|
||||
log.error(`[Browser ${type}] ${msg.text()}`, {
|
||||
pageUrl: page.url(),
|
||||
consoleType: type,
|
||||
location: location ? `${location.url}:${location.lineNumber}` : undefined
|
||||
})
|
||||
})
|
||||
|
||||
page.on('pageerror', (error) => {
|
||||
log.error(`[Browser pageerror] ${error.message}`, {
|
||||
pageUrl: page.url(),
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach diagnostics to all current and future pages in a browser context.
|
||||
* Covers popups and pages opened by ERP automation.
|
||||
*/
|
||||
export function attachContextDiagnostics(context: BrowserContext): void {
|
||||
context.on('page', (page) => {
|
||||
attachPageDiagnostics(page)
|
||||
})
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import DailyRotateFile from 'winston-daily-rotate-file'
|
||||
import path from 'path'
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { serializeError, sanitizeError } from './error-utils'
|
||||
import { getLogDir, isProduction } from './shared'
|
||||
import { getLogDir, isProduction, cleanupOldScreenshots } from './shared'
|
||||
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
|
||||
import { getContext, run } from './request-context'
|
||||
|
||||
@@ -212,6 +212,9 @@ export function applyLoggingConfig(config: { level: string; appRetention: number
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Clean up old screenshot files beyond the retention window
|
||||
cleanupOldScreenshots(config.appRetention)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,3 +58,35 @@ export const LOG_LEVEL_PRIORITY: Record<string, number> = {
|
||||
export function isLoggable(level: string, threshold: string): boolean {
|
||||
return (LOG_LEVEL_PRIORITY[level] ?? 0) >= (LOG_LEVEL_PRIORITY[threshold] ?? 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete screenshot files older than the given retention period.
|
||||
* Scans <logDir>/screenshots/ and removes .png files whose mtime exceeds
|
||||
* the retention window. Defensive: never throws.
|
||||
*
|
||||
* @param retentionDays - Number of days to keep screenshots
|
||||
*/
|
||||
export function cleanupOldScreenshots(retentionDays: number): void {
|
||||
try {
|
||||
const screenshotDir = path.join(getLogDir(), 'screenshots')
|
||||
if (!fs.existsSync(screenshotDir)) return
|
||||
|
||||
const files = fs.readdirSync(screenshotDir)
|
||||
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.png')) continue
|
||||
const filePath = path.join(screenshotDir, file)
|
||||
try {
|
||||
const stat = fs.statSync(filePath)
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
} catch {
|
||||
// individual file deletion failure should not stop the loop
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// entire cleanup is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user