Skip to content

Commit c69e635

Browse files
authored
Merge pull request #3 from gemini-testing/feat/butch
Runtime.exteptionThrown in console, copy JSON button
2 parents 1cb717d + 8e6cbb7 commit c69e635

15 files changed

Lines changed: 449 additions & 48 deletions

examples/webdriver.txt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71351,6 +71351,50 @@
7135171351
2026-01-15T17:37:50+03:00 [1768498670.490][INFO]: [a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4] RESPONSE ExecuteScript 2026-01-15T17:37:50+03:00 {
7135271352
2026-01-15T17:37:50+03:00 "value": "complete"
7135371353
2026-01-15T17:37:50+03:00 }
71354+
2026-01-15T17:37:50+03:00 [1768498670.800][DEBUG]: DevTools WebSocket Event: Runtime.exceptionThrown (session_id=612F83D82906371330EB8554EFE82149) 0123456789ABCDEF0123456789ABCDEF 2026-01-15T17:37:50+03:00 {
71355+
2026-01-15T17:37:50+03:00 "exceptionDetails": {
71356+
2026-01-15T17:37:50+03:00 "columnNumber": 750221,
71357+
2026-01-15T17:37:50+03:00 "exception": {
71358+
2026-01-15T17:37:50+03:00 "className": "ReferenceError",
71359+
2026-01-15T17:37:50+03:00 "description": "ReferenceError: expect is not defined\n at 96418 (https://example.com/static/js/8563.332d3409.js:1:750222)\n at r (https://example.com/static/js/8563.332d3409.js:1:750300)",
71360+
2026-01-15T17:37:50+03:00 "objectId": "-902336139148519460.1.8",
71361+
2026-01-15T17:37:50+03:00 "subtype": "error",
71362+
2026-01-15T17:37:50+03:00 "type": "object"
71363+
2026-01-15T17:37:50+03:00 },
71364+
2026-01-15T17:37:50+03:00 "exceptionId": 1,
71365+
2026-01-15T17:37:50+03:00 "executionContextId": 1,
71366+
2026-01-15T17:37:50+03:00 "lineNumber": 0,
71367+
2026-01-15T17:37:50+03:00 "scriptId": "20",
71368+
2026-01-15T17:37:50+03:00 "stackTrace": {
71369+
2026-01-15T17:37:50+03:00 "callFrames": [
71370+
2026-01-15T17:37:50+03:00 {
71371+
2026-01-15T17:37:50+03:00 "columnNumber": 750221,
71372+
2026-01-15T17:37:50+03:00 "functionName": "",
71373+
2026-01-15T17:37:50+03:00 "lineNumber": 0,
71374+
2026-01-15T17:37:50+03:00 "scriptId": "20",
71375+
2026-01-15T17:37:50+03:00 "url": "https://example.com/static/js/8563.332d3409.js"
71376+
2026-01-15T17:37:50+03:00 },
71377+
2026-01-15T17:37:50+03:00 {
71378+
2026-01-15T17:37:50+03:00 "columnNumber": 750299,
71379+
2026-01-15T17:37:50+03:00 "functionName": "r",
71380+
2026-01-15T17:37:50+03:00 "lineNumber": 0,
71381+
2026-01-15T17:37:50+03:00 "scriptId": "20",
71382+
2026-01-15T17:37:50+03:00 "url": "https://example.com/static/js/8563.332d3409.js"
71383+
2026-01-15T17:37:50+03:00 },
71384+
2026-01-15T17:37:50+03:00 {
71385+
2026-01-15T17:37:50+03:00 "columnNumber": 12,
71386+
2026-01-15T17:37:50+03:00 "functionName": "handleClick",
71387+
2026-01-15T17:37:50+03:00 "lineNumber": 45,
71388+
2026-01-15T17:37:50+03:00 "scriptId": "30",
71389+
2026-01-15T17:37:50+03:00 "url": "https://example.com/static/js/app.chunk.789abc.js"
71390+
2026-01-15T17:37:50+03:00 }
71391+
2026-01-15T17:37:50+03:00 ]
71392+
2026-01-15T17:37:50+03:00 },
71393+
2026-01-15T17:37:50+03:00 "text": "Uncaught",
71394+
2026-01-15T17:37:50+03:00 "url": "https://example.com/static/js/8563.332d3409.js"
71395+
2026-01-15T17:37:50+03:00 },
71396+
2026-01-15T17:37:50+03:00 "timestamp": 1778137688722.41
71397+
2026-01-15T17:37:50+03:00 }
7135471398
2026-01-15T17:37:51+03:00 [72:185:0115/195419.000120:ERROR:bus.cc(399)] Failed to connect to the bus: No such file or directory
7135571399
2026-01-15T17:37:51+03:00 [1768498671.520][INFO]: [a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4] COMMAND Quit 2026-01-15T17:37:51+03:00 {}
7135671400
2026-01-15T17:37:52+03:00 [1768498672.490][DEBUG]: DevTools WebSocket Event: Inspector.detached (session_id=612F83D82906371330EB8554EFE82149) 0123456789ABCDEF0123456789ABCDEF 2026-01-15T17:37:52+03:00 {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
import { exceptionToConsolePayload } from './exceptionToConsolePayload';
4+
import type { ExceptionPayload } from './types';
5+
6+
const basePayload: ExceptionPayload = {
7+
exceptionDetails: {
8+
columnNumber: 750221,
9+
exception: {
10+
className: 'ReferenceError',
11+
description: 'ReferenceError: expect is not defined\n at 96418 (script.js:1:750222)',
12+
type: 'object',
13+
subtype: 'error',
14+
},
15+
exceptionId: 1,
16+
executionContextId: 1,
17+
lineNumber: 0,
18+
scriptId: '20',
19+
stackTrace: {
20+
callFrames: [
21+
{ functionName: '', url: 'https://example.com/script.js', lineNumber: 0, columnNumber: 750221 },
22+
{ functionName: 'handleClick', url: 'https://example.com/app.js', lineNumber: 45, columnNumber: 12 },
23+
],
24+
},
25+
text: 'Uncaught',
26+
url: 'https://example.com/script.js',
27+
},
28+
timestamp: 1778137688722,
29+
};
30+
31+
describe('exceptionToConsolePayload', () => {
32+
it('should return type: error', () => {
33+
const result = exceptionToConsolePayload(basePayload);
34+
35+
expect(result.type).toBe('error');
36+
});
37+
38+
it('should use exception.description as message text', () => {
39+
const result = exceptionToConsolePayload(basePayload);
40+
41+
expect(result.args).toEqual([
42+
{ type: 'string', value: 'ReferenceError: expect is not defined\n at 96418 (script.js:1:750222)' },
43+
]);
44+
});
45+
46+
it('should fall back to exceptionDetails.text if exception is missing', () => {
47+
const payload: ExceptionPayload = {
48+
...basePayload,
49+
exceptionDetails: { ...basePayload.exceptionDetails, exception: undefined },
50+
};
51+
52+
const result = exceptionToConsolePayload(payload);
53+
54+
expect(result.args).toEqual([{ type: 'string', value: 'Uncaught' }]);
55+
});
56+
57+
it('should pass through timestamp', () => {
58+
const result = exceptionToConsolePayload(basePayload);
59+
60+
expect(result.timestamp).toBe(1778137688722);
61+
});
62+
63+
it('should pass through stackTrace', () => {
64+
const result = exceptionToConsolePayload(basePayload);
65+
66+
expect(result.stackTrace).toBe(basePayload.exceptionDetails.stackTrace);
67+
});
68+
69+
it('should return undefined stackTrace if exceptionDetails has none', () => {
70+
const payload: ExceptionPayload = {
71+
...basePayload,
72+
exceptionDetails: { ...basePayload.exceptionDetails, stackTrace: undefined },
73+
};
74+
75+
const result = exceptionToConsolePayload(payload);
76+
77+
expect(result.stackTrace).toBeUndefined();
78+
});
79+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { ExceptionPayload, ConsolePayload } from './types';
2+
3+
export function exceptionToConsolePayload(payload: ExceptionPayload): ConsolePayload {
4+
const { exceptionDetails, timestamp } = payload;
5+
const description = exceptionDetails.exception?.description ?? exceptionDetails.text;
6+
7+
return {
8+
type: 'error',
9+
args: [{ type: 'string', value: description }],
10+
timestamp,
11+
stackTrace: exceptionDetails.stackTrace,
12+
};
13+
}

src/lib/console/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export * from './extractConsoleMessage';
44
export * from './formatStackTrace';
55
export * from './remoteObjectToJson';
66
export * from './isConsolePayload';
7+
export * from './isExceptionPayload';
8+
export * from './exceptionToConsolePayload';
79
export * from './getObjectArgs';
810
export * from './getLongStringArgs';
911
export * from './hasExpandContent';
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
import { isExceptionPayload } from './isExceptionPayload';
4+
5+
describe('isExceptionPayload', () => {
6+
it('should return true for a valid ExceptionPayload', () => {
7+
expect(
8+
isExceptionPayload({
9+
exceptionDetails: { text: 'Uncaught', lineNumber: 0, columnNumber: 0, exceptionId: 1 },
10+
timestamp: 1778137688722,
11+
}),
12+
).toBe(true);
13+
});
14+
15+
it('should return false if exceptionDetails is missing', () => {
16+
expect(isExceptionPayload({ timestamp: 1778137688722 })).toBe(false);
17+
});
18+
19+
it('should return false if timestamp is missing', () => {
20+
expect(
21+
isExceptionPayload({
22+
exceptionDetails: { text: 'Uncaught', lineNumber: 0, columnNumber: 0, exceptionId: 1 },
23+
}),
24+
).toBe(false);
25+
});
26+
27+
it('should return false for null', () => {
28+
expect(isExceptionPayload(null)).toBe(false);
29+
});
30+
31+
it('should return false for a primitive', () => {
32+
expect(isExceptionPayload('string')).toBe(false);
33+
});
34+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { isNonNullObject, isNumber } from '@/lib/guards';
2+
import type { ExceptionPayload } from './types';
3+
4+
export function isExceptionPayload(payload: unknown): payload is ExceptionPayload {
5+
if (!isNonNullObject(payload)) {
6+
return false;
7+
}
8+
9+
return (
10+
isNonNullObject(payload.exceptionDetails)
11+
&& isNumber(payload.timestamp)
12+
);
13+
}

src/lib/console/parseConsoleMessage.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,12 @@ describe('parseConsoleMessage', () => {
8686

8787
expect(result).toEqual([{ text: 'hello', style: undefined }]);
8888
});
89+
90+
it('should use description as text for an object arg without value', () => {
91+
const result = parseConsoleMessage([
92+
{ type: 'object', description: 'ReferenceError: expect is not defined' },
93+
]);
94+
95+
expect(result).toEqual([{ text: 'ReferenceError: expect is not defined' }]);
96+
});
8997
});

src/lib/console/parseConsoleMessage.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ function argToString(arg: RemoteObject): string {
2828
return isString(arg.value) ? arg.value : JSON.stringify(arg.value);
2929
}
3030

31+
if (arg.description !== undefined) {
32+
return arg.description;
33+
}
34+
3135
return `[${arg.type}]`;
3236
}
3337

src/lib/console/types.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,27 @@ export interface MessageSegment {
3939
text: string;
4040
style?: React.CSSProperties;
4141
}
42+
43+
export interface ExceptionObject {
44+
className?: string;
45+
description?: string;
46+
type: string;
47+
subtype?: string;
48+
}
49+
50+
export interface ExceptionDetails {
51+
columnNumber: number;
52+
exception?: ExceptionObject;
53+
exceptionId: number;
54+
executionContextId?: number;
55+
lineNumber: number;
56+
scriptId?: string;
57+
stackTrace?: StackTrace;
58+
text: string;
59+
url?: string;
60+
}
61+
62+
export interface ExceptionPayload {
63+
exceptionDetails: ExceptionDetails;
64+
timestamp: number;
65+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useState } from 'react';
2+
import { Button } from 'antd';
3+
import { CopyOutlined, CheckOutlined } from '@ant-design/icons';
4+
import { createStyles } from 'antd-style';
5+
6+
const COPIED_RESET_DELAY_MS = 1500;
7+
8+
const useStyles = createStyles(({ token }) => ({
9+
button: {
10+
position: 'absolute',
11+
right: 8,
12+
top: '50%',
13+
transform: 'translateY(-50%)',
14+
zIndex: 1,
15+
fontSize: '11px',
16+
gap: '3px',
17+
paddingInline: '6px',
18+
color: token.colorTextSecondary,
19+
border: `1px solid ${token.colorBorderSecondary}`,
20+
'&:hover': {
21+
borderColor: `${token.colorPrimary} !important`,
22+
color: `${token.colorPrimary} !important`,
23+
},
24+
},
25+
}));
26+
27+
interface CopyJsonButtonProps {
28+
data: unknown;
29+
visible: boolean;
30+
}
31+
32+
export function CopyJsonButton({ data, visible }: CopyJsonButtonProps) {
33+
const { styles } = useStyles();
34+
const [copied, setCopied] = useState(false);
35+
36+
if (!visible || data == null) {
37+
return null;
38+
}
39+
40+
const handleCopy = (e: React.MouseEvent) => {
41+
e.stopPropagation();
42+
43+
const text = JSON.stringify(data, null, 2);
44+
45+
navigator.clipboard.writeText(text).then(() => {
46+
setCopied(true);
47+
setTimeout(() => setCopied(false), COPIED_RESET_DELAY_MS);
48+
});
49+
};
50+
51+
return (
52+
<Button
53+
className={styles.button}
54+
size="small"
55+
type="text"
56+
icon={copied ? <CheckOutlined style={{ color: '#52c41a' }} /> : <CopyOutlined />}
57+
onClick={handleCopy}
58+
>
59+
{copied ? 'Copied!' : 'JSON'}
60+
</Button>
61+
);
62+
}

0 commit comments

Comments
 (0)