@monoes/monobrowse 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/dist/src/__tests__/bridge-instruments.test.d.ts +2 -0
  2. package/dist/src/__tests__/bridge-instruments.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/bridge-instruments.test.js +132 -0
  4. package/dist/src/__tests__/bridge-instruments.test.js.map +1 -0
  5. package/dist/src/__tests__/bridge-transport.test.d.ts +2 -0
  6. package/dist/src/__tests__/bridge-transport.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/bridge-transport.test.js +287 -0
  8. package/dist/src/__tests__/bridge-transport.test.js.map +1 -0
  9. package/dist/src/__tests__/cdp-transport.test.d.ts +2 -0
  10. package/dist/src/__tests__/cdp-transport.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/cdp-transport.test.js +167 -0
  12. package/dist/src/__tests__/cdp-transport.test.js.map +1 -0
  13. package/dist/src/browser/bridge.d.ts +119 -0
  14. package/dist/src/browser/bridge.d.ts.map +1 -0
  15. package/dist/src/browser/bridge.js +264 -0
  16. package/dist/src/browser/bridge.js.map +1 -0
  17. package/dist/src/browser/browser.d.ts.map +1 -1
  18. package/dist/src/browser/browser.js +11 -0
  19. package/dist/src/browser/browser.js.map +1 -1
  20. package/dist/src/browser/cdp.d.ts +11 -1
  21. package/dist/src/browser/cdp.d.ts.map +1 -1
  22. package/dist/src/browser/cdp.js +60 -56
  23. package/dist/src/browser/cdp.js.map +1 -1
  24. package/dist/src/browser/index.d.ts +2 -0
  25. package/dist/src/browser/index.d.ts.map +1 -1
  26. package/dist/src/browser/index.js +2 -0
  27. package/dist/src/browser/index.js.map +1 -1
  28. package/dist/src/browser/transport.d.ts +62 -0
  29. package/dist/src/browser/transport.d.ts.map +1 -0
  30. package/dist/src/browser/transport.js +65 -0
  31. package/dist/src/browser/transport.js.map +1 -0
  32. package/dist/src/cli/commander-adapter.d.ts +16 -0
  33. package/dist/src/cli/commander-adapter.d.ts.map +1 -0
  34. package/dist/src/cli/commander-adapter.js +68 -0
  35. package/dist/src/cli/commander-adapter.js.map +1 -0
  36. package/dist/src/cli/commands-context.d.ts +14 -0
  37. package/dist/src/cli/commands-context.d.ts.map +1 -0
  38. package/dist/src/cli/commands-context.js +351 -0
  39. package/dist/src/cli/commands-context.js.map +1 -0
  40. package/dist/src/cli/commands-data.d.ts +12 -0
  41. package/dist/src/cli/commands-data.d.ts.map +1 -0
  42. package/dist/src/cli/commands-data.js +246 -0
  43. package/dist/src/cli/commands-data.js.map +1 -0
  44. package/dist/src/cli/commands-element.d.ts +15 -0
  45. package/dist/src/cli/commands-element.d.ts.map +1 -0
  46. package/dist/src/cli/commands-element.js +262 -0
  47. package/dist/src/cli/commands-element.js.map +1 -0
  48. package/dist/src/cli/commands-files.d.ts +9 -0
  49. package/dist/src/cli/commands-files.d.ts.map +1 -0
  50. package/dist/src/cli/commands-files.js +207 -0
  51. package/dist/src/cli/commands-files.js.map +1 -0
  52. package/dist/src/cli/commands-input.d.ts +26 -0
  53. package/dist/src/cli/commands-input.d.ts.map +1 -0
  54. package/dist/src/cli/commands-input.js +399 -0
  55. package/dist/src/cli/commands-input.js.map +1 -0
  56. package/dist/src/cli/commands-navigate.d.ts +12 -0
  57. package/dist/src/cli/commands-navigate.d.ts.map +1 -0
  58. package/dist/src/cli/commands-navigate.js +389 -0
  59. package/dist/src/cli/commands-navigate.js.map +1 -0
  60. package/dist/src/cli/commands-page.d.ts +25 -0
  61. package/dist/src/cli/commands-page.d.ts.map +1 -0
  62. package/dist/src/cli/commands-page.js +377 -0
  63. package/dist/src/cli/commands-page.js.map +1 -0
  64. package/dist/src/cli/commands-report.d.ts +8 -0
  65. package/dist/src/cli/commands-report.d.ts.map +1 -0
  66. package/dist/src/cli/commands-report.js +256 -0
  67. package/dist/src/cli/commands-report.js.map +1 -0
  68. package/dist/src/cli/commands-script.d.ts +43 -0
  69. package/dist/src/cli/commands-script.d.ts.map +1 -0
  70. package/dist/src/cli/commands-script.js +168 -0
  71. package/dist/src/cli/commands-script.js.map +1 -0
  72. package/dist/src/cli/commands-session.d.ts +15 -0
  73. package/dist/src/cli/commands-session.d.ts.map +1 -0
  74. package/dist/src/cli/commands-session.js +357 -0
  75. package/dist/src/cli/commands-session.js.map +1 -0
  76. package/dist/src/cli/commands-trace.d.ts +12 -0
  77. package/dist/src/cli/commands-trace.d.ts.map +1 -0
  78. package/dist/src/cli/commands-trace.js +347 -0
  79. package/dist/src/cli/commands-trace.js.map +1 -0
  80. package/dist/src/cli/commands.d.ts +22 -42
  81. package/dist/src/cli/commands.d.ts.map +1 -1
  82. package/dist/src/cli/commands.js +42 -3648
  83. package/dist/src/cli/commands.js.map +1 -1
  84. package/dist/src/cli/session.d.ts +47 -0
  85. package/dist/src/cli/session.d.ts.map +1 -0
  86. package/dist/src/cli/session.js +343 -0
  87. package/dist/src/cli/session.js.map +1 -0
  88. package/dist/tsconfig.tsbuildinfo +1 -1
  89. package/package.json +1 -1
  90. package/src/__tests__/bridge-instruments.test.ts +180 -0
  91. package/src/__tests__/bridge-transport.test.ts +365 -0
  92. package/src/__tests__/cdp-transport.test.ts +198 -0
  93. package/src/browser/bridge.ts +354 -0
  94. package/src/browser/browser.ts +12 -0
  95. package/src/browser/cdp.ts +57 -52
  96. package/src/browser/index.ts +2 -0
  97. package/src/browser/transport.ts +111 -0
  98. package/src/cli/commander-adapter.ts +65 -0
  99. package/src/cli/commands-context.ts +367 -0
  100. package/src/cli/commands-data.ts +248 -0
  101. package/src/cli/commands-element.ts +305 -0
  102. package/src/cli/commands-files.ts +248 -0
  103. package/src/cli/commands-input.ts +434 -0
  104. package/src/cli/commands-navigate.ts +442 -0
  105. package/src/cli/commands-page.ts +420 -0
  106. package/src/cli/commands-report.ts +275 -0
  107. package/src/cli/commands-script.ts +183 -0
  108. package/src/cli/commands-session.ts +376 -0
  109. package/src/cli/commands-trace.ts +349 -0
  110. package/src/cli/commands.ts +95 -3943
  111. package/src/cli/session.ts +412 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monobrowse",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "Native browser automation via Chrome DevTools Protocol — the engine powering monomind browse",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,180 @@
1
+ /**
2
+ * RIG-07: the instruments, unmodified, against the extension bridge.
3
+ *
4
+ * This is the payoff the transport seam exists for. Each case calls the same
5
+ * monobrowse entry points the MCP tools in @monomind/cli call — the
6
+ * `browser_console`, `browser_network` and `browser_vitals` handlers each do
7
+ * no more than take a `{ client, cdpSessionId }` pair and hand it to these
8
+ * functions — but the pair here is backed by the bridge rather than by a
9
+ * socket to a Chrome we launched.
10
+ *
11
+ * The far end is a fake relay, not Chrome: it answers CDP commands and pushes
12
+ * debugger events, which is exactly the contract internal/extension/cdp.go
13
+ * and chrome-extension/cdp_proxy.js implement between them. What it proves is
14
+ * that nothing in the instruments needs to know which browser it is on.
15
+ */
16
+ import type { AddressInfo } from 'node:net';
17
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
18
+ import type { WebSocket as WsSocket } from 'ws';
19
+ import { WebSocketServer } from 'ws';
20
+ import { BridgeTransport, bridgeSessionId } from '../browser/bridge.js';
21
+ import { CdpClient } from '../browser/cdp.js';
22
+ import {
23
+ clearConsoleMessages,
24
+ getConsoleMessages,
25
+ getPageErrors,
26
+ setupConsoleCapture,
27
+ teardownConsoleCapture,
28
+ } from '../browser/console-log.js';
29
+ import {
30
+ getCapturedRequests,
31
+ startRequestCapture,
32
+ stopRequestCapture,
33
+ } from '../browser/network.js';
34
+ import { collectVitals } from '../browser/vitals.js';
35
+
36
+ /** The Go relay plus the extension behind it, as far as CDP is concerned. */
37
+ class FakeChrome {
38
+ server: WebSocketServer;
39
+ socket: WsSocket | null = null;
40
+ /** Canned Runtime.evaluate return values, by the order they are asked for. */
41
+ evalResults: unknown[] = [];
42
+ methods: string[] = [];
43
+
44
+ constructor() {
45
+ this.server = new WebSocketServer({ host: '127.0.0.1', port: 0 });
46
+ this.server.on('connection', (ws) => {
47
+ this.socket = ws;
48
+ ws.on('message', (raw) => {
49
+ const env = JSON.parse(raw.toString());
50
+ if (env.type !== 'cdp') {
51
+ ws.send(JSON.stringify({ id: env.id, success: true, data: { tabId: env.tabId ?? 5 } }));
52
+ return;
53
+ }
54
+ const { method } = env.params as { method: string };
55
+ this.methods.push(method);
56
+ const result =
57
+ method === 'Runtime.evaluate'
58
+ ? { result: { type: 'object', value: this.evalResults.shift() ?? {} } }
59
+ : {};
60
+ ws.send(JSON.stringify({ id: env.id, success: true, data: { result } }));
61
+ });
62
+ });
63
+ }
64
+
65
+ get url(): string {
66
+ return `ws://127.0.0.1:${(this.server.address() as AddressInfo).port}/monoagent/cdp`;
67
+ }
68
+
69
+ ready(): Promise<void> {
70
+ return new Promise((resolve) => this.server.once('listening', resolve));
71
+ }
72
+
73
+ /** What chrome.debugger.onEvent delivers, relayed up the bridge. */
74
+ emit(tabId: number, method: string, params: Record<string, unknown>): void {
75
+ this.socket!.send(JSON.stringify({ type: 'cdp_event', data: { tabId, method, params } }));
76
+ }
77
+
78
+ close(): Promise<void> {
79
+ return new Promise((resolve) => this.server.close(() => resolve()));
80
+ }
81
+ }
82
+
83
+ const TAB = 5;
84
+ const SESSION = bridgeSessionId(TAB);
85
+
86
+ describe('RIG-07: monobrowse instruments over the extension bridge', () => {
87
+ let chrome: FakeChrome;
88
+ let client: CdpClient;
89
+
90
+ beforeEach(async () => {
91
+ chrome = new FakeChrome();
92
+ if (chrome.server.address() === null) await chrome.ready();
93
+ client = new CdpClient();
94
+ await client.connectTransport(
95
+ new BridgeTransport({ tabId: TAB, url: chrome.url, token: 'tok' }),
96
+ );
97
+ });
98
+
99
+ afterEach(async () => {
100
+ client.close();
101
+ await chrome.close();
102
+ });
103
+
104
+ // browser_console: setupConsoleCapture at connect, read the buffers later.
105
+ it('browser_console reads the real tab console', async () => {
106
+ setupConsoleCapture(client, SESSION);
107
+ clearConsoleMessages(SESSION);
108
+ await client.send('Runtime.enable', {}, SESSION);
109
+ await client.send('Log.enable', {}, SESSION);
110
+
111
+ chrome.emit(TAB, 'Runtime.consoleAPICalled', {
112
+ type: 'warning',
113
+ args: [{ value: 'cart total mismatch' }],
114
+ });
115
+ chrome.emit(TAB, 'Log.entryAdded', {
116
+ entry: { level: 'error', text: 'Failed to load resource: 402', url: 'https://shop.test/pay' },
117
+ });
118
+ chrome.emit(TAB, 'Runtime.exceptionThrown', {
119
+ exceptionDetails: { exception: { description: 'ReferenceError: checkout is not defined' } },
120
+ });
121
+ await tick();
122
+
123
+ expect(getConsoleMessages(SESSION)).toMatchObject([
124
+ { type: 'warn', text: 'cart total mismatch' },
125
+ { type: 'error', text: 'Failed to load resource: 402', url: 'https://shop.test/pay' },
126
+ ]);
127
+ expect(getPageErrors(SESSION)).toMatchObject([
128
+ { text: 'ReferenceError: checkout is not defined' },
129
+ ]);
130
+ teardownConsoleCapture(SESSION);
131
+ });
132
+
133
+ // browser_network action:"start" then action:"list".
134
+ it('browser_network records the real tab requests', async () => {
135
+ startRequestCapture(client, SESSION);
136
+
137
+ chrome.emit(TAB, 'Network.requestWillBeSent', {
138
+ requestId: 'R1',
139
+ request: { url: 'https://shop.test/api/cart', method: 'GET', headers: {} },
140
+ timestamp: 1,
141
+ });
142
+ chrome.emit(TAB, 'Network.responseReceived', {
143
+ requestId: 'R1',
144
+ response: { status: 402, mimeType: 'application/json', headers: {} },
145
+ timestamp: 2,
146
+ });
147
+ chrome.emit(TAB, 'Network.loadingFinished', {
148
+ requestId: 'R1',
149
+ timestamp: 2,
150
+ encodedDataLength: 1234,
151
+ });
152
+ await tick();
153
+
154
+ expect(getCapturedRequests(SESSION)).toMatchObject([
155
+ { url: 'https://shop.test/api/cart', method: 'GET', status: 402 },
156
+ ]);
157
+ stopRequestCapture(SESSION);
158
+ });
159
+
160
+ // browser_vitals: one Runtime.evaluate, straight through the bridge.
161
+ it('browser_vitals measures the real tab', async () => {
162
+ chrome.evalResults = [{ lcp: 2400, fcp: 900, cls: 0.03, ttfb: 180 }];
163
+ const vitals = await collectVitals(client, SESSION, 0);
164
+
165
+ expect(vitals).toMatchObject({ lcp: 2400, fcp: 900, cls: 0.03, ttfb: 180 });
166
+ expect(chrome.methods).toContain('Runtime.evaluate');
167
+ });
168
+
169
+ it('every command reached the tab session, with no CDP sessionId on the wire', async () => {
170
+ await client.send('Page.enable', {}, SESSION);
171
+ // The synthetic session id is monobrowse's bookkeeping; chrome.debugger
172
+ // addresses the page by its debuggee, so it must never travel.
173
+ expect(chrome.methods).toEqual(['Page.enable']);
174
+ });
175
+ });
176
+
177
+ /** Let the relayed events drain through the socket and the listeners. */
178
+ function tick(): Promise<void> {
179
+ return new Promise((r) => setTimeout(r, 50));
180
+ }
@@ -0,0 +1,365 @@
1
+ /**
2
+ * The bridge-backed transport: CDP over the MonoAgent extension bridge.
3
+ *
4
+ * The end-to-end case runs against a real loopback WebSocket server standing
5
+ * in for the Go relay and the extension behind it — no Chrome, but real
6
+ * framing, real sockets, real asynchrony. That is the case that proves the
7
+ * instruments work over the bridge, so it drives console capture (one of the
8
+ * modules this whole seam exists for) rather than a bare send().
9
+ */
10
+ import type { AddressInfo } from 'node:net';
11
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
12
+ import type { WebSocket as WsSocket } from 'ws';
13
+ import { WebSocketServer } from 'ws';
14
+ import {
15
+ BRIDGE_TOKEN_HEADER,
16
+ BridgeTransport,
17
+ bridgeSessionId,
18
+ bridgeTargetId,
19
+ } from '../browser/bridge.js';
20
+ import { CdpClient } from '../browser/cdp.js';
21
+ import {
22
+ getConsoleMessages,
23
+ getPageErrors,
24
+ setupConsoleCapture,
25
+ teardownConsoleCapture,
26
+ } from '../browser/console-log.js';
27
+ import type { CdpTransport, CdpTransportHandlers } from '../browser/transport.js';
28
+
29
+ // --- a scripted inner pipe, for the translation unit tests -----------------
30
+
31
+ class ScriptedSocket implements CdpTransport {
32
+ sent: unknown[] = [];
33
+ closed = false;
34
+ private handlers: CdpTransportHandlers | null = null;
35
+ /** Answers each outbound envelope as the relay would. */
36
+ autoReply: ((env: Record<string, unknown>) => unknown) | null = null;
37
+
38
+ async open(handlers: CdpTransportHandlers): Promise<void> {
39
+ this.handlers = handlers;
40
+ }
41
+
42
+ async send(frame: string): Promise<void> {
43
+ const env = JSON.parse(frame) as Record<string, unknown>;
44
+ this.sent.push(env);
45
+ const reply = this.autoReply?.(env);
46
+ if (reply !== undefined) this.push(reply);
47
+ }
48
+
49
+ close(): void {
50
+ this.closed = true;
51
+ }
52
+
53
+ push(msg: unknown): void {
54
+ this.handlers!.message(JSON.stringify(msg));
55
+ }
56
+
57
+ drop(err?: Error): void {
58
+ this.handlers!.close(err);
59
+ }
60
+
61
+ /** The last envelope of a given type. */
62
+ lastOf(type: string): Record<string, unknown> | undefined {
63
+ return [...(this.sent as Array<Record<string, unknown>>)]
64
+ .reverse()
65
+ .find((e) => e.type === type);
66
+ }
67
+ }
68
+
69
+ /** A socket whose attach and plain cdp commands always succeed. */
70
+ function okSocket(): ScriptedSocket {
71
+ const s = new ScriptedSocket();
72
+ s.autoReply = (env) => {
73
+ if (env.type === 'cdp_attach' || env.type === 'cdp_detach') {
74
+ return { id: env.id, success: true, data: {} };
75
+ }
76
+ if (env.type === 'cdp') {
77
+ const params = env.params as { method: string };
78
+ return { id: env.id, success: true, data: { result: { echoed: params.method } } };
79
+ }
80
+ return undefined;
81
+ };
82
+ return s;
83
+ }
84
+
85
+ describe('BridgeTransport framing', () => {
86
+ it('attaches the debugger to the tab before reporting itself open', async () => {
87
+ const socket = okSocket();
88
+ const t = new BridgeTransport({ tabId: 42, socket });
89
+ await t.open({ message: () => {}, close: () => {}, error: () => {} });
90
+
91
+ const attach = socket.lastOf('cdp_attach')!;
92
+ expect(attach.tabId).toBe(42);
93
+ });
94
+
95
+ it('fails open() when the tab cannot be attached, instead of half-connecting', async () => {
96
+ const socket = new ScriptedSocket();
97
+ socket.autoReply = (env) => ({
98
+ id: env.id,
99
+ success: false,
100
+ error: 'debugger attach: Cannot access a chrome:// URL',
101
+ });
102
+ const t = new BridgeTransport({ tabId: 7, socket });
103
+ await expect(t.open({ message: () => {}, close: () => {}, error: () => {} })).rejects.toThrow(
104
+ /Cannot access a chrome:\/\/ URL/,
105
+ );
106
+ });
107
+
108
+ it('wraps a CDP frame as a bridge command and unwraps the result', async () => {
109
+ const socket = okSocket();
110
+ const client = new CdpClient();
111
+ await client.connectTransport(new BridgeTransport({ tabId: 42, socket }));
112
+
113
+ const result = await client.send('Page.navigate', { url: 'https://x.test' });
114
+ const cmd = socket.lastOf('cdp')!;
115
+ expect(cmd.tabId).toBe(42);
116
+ expect(cmd.params).toEqual({ method: 'Page.navigate', params: { url: 'https://x.test' } });
117
+ expect(result).toEqual({ echoed: 'Page.navigate' });
118
+ });
119
+
120
+ it('turns a bridge error into a CDP error the caller can read', async () => {
121
+ const socket = new ScriptedSocket();
122
+ socket.autoReply = (env) =>
123
+ env.type === 'cdp_attach'
124
+ ? { id: env.id, success: true, data: {} }
125
+ : { id: env.id, success: false, error: 'Not allowed' };
126
+ const client = new CdpClient();
127
+ await client.connectTransport(new BridgeTransport({ tabId: 42, socket }));
128
+
129
+ await expect(client.send('HeapProfiler.enable')).rejects.toThrow(/Not allowed/);
130
+ });
131
+
132
+ it('strips the synthetic session id from commands on the tab session', async () => {
133
+ const socket = okSocket();
134
+ const client = new CdpClient();
135
+ await client.connectTransport(new BridgeTransport({ tabId: 42, socket }));
136
+
137
+ await client.send('Runtime.enable', {}, bridgeSessionId(42));
138
+ const cmd = socket.lastOf('cdp')!;
139
+ // chrome.debugger's tab debuggee IS the page session; a sessionId here
140
+ // would make it look for a child session that does not exist.
141
+ expect(cmd.params).toEqual({ method: 'Runtime.enable', params: {} });
142
+ });
143
+
144
+ it('passes a real child session id through for out-of-process frames', async () => {
145
+ const socket = okSocket();
146
+ const client = new CdpClient();
147
+ await client.connectTransport(new BridgeTransport({ tabId: 42, socket }));
148
+
149
+ await client.send('Runtime.enable', {}, 'OOPIF-SESSION-1');
150
+ const cmd = socket.lastOf('cdp')!;
151
+ expect(cmd.params).toEqual({
152
+ method: 'Runtime.enable',
153
+ params: {},
154
+ sessionId: 'OOPIF-SESSION-1',
155
+ });
156
+ });
157
+
158
+ it('answers Target.attachToTarget for the tab itself without touching the bridge', async () => {
159
+ const socket = okSocket();
160
+ const client = new CdpClient();
161
+ await client.connectTransport(new BridgeTransport({ tabId: 42, socket }));
162
+
163
+ const before = socket.sent.length;
164
+ const attached = await client.send<{ sessionId: string }>('Target.attachToTarget', {
165
+ targetId: bridgeTargetId(42),
166
+ flatten: true,
167
+ });
168
+ expect(attached.sessionId).toBe(bridgeSessionId(42));
169
+ expect(socket.sent.length).toBe(before);
170
+ });
171
+
172
+ it('forwards Target.attachToTarget for any other target', async () => {
173
+ const socket = okSocket();
174
+ const client = new CdpClient();
175
+ await client.connectTransport(new BridgeTransport({ tabId: 42, socket }));
176
+
177
+ await client.send('Target.attachToTarget', { targetId: 'SOME-IFRAME', flatten: true });
178
+ expect((socket.lastOf('cdp')!.params as { params: unknown }).params).toEqual({
179
+ targetId: 'SOME-IFRAME',
180
+ flatten: true,
181
+ });
182
+ });
183
+
184
+ it('labels relayed events with the tab session so per-session listeners match', async () => {
185
+ const socket = okSocket();
186
+ const client = new CdpClient();
187
+ await client.connectTransport(new BridgeTransport({ tabId: 42, socket }));
188
+
189
+ const seen: Array<string | undefined> = [];
190
+ client.on('Network.responseReceived', (_p, sid) => void seen.push(sid));
191
+ socket.push({
192
+ type: 'cdp_event',
193
+ data: { tabId: 42, method: 'Network.responseReceived', params: { requestId: 'R1' } },
194
+ });
195
+
196
+ expect(seen).toEqual([bridgeSessionId(42)]);
197
+ });
198
+
199
+ it('keeps a child session id on events that carry one', async () => {
200
+ const socket = okSocket();
201
+ const client = new CdpClient();
202
+ await client.connectTransport(new BridgeTransport({ tabId: 42, socket }));
203
+
204
+ const seen: Array<string | undefined> = [];
205
+ client.on('Network.responseReceived', (_p, sid) => void seen.push(sid));
206
+ socket.push({
207
+ type: 'cdp_event',
208
+ data: {
209
+ tabId: 42,
210
+ sessionId: 'OOPIF-SESSION-1',
211
+ method: 'Network.responseReceived',
212
+ params: {},
213
+ },
214
+ });
215
+
216
+ expect(seen).toEqual(['OOPIF-SESSION-1']);
217
+ });
218
+
219
+ it('ignores events for a tab this transport is not driving', async () => {
220
+ const socket = okSocket();
221
+ const client = new CdpClient();
222
+ await client.connectTransport(new BridgeTransport({ tabId: 42, socket }));
223
+
224
+ const seen: unknown[] = [];
225
+ client.on('Network.responseReceived', (p) => void seen.push(p));
226
+ socket.push({
227
+ type: 'cdp_event',
228
+ data: { tabId: 99, method: 'Network.responseReceived', params: {} },
229
+ });
230
+
231
+ expect(seen).toEqual([]);
232
+ });
233
+
234
+ it('fails in-flight commands when the debugger is detached out from under it', async () => {
235
+ const socket = okSocket();
236
+ const client = new CdpClient();
237
+ await client.connectTransport(new BridgeTransport({ tabId: 42, socket }));
238
+
239
+ socket.autoReply = null; // this one never gets answered
240
+ const pending = client.send('Page.enable', {}, undefined, 0);
241
+ socket.push({
242
+ type: 'cdp_event',
243
+ data: { tabId: 42, method: 'Inspector.detached', params: { reason: 'canceled_by_user' } },
244
+ });
245
+
246
+ await expect(pending).rejects.toThrow(/canceled_by_user/);
247
+ expect(client.isConnected()).toBe(false);
248
+ });
249
+
250
+ it('detaches the debugger when the client closes, so the banner goes away', async () => {
251
+ const socket = okSocket();
252
+ const client = new CdpClient();
253
+ await client.connectTransport(new BridgeTransport({ tabId: 42, socket }));
254
+ client.close();
255
+
256
+ expect(socket.lastOf('cdp_detach')).toMatchObject({ tabId: 42 });
257
+ expect(socket.closed).toBe(true);
258
+ });
259
+ });
260
+
261
+ // --- end to end over a real socket -----------------------------------------
262
+
263
+ /**
264
+ * Stands in for `/monoagent/cdp` plus the extension behind it: answers the
265
+ * bridge envelopes and can push unsolicited events, exactly as the Go relay
266
+ * fans out `chrome.debugger.onEvent`.
267
+ */
268
+ class FakeBridge {
269
+ server: WebSocketServer;
270
+ socket: WsSocket | null = null;
271
+ tokens: Array<string | undefined> = [];
272
+ commands: Array<{ method: string; params: unknown }> = [];
273
+
274
+ constructor() {
275
+ this.server = new WebSocketServer({ host: '127.0.0.1', port: 0 });
276
+ this.server.on('connection', (ws, req) => {
277
+ this.socket = ws;
278
+ this.tokens.push(req.headers[BRIDGE_TOKEN_HEADER.toLowerCase()] as string | undefined);
279
+ ws.on('message', (raw) => {
280
+ const env = JSON.parse(raw.toString());
281
+ if (env.type === 'cdp_attach' || env.type === 'cdp_detach') {
282
+ ws.send(JSON.stringify({ id: env.id, success: true, data: {} }));
283
+ return;
284
+ }
285
+ if (env.type === 'cdp') {
286
+ this.commands.push(env.params);
287
+ ws.send(JSON.stringify({ id: env.id, success: true, data: { result: {} } }));
288
+ }
289
+ });
290
+ });
291
+ }
292
+
293
+ get url(): string {
294
+ const { port } = this.server.address() as AddressInfo;
295
+ return `ws://127.0.0.1:${port}/monoagent/cdp`;
296
+ }
297
+
298
+ ready(): Promise<void> {
299
+ return new Promise((resolve) => this.server.once('listening', resolve));
300
+ }
301
+
302
+ /** Push an event up as the extension's chrome.debugger.onEvent would. */
303
+ emit(tabId: number, method: string, params: Record<string, unknown>): void {
304
+ this.socket!.send(JSON.stringify({ type: 'cdp_event', data: { tabId, method, params } }));
305
+ }
306
+
307
+ close(): Promise<void> {
308
+ return new Promise((resolve) => this.server.close(() => resolve()));
309
+ }
310
+ }
311
+
312
+ describe('BridgeTransport end to end over a real socket', () => {
313
+ let bridge: FakeBridge;
314
+
315
+ beforeEach(async () => {
316
+ bridge = new FakeBridge();
317
+ if (bridge.server.address() === null) await bridge.ready();
318
+ });
319
+
320
+ afterEach(async () => {
321
+ await bridge.close();
322
+ });
323
+
324
+ it('carries console capture — an unmodified instrument — over the bridge', async () => {
325
+ const client = new CdpClient();
326
+ const transport = new BridgeTransport({ tabId: 5, url: bridge.url, token: 'tok-123' });
327
+ await client.connectTransport(transport);
328
+
329
+ const sessionId = bridgeSessionId(5);
330
+ setupConsoleCapture(client, sessionId);
331
+ // console-log.ts, untouched: it enables the domains it needs...
332
+ await client.send('Runtime.enable', {}, sessionId);
333
+ await client.send('Log.enable', {}, sessionId);
334
+
335
+ // ...and the page's console output arrives as a relayed debugger event.
336
+ bridge.emit(5, 'Runtime.consoleAPICalled', {
337
+ type: 'error',
338
+ args: [{ value: 'checkout failed' }],
339
+ });
340
+ bridge.emit(5, 'Runtime.exceptionThrown', {
341
+ exceptionDetails: { exception: { description: 'TypeError: x is not a function' } },
342
+ });
343
+ await new Promise((r) => setTimeout(r, 50));
344
+
345
+ expect(getConsoleMessages(sessionId)).toMatchObject([
346
+ { type: 'error', text: 'checkout failed' },
347
+ ]);
348
+ expect(getPageErrors(sessionId)).toMatchObject([{ text: 'TypeError: x is not a function' }]);
349
+ expect(bridge.commands.map((c) => c.method)).toEqual(['Runtime.enable', 'Log.enable']);
350
+ expect(bridge.tokens).toEqual(['tok-123']);
351
+
352
+ teardownConsoleCapture(sessionId);
353
+ client.close();
354
+ });
355
+
356
+ it('reports a dropped bridge to in-flight callers', async () => {
357
+ const client = new CdpClient();
358
+ await client.connectTransport(
359
+ new BridgeTransport({ tabId: 5, url: bridge.url, token: 'tok-123' }),
360
+ );
361
+ const pending = client.send('Page.captureScreenshot', {}, undefined, 0);
362
+ bridge.socket!.close();
363
+ await expect(pending).rejects.toThrow(/closed|bridge/i);
364
+ });
365
+ });