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:
133
tests/unit/services/erp/erp-error-context.test.ts
Normal file
133
tests/unit/services/erp/erp-error-context.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Tests for ERP Error Context Capture with screenshot support
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
// Mock logger/shared before importing the module under test
|
||||
vi.mock('../../../../src/main/services/logger/shared', () => ({
|
||||
getLogDir: vi.fn(() => '/tmp/test-logs')
|
||||
}))
|
||||
|
||||
// Mock fs
|
||||
vi.mock('fs', () => ({
|
||||
default: {
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
existsSync: vi.fn(() => true),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { capturePageContext } from '../../../../src/main/services/erp/erp-error-context'
|
||||
|
||||
function createMockPage(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
url: vi.fn(() => 'https://erp.example.com/page'),
|
||||
frames: vi.fn(() => [
|
||||
{ name: () => 'main', url: () => 'https://erp.example.com/main' },
|
||||
{ name: () => 'forwardFrame', url: () => 'https://erp.example.com/frame' }
|
||||
]),
|
||||
isClosed: vi.fn(() => false),
|
||||
screenshot: vi.fn(() => Buffer.from('fake-png-data')),
|
||||
...overrides
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('capturePageContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(fs.mkdirSync).mockReturnValue(undefined)
|
||||
vi.mocked(fs.writeFileSync).mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should capture screenshot and return screenshotPath on success', async () => {
|
||||
const page = createMockPage()
|
||||
|
||||
const ctx = await capturePageContext(page, '#selector', 'login.username')
|
||||
|
||||
expect(ctx.screenshotPath).toBeDefined()
|
||||
expect(ctx.screenshotPath).toContain('err_')
|
||||
expect(ctx.screenshotPath).toContain('.png')
|
||||
expect(ctx.screenshotPath).toContain('login_username')
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.png'),
|
||||
expect.any(Buffer)
|
||||
)
|
||||
})
|
||||
|
||||
it('should return screenshotPath undefined when page is closed', async () => {
|
||||
const page = createMockPage({ isClosed: vi.fn(() => true) })
|
||||
|
||||
const ctx = await capturePageContext(page)
|
||||
|
||||
expect(ctx.screenshotPath).toBeUndefined()
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should still return other fields when screenshot fails', async () => {
|
||||
const page = createMockPage({
|
||||
screenshot: vi.fn(() => {
|
||||
throw new Error('screenshot timeout')
|
||||
})
|
||||
})
|
||||
|
||||
const ctx = await capturePageContext(page, '#target', 'test-step')
|
||||
|
||||
// Other fields should still be populated
|
||||
expect(ctx.pageUrl).toBe('https://erp.example.com/page')
|
||||
expect(ctx.frameHierarchy).toHaveLength(2)
|
||||
expect(ctx.targetSelector).toBe('#target')
|
||||
expect(ctx.step).toBe('test-step')
|
||||
// Screenshot should be undefined due to failure
|
||||
expect(ctx.screenshotPath).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should sanitize special characters in step name for filename', async () => {
|
||||
const page = createMockPage()
|
||||
|
||||
const ctx = await capturePageContext(page, undefined, 'login/user:test*step')
|
||||
|
||||
expect(ctx.screenshotPath).toBeDefined()
|
||||
expect(ctx.screenshotPath).toContain('login_user_test_step')
|
||||
})
|
||||
|
||||
it('should use "unknown" in filename when step is not provided', async () => {
|
||||
const page = createMockPage()
|
||||
|
||||
const ctx = await capturePageContext(page)
|
||||
|
||||
expect(ctx.screenshotPath).toBeDefined()
|
||||
expect(ctx.screenshotPath).toContain('unknown')
|
||||
})
|
||||
|
||||
it('should truncate long step names to 40 characters', async () => {
|
||||
const page = createMockPage()
|
||||
const longStep = 'a'.repeat(80)
|
||||
|
||||
const ctx = await capturePageContext(page, undefined, longStep)
|
||||
|
||||
expect(ctx.screenshotPath).toBeDefined()
|
||||
const filename = path.basename(ctx.screenshotPath!)
|
||||
// Extract step portion: err_YYYYMMDD_HHmmss_<step>.png
|
||||
const stepPart = filename.replace(/^err_\d{8}_\d{6}_/, '').replace(/\.png$/, '')
|
||||
expect(stepPart.length).toBe(40)
|
||||
})
|
||||
|
||||
it('should create screenshots directory if it does not exist', async () => {
|
||||
const page = createMockPage()
|
||||
|
||||
await capturePageContext(page)
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('screenshots'), {
|
||||
recursive: true
|
||||
})
|
||||
})
|
||||
})
|
||||
184
tests/unit/services/erp/page-diagnostics.test.ts
Normal file
184
tests/unit/services/erp/page-diagnostics.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Tests for Page Diagnostics module
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// vi.hoisted() runs before vi.mock() factories, even though both are hoisted.
|
||||
const mockLog = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/main/services/logger', () => ({
|
||||
createLogger: () => mockLog
|
||||
}))
|
||||
|
||||
import {
|
||||
attachPageDiagnostics,
|
||||
attachContextDiagnostics
|
||||
} from '../../../../src/main/services/erp/page-diagnostics'
|
||||
|
||||
describe('page-diagnostics', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('attachPageDiagnostics', () => {
|
||||
it('should log console warning messages', () => {
|
||||
const listeners: Record<string, ((...args: any[]) => any)[]> = {}
|
||||
const page = {
|
||||
on: vi.fn((event: string, handler: (...args: any[]) => any) => {
|
||||
if (!listeners[event]) listeners[event] = []
|
||||
listeners[event].push(handler)
|
||||
}),
|
||||
url: vi.fn(() => 'https://erp.example.com/page')
|
||||
} as any
|
||||
|
||||
attachPageDiagnostics(page)
|
||||
|
||||
const consoleHandlers = listeners['console'] ?? []
|
||||
expect(consoleHandlers).toHaveLength(1)
|
||||
|
||||
consoleHandlers[0]({
|
||||
type: () => 'warning',
|
||||
text: () => 'Something suspicious',
|
||||
location: () => ({ url: 'app.js', lineNumber: 42 })
|
||||
})
|
||||
|
||||
expect(mockLog.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Browser warning'),
|
||||
expect.objectContaining({
|
||||
pageUrl: 'https://erp.example.com/page',
|
||||
consoleType: 'warning'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should log console error messages', () => {
|
||||
const listeners: Record<string, ((...args: any[]) => any)[]> = {}
|
||||
const page = {
|
||||
on: vi.fn((event: string, handler: (...args: any[]) => any) => {
|
||||
if (!listeners[event]) listeners[event] = []
|
||||
listeners[event].push(handler)
|
||||
}),
|
||||
url: vi.fn(() => 'https://erp.example.com/page')
|
||||
} as any
|
||||
|
||||
attachPageDiagnostics(page)
|
||||
|
||||
const consoleHandlers = listeners['console'] ?? []
|
||||
consoleHandlers[0]({
|
||||
type: () => 'error',
|
||||
text: () => 'Uncaught TypeError',
|
||||
location: () => ({ url: 'vendor.js', lineNumber: 100 })
|
||||
})
|
||||
|
||||
expect(mockLog.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Browser error'),
|
||||
expect.objectContaining({
|
||||
consoleType: 'error'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should NOT log console info messages', () => {
|
||||
const listeners: Record<string, ((...args: any[]) => any)[]> = {}
|
||||
const page = {
|
||||
on: vi.fn((event: string, handler: (...args: any[]) => any) => {
|
||||
if (!listeners[event]) listeners[event] = []
|
||||
listeners[event].push(handler)
|
||||
}),
|
||||
url: vi.fn(() => 'https://erp.example.com/page')
|
||||
} as any
|
||||
|
||||
attachPageDiagnostics(page)
|
||||
|
||||
const consoleHandlers = listeners['console'] ?? []
|
||||
consoleHandlers[0]({
|
||||
type: () => 'info',
|
||||
text: () => 'XHR loaded',
|
||||
location: () => ({ url: 'app.js', lineNumber: 10 })
|
||||
})
|
||||
|
||||
expect(mockLog.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT log console log messages', () => {
|
||||
const listeners: Record<string, ((...args: any[]) => any)[]> = {}
|
||||
const page = {
|
||||
on: vi.fn((event: string, handler: (...args: any[]) => any) => {
|
||||
if (!listeners[event]) listeners[event] = []
|
||||
listeners[event].push(handler)
|
||||
}),
|
||||
url: vi.fn(() => 'https://erp.example.com/page')
|
||||
} as any
|
||||
|
||||
attachPageDiagnostics(page)
|
||||
|
||||
const consoleHandlers = listeners['console'] ?? []
|
||||
consoleHandlers[0]({
|
||||
type: () => 'log',
|
||||
text: () => 'debug output',
|
||||
location: () => null
|
||||
})
|
||||
|
||||
expect(mockLog.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should log pageerror events', () => {
|
||||
const listeners: Record<string, ((...args: any[]) => any)[]> = {}
|
||||
const page = {
|
||||
on: vi.fn((event: string, handler: (...args: any[]) => any) => {
|
||||
if (!listeners[event]) listeners[event] = []
|
||||
listeners[event].push(handler)
|
||||
}),
|
||||
url: vi.fn(() => 'https://erp.example.com/page')
|
||||
} as any
|
||||
|
||||
attachPageDiagnostics(page)
|
||||
|
||||
const pageErrorHandlers = listeners['pageerror'] ?? []
|
||||
expect(pageErrorHandlers).toHaveLength(1)
|
||||
|
||||
pageErrorHandlers[0](new Error('Script error on page'))
|
||||
|
||||
expect(mockLog.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Browser pageerror'),
|
||||
expect.objectContaining({
|
||||
pageUrl: 'https://erp.example.com/page',
|
||||
error: 'Script error on page'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('attachContextDiagnostics', () => {
|
||||
it('should auto-attach diagnostics to new pages in the context', () => {
|
||||
const contextListeners: Record<string, ((...args: any[]) => any)[]> = {}
|
||||
const context = {
|
||||
on: vi.fn((event: string, handler: (...args: any[]) => any) => {
|
||||
if (!contextListeners[event]) contextListeners[event] = []
|
||||
contextListeners[event].push(handler)
|
||||
})
|
||||
} as any
|
||||
|
||||
attachContextDiagnostics(context)
|
||||
|
||||
const pageHandlers = contextListeners['page'] ?? []
|
||||
expect(pageHandlers).toHaveLength(1)
|
||||
|
||||
const newPage = {
|
||||
on: vi.fn(),
|
||||
url: vi.fn(() => 'https://erp.example.com/popup')
|
||||
} as any
|
||||
|
||||
pageHandlers[0](newPage)
|
||||
|
||||
expect(newPage.on).toHaveBeenCalledWith('console', expect.any(Function))
|
||||
expect(newPage.on).toHaveBeenCalledWith('pageerror', expect.any(Function))
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user