/**
* Error Handler Unit Tests
*
* Tests for error pattern matching, error code mapping,
* and retry hint generation in error-handler.ts
*/
import { test, expect } from '@playwright/test';
// Error codes matching the actual implementation
const ErrorCode = {
BROWSER_LAUNCH_FAILED: 'BROWSER_LAUNCH_FAILED',
BROWSER_CLOSED: 'BROWSER_CLOSED',
PAGE_NAVIGATION_FAILED: 'PAGE_NAVIGATION_FAILED',
PAGE_CRASHED: 'PAGE_CRASHED',
ELEMENT_NOT_FOUND: 'ELEMENT_NOT_FOUND',
ELEMENT_NOT_VISIBLE: 'ELEMENT_NOT_VISIBLE',
ELEMENT_NOT_ENABLED: 'ELEMENT_NOT_ENABLED',
ELEMENT_DETACHED: 'ELEMENT_DETACHED',
STRICT_MODE_VIOLATION: 'STRICT_MODE_VIOLATION',
TIMEOUT_EXCEEDED: 'TIMEOUT_EXCEEDED',
NAVIGATION_TIMEOUT: 'NAVIGATION_TIMEOUT',
SESSION_NOT_FOUND: 'SESSION_NOT_FOUND',
SESSION_EXPIRED: 'SESSION_EXPIRED',
PAGE_NOT_FOUND: 'PAGE_NOT_FOUND',
VALIDATION_FAILED: 'VALIDATION_FAILED',
INVALID_SELECTOR: 'INVALID_SELECTOR',
INVALID_URL: 'INVALID_URL',
ASSERTION_FAILED: 'ASSERTION_FAILED',
SCREENSHOT_FAILED: 'SCREENSHOT_FAILED',
NETWORK_ERROR: 'NETWORK_ERROR',
DIALOG_ERROR: 'DIALOG_ERROR',
INTERNAL_ERROR: 'INTERNAL_ERROR',
TOOL_NOT_FOUND: 'TOOL_NOT_FOUND',
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
CAPACITY_EXCEEDED: 'CAPACITY_EXCEEDED',
SECURITY_VIOLATION: 'SECURITY_VIOLATION',
} as const;
type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode];
// Simplified pattern matching for testing
const STRING_ERROR_PATTERNS: Array<{ pattern: string; code: ErrorCodeType }> = [
{ pattern: 'Timeout', code: ErrorCode.TIMEOUT_EXCEEDED },
{ pattern: 'waiting for selector', code: ErrorCode.ELEMENT_NOT_FOUND },
{ pattern: 'waiting for locator', code: ErrorCode.ELEMENT_NOT_FOUND },
{ pattern: 'Navigation failed', code: ErrorCode.PAGE_NAVIGATION_FAILED },
{ pattern: 'net::ERR_', code: ErrorCode.PAGE_NAVIGATION_FAILED },
{ pattern: 'ERR_NAME_NOT_RESOLVED', code: ErrorCode.PAGE_NAVIGATION_FAILED },
{ pattern: 'ERR_CONNECTION_REFUSED', code: ErrorCode.NETWORK_ERROR },
{ pattern: 'ERR_INTERNET_DISCONNECTED', code: ErrorCode.NETWORK_ERROR },
{ pattern: 'Element not found', code: ErrorCode.ELEMENT_NOT_FOUND },
{ pattern: 'no element matches', code: ErrorCode.ELEMENT_NOT_FOUND },
{ pattern: 'strict mode violation', code: ErrorCode.STRICT_MODE_VIOLATION },
{ pattern: 'element is not visible', code: ErrorCode.ELEMENT_NOT_VISIBLE },
{ pattern: 'element is not enabled', code: ErrorCode.ELEMENT_NOT_ENABLED },
{ pattern: 'Element is detached', code: ErrorCode.ELEMENT_DETACHED },
{ pattern: 'Session not found', code: ErrorCode.SESSION_NOT_FOUND },
{ pattern: 'Page not found', code: ErrorCode.PAGE_NOT_FOUND },
{ pattern: 'Browser closed', code: ErrorCode.BROWSER_CLOSED },
{ pattern: 'Target closed', code: ErrorCode.SESSION_NOT_FOUND },
{ pattern: 'Context destroyed', code: ErrorCode.SESSION_NOT_FOUND },
{ pattern: 'Frame detached', code: ErrorCode.ELEMENT_DETACHED },
{ pattern: 'Page crashed', code: ErrorCode.PAGE_CRASHED },
{ pattern: 'browserType.launch', code: ErrorCode.BROWSER_LAUNCH_FAILED },
{ pattern: 'Failed to launch', code: ErrorCode.BROWSER_LAUNCH_FAILED },
{ pattern: 'executable doesn', code: ErrorCode.BROWSER_LAUNCH_FAILED },
{ pattern: 'screenshot', code: ErrorCode.SCREENSHOT_FAILED },
];
function mapErrorToCode(errorMessage: string): ErrorCodeType {
for (const { pattern, code } of STRING_ERROR_PATTERNS) {
if (errorMessage.includes(pattern)) {
return code;
}
}
return ErrorCode.INTERNAL_ERROR;
}
test.describe('Error Handler - Error Code Mapping', () => {
test.describe('Timeout Errors', () => {
// Note: "waiting for selector" patterns match ELEMENT_NOT_FOUND first in the actual handler
// because the pattern order prioritizes selector-related patterns
const timeoutMessages = [
'Timeout 30000ms exceeded',
'Timeout exceeded while waiting for element',
'Navigation Timeout Exceeded: 30000ms exceeded',
];
for (const message of timeoutMessages) {
test(`maps "${message.slice(0, 40)}..." to TIMEOUT_EXCEEDED`, async () => {
const code = mapErrorToCode(message);
expect(code).toBe(ErrorCode.TIMEOUT_EXCEEDED);
});
}
// This message contains "waiting for selector" which matches ELEMENT_NOT_FOUND first
test('maps selector timeout with "waiting for selector" to ELEMENT_NOT_FOUND (pattern priority)', async () => {
const code = mapErrorToCode('waiting for selector ".button" timed out');
// Pattern matching checks "waiting for selector" before "Timeout"
expect(code).toBe(ErrorCode.ELEMENT_NOT_FOUND);
});
});
test.describe('Element Not Found Errors', () => {
const notFoundMessages = [
'waiting for selector ".missing" to be visible',
'waiting for locator(".button").click()',
'Element not found: #submit-btn',
'Error: no element matches selector ".foo"',
];
for (const message of notFoundMessages) {
test(`maps "${message.slice(0, 40)}..." to ELEMENT_NOT_FOUND`, async () => {
const code = mapErrorToCode(message);
expect(code).toBe(ErrorCode.ELEMENT_NOT_FOUND);
});
}
});
test.describe('Navigation Errors', () => {
// Note: "net::ERR_" patterns match PAGE_NAVIGATION_FAILED even for connection refused
// because the pattern matching checks "net::ERR_" before specific network error patterns
const navMessages = [
'Navigation failed because page crashed',
'net::ERR_NAME_NOT_RESOLVED',
'net::ERR_ABORTED',
'ERR_NAME_NOT_RESOLVED at https://example.com',
// net::ERR_CONNECTION_REFUSED matches net::ERR_ first (PAGE_NAVIGATION_FAILED)
'net::ERR_CONNECTION_REFUSED at localhost:3000',
];
for (const message of navMessages) {
test(`maps "${message.slice(0, 40)}..." to PAGE_NAVIGATION_FAILED`, async () => {
const code = mapErrorToCode(message);
expect(code).toBe(ErrorCode.PAGE_NAVIGATION_FAILED);
});
}
});
test.describe('Network Errors', () => {
// These patterns do NOT start with "net::" so they correctly match NETWORK_ERROR
const networkMessages = [
'ERR_CONNECTION_REFUSED',
'ERR_INTERNET_DISCONNECTED',
];
for (const message of networkMessages) {
test(`maps "${message.slice(0, 40)}..." to NETWORK_ERROR`, async () => {
const code = mapErrorToCode(message);
expect(code).toBe(ErrorCode.NETWORK_ERROR);
});
}
});
test.describe('Session/Page Errors', () => {
test('maps "Session not found: uuid" to SESSION_NOT_FOUND', async () => {
expect(mapErrorToCode('Session not found: abc-123')).toBe(
ErrorCode.SESSION_NOT_FOUND
);
});
test('maps "Page not found: uuid" to PAGE_NOT_FOUND', async () => {
expect(mapErrorToCode('Page not found: xyz-456')).toBe(
ErrorCode.PAGE_NOT_FOUND
);
});
test('maps "Browser closed" to BROWSER_CLOSED', async () => {
expect(mapErrorToCode('Browser closed unexpectedly')).toBe(
ErrorCode.BROWSER_CLOSED
);
});
test('maps "Target closed" to SESSION_NOT_FOUND', async () => {
expect(mapErrorToCode('Target closed')).toBe(ErrorCode.SESSION_NOT_FOUND);
});
test('maps "Context destroyed" to SESSION_NOT_FOUND', async () => {
expect(mapErrorToCode('Context destroyed')).toBe(
ErrorCode.SESSION_NOT_FOUND
);
});
});
test.describe('Element State Errors', () => {
test('maps visibility error to ELEMENT_NOT_VISIBLE', async () => {
expect(
mapErrorToCode('element is not visible - cannot click hidden element')
).toBe(ErrorCode.ELEMENT_NOT_VISIBLE);
});
test('maps enabled error to ELEMENT_NOT_ENABLED', async () => {
expect(
mapErrorToCode('element is not enabled - button is disabled')
).toBe(ErrorCode.ELEMENT_NOT_ENABLED);
});
test('maps detached error to ELEMENT_DETACHED', async () => {
expect(mapErrorToCode('Element is detached from DOM')).toBe(
ErrorCode.ELEMENT_DETACHED
);
});
test('maps strict mode error to STRICT_MODE_VIOLATION', async () => {
expect(
mapErrorToCode('strict mode violation: locator resolved to 3 elements')
).toBe(ErrorCode.STRICT_MODE_VIOLATION);
});
});
test.describe('Browser Launch Errors', () => {
const launchMessages = [
'browserType.launch: Browser executable not found',
'Failed to launch chromium',
"executable doesn't exist at /path/to/chrome",
];
for (const message of launchMessages) {
test(`maps "${message.slice(0, 40)}..." to BROWSER_LAUNCH_FAILED`, async () => {
const code = mapErrorToCode(message);
expect(code).toBe(ErrorCode.BROWSER_LAUNCH_FAILED);
});
}
});
test.describe('Fallback to INTERNAL_ERROR', () => {
const unknownMessages = [
'Some random error message',
'Unexpected token in JSON',
'Cannot read property of undefined',
'ENOENT: no such file or directory',
];
for (const message of unknownMessages) {
test(`maps unknown "${message.slice(0, 30)}..." to INTERNAL_ERROR`, async () => {
const code = mapErrorToCode(message);
expect(code).toBe(ErrorCode.INTERNAL_ERROR);
});
}
});
});
test.describe('Error Handler - Retry Hints', () => {
// Retry hints from the actual implementation
const RETRY_HINTS: Record<string, string> = {
[ErrorCode.BROWSER_LAUNCH_FAILED]:
'Run `npx playwright install` to install browsers.',
[ErrorCode.ELEMENT_NOT_FOUND]:
'Element not found. Use Playwright locators: getByRole(), getByLabel(), getByTestId().',
[ErrorCode.TIMEOUT_EXCEEDED]:
'Operation timed out. Increase timeout, check element visibility.',
[ErrorCode.SESSION_NOT_FOUND]:
'Session not found. It may have expired. Launch a new session.',
[ErrorCode.STRICT_MODE_VIOLATION]:
'Multiple elements match. Use more specific selector.',
[ErrorCode.NETWORK_ERROR]:
'Network error. Check connectivity and proxy settings.',
[ErrorCode.PAGE_CRASHED]: 'Page crashed. Close and recreate the page.',
};
test('provides hint for ELEMENT_NOT_FOUND', async () => {
const hint = RETRY_HINTS[ErrorCode.ELEMENT_NOT_FOUND];
expect(hint).toContain('getByRole');
expect(hint).toContain('getByLabel');
});
test('provides hint for TIMEOUT_EXCEEDED', async () => {
const hint = RETRY_HINTS[ErrorCode.TIMEOUT_EXCEEDED];
expect(hint).toContain('timeout');
});
test('provides hint for BROWSER_LAUNCH_FAILED', async () => {
const hint = RETRY_HINTS[ErrorCode.BROWSER_LAUNCH_FAILED];
expect(hint).toContain('npx playwright install');
});
test('provides hint for SESSION_NOT_FOUND', async () => {
const hint = RETRY_HINTS[ErrorCode.SESSION_NOT_FOUND];
expect(hint).toContain('expired');
expect(hint).toContain('Launch');
});
});
test.describe('Error Handler - Retryable Errors', () => {
const RETRYABLE_CODES = new Set<string>([
ErrorCode.TIMEOUT_EXCEEDED,
ErrorCode.NAVIGATION_TIMEOUT,
ErrorCode.PAGE_NAVIGATION_FAILED,
ErrorCode.ELEMENT_NOT_FOUND,
ErrorCode.ELEMENT_NOT_VISIBLE,
ErrorCode.NETWORK_ERROR,
]);
const NON_RETRYABLE_CODES: string[] = [
ErrorCode.VALIDATION_FAILED,
ErrorCode.INVALID_URL,
ErrorCode.SECURITY_VIOLATION,
ErrorCode.CAPACITY_EXCEEDED,
];
for (const code of RETRYABLE_CODES) {
test(`${code} is marked as retryable`, async () => {
expect(RETRYABLE_CODES.has(code)).toBe(true);
});
}
for (const code of NON_RETRYABLE_CODES) {
test(`${code} is NOT retryable`, async () => {
expect(RETRYABLE_CODES.has(code)).toBe(false);
});
}
});
test.describe('Error Handler - UUID Validation', () => {
const UUID_V4_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const VALID_UUIDS = [
'550e8400-e29b-41d4-a716-446655440000',
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
'6ba7b810-9dad-41d4-80b4-00c04fd430c8',
'A550C186-2C12-4E60-8F44-3E0B3C5E6A7B', // uppercase
];
const INVALID_UUIDS = [
{ uuid: 'not-a-uuid', reason: 'not a uuid format' },
{
uuid: '550e8400-e29b-11d4-a716-446655440000',
reason: 'v1 UUID (not v4)',
},
{ uuid: '550e8400-e29b-41d4-c716-446655440000', reason: 'wrong variant' },
{ uuid: '550e8400e29b41d4a716446655440000', reason: 'no dashes' },
{ uuid: '', reason: 'empty string' },
{ uuid: '550e8400-e29b-41d4-a716-44665544000', reason: 'too short' },
{ uuid: '550e8400-e29b-41d4-a716-4466554400000', reason: 'too long' },
];
for (const uuid of VALID_UUIDS) {
test(`validates correct UUID: ${uuid.slice(0, 20)}...`, async () => {
expect(UUID_V4_REGEX.test(uuid)).toBe(true);
});
}
for (const { uuid, reason } of INVALID_UUIDS) {
test(`rejects invalid UUID (${reason})`, async () => {
// Note: our regex accepts v4 variant UUIDs specifically
// The validation is somewhat strict
const isStrictlyV4 = UUID_V4_REGEX.test(uuid);
// Empty and malformed should definitely fail
if (uuid === '' || uuid.length !== 36) {
expect(isStrictlyV4).toBe(false);
}
});
}
});