Skip to content

Commit d0cddd2

Browse files
committed
feat: add streaming API interceptor
1 parent eead9e0 commit d0cddd2

11 files changed

Lines changed: 606 additions & 6 deletions

File tree

src/browser/base-page.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
waitForTextJs,
1919
waitForCaptureJs,
2020
waitForSelectorJs,
21+
waitForStreamCaptureJs,
2122
scrollJs,
2223
autoScrollJs,
2324
networkRequestsJs,
@@ -159,6 +160,22 @@ export abstract class BasePage implements IPage {
159160
await this.evaluate(waitForCaptureJs(maxMs));
160161
}
161162

163+
async installStreamingInterceptor(pattern: string): Promise<void> {
164+
const { generateStreamingInterceptorJs } = await import('../interceptor.js');
165+
await this.evaluate(generateStreamingInterceptorJs(JSON.stringify(pattern)));
166+
}
167+
168+
async getStreamedResponses(opts?: { clear?: boolean }): Promise<{ text: string; events: any[]; done: boolean; errors: any[] }> {
169+
const { generateReadStreamJs } = await import('../interceptor.js');
170+
const clear = opts?.clear !== false; // default true for backwards compat
171+
return (await this.evaluate(generateReadStreamJs('__opencli_stream', clear))) as { text: string; events: any[]; done: boolean; errors: any[] };
172+
}
173+
174+
async waitForStreamCapture(timeout: number = 30, opts?: { minChars?: number; waitForDone?: boolean }): Promise<void> {
175+
const maxMs = timeout * 1000;
176+
await this.evaluate(waitForStreamCaptureJs(maxMs, opts));
177+
}
178+
162179
/** Fallback basic snapshot */
163180
protected async _basicSnapshot(opts: Pick<SnapshotOptions, 'interactive' | 'compact' | 'maxDepth' | 'raw'> = {}): Promise<unknown> {
164181
const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));

src/browser/cdp.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { BrowserCookie, IPage, ScreenshotOptions } from '../types.js';
1515
import type { IBrowserFactory } from '../runtime.js';
1616
import { wrapForEval } from './utils.js';
1717
import { generateStealthJs } from './stealth.js';
18-
import { waitForDomStableJs } from './dom-helpers.js';
18+
import { waitForDomStableJs, waitForCaptureJs } from './dom-helpers.js';
1919
import { isRecord, saveBase64ToFile } from '../utils.js';
2020
import { getAllElectronApps } from '../electron-apps.js';
2121
import { BasePage } from './base-page.js';
@@ -234,6 +234,33 @@ class CDPPage extends BasePage {
234234
async selectTab(_index: number): Promise<void> {
235235
// Not supported in direct CDP mode
236236
}
237+
238+
async consoleMessages(_level?: string): Promise<unknown[]> {
239+
return [];
240+
}
241+
242+
async getCurrentUrl(): Promise<string | null> {
243+
return this._lastUrl;
244+
}
245+
246+
async installInterceptor(pattern: string): Promise<void> {
247+
const { generateInterceptorJs } = await import('../interceptor.js');
248+
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
249+
arrayName: '__opencli_xhr',
250+
patchGuard: '__opencli_interceptor_patched',
251+
}));
252+
}
253+
254+
async getInterceptedRequests(): Promise<unknown[]> {
255+
const { generateReadInterceptedJs } = await import('../interceptor.js');
256+
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
257+
return Array.isArray(result) ? result : [];
258+
}
259+
260+
async waitForCapture(timeout: number = 10): Promise<void> {
261+
const maxMs = timeout * 1000;
262+
await this.evaluate(waitForCaptureJs(maxMs));
263+
}
237264
}
238265

239266
function isCookie(value: unknown): value is BrowserCookie {

src/browser/daemon-client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export async function isExtensionConnected(): Promise<boolean> {
9090
export async function sendCommand(
9191
action: DaemonCommand['action'],
9292
params: Omit<DaemonCommand, 'id' | 'action'> = {},
93+
timeoutMs: number = 30000,
9394
): Promise<unknown> {
9495
const maxRetries = 4;
9596

@@ -99,7 +100,7 @@ export async function sendCommand(
99100
const command: DaemonCommand = { id, action, ...params };
100101
try {
101102
const controller = new AbortController();
102-
const timer = setTimeout(() => controller.abort(), 30000);
103+
const timer = setTimeout(() => controller.abort(), timeoutMs);
103104

104105
const res = await fetch(`${DAEMON_URL}/command`, {
105106
method: 'POST',

src/browser/dom-helpers.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { autoScrollJs, waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js';
2+
import { autoScrollJs, waitForCaptureJs, waitForSelectorJs, waitForStreamCaptureJs } from './dom-helpers.js';
33

44
describe('autoScrollJs', () => {
55
it('returns early without error when document.body is null', async () => {
@@ -112,3 +112,64 @@ describe('waitForSelectorJs', () => {
112112
delete g.MutationObserver;
113113
});
114114
});
115+
116+
describe('waitForStreamCaptureJs', () => {
117+
it('returns a non-empty string with default prefix', () => {
118+
const code = waitForStreamCaptureJs(1000);
119+
expect(typeof code).toBe('string');
120+
expect(code.length).toBeGreaterThan(0);
121+
expect(code).toContain('__opencli_stream_text');
122+
expect(code).toContain('__opencli_stream_done');
123+
});
124+
125+
it('generates code that resolves when minChars is reached', async () => {
126+
const g = globalThis as any;
127+
g.__opencli_stream_text = '';
128+
g.__opencli_stream_done = false;
129+
g.window = g;
130+
const code = waitForStreamCaptureJs(1000, { minChars: 5 });
131+
const promise = eval(code) as Promise<void>;
132+
// Simulate data arriving
133+
g.__opencli_stream_text = 'hello world';
134+
await expect(promise).resolves.not.toThrow();
135+
delete g.__opencli_stream_text;
136+
delete g.__opencli_stream_done;
137+
delete g.window;
138+
});
139+
140+
it('generates code that resolves when done flag is set', async () => {
141+
const g = globalThis as any;
142+
g.__opencli_stream_text = '';
143+
g.__opencli_stream_done = false;
144+
g.window = g;
145+
const code = waitForStreamCaptureJs(1000, { waitForDone: true });
146+
const promise = eval(code) as Promise<void>;
147+
// Simulate stream completion — need both minChars AND done
148+
g.__opencli_stream_text = 'data arrived';
149+
g.__opencli_stream_done = true;
150+
await expect(promise).resolves.not.toThrow();
151+
delete g.__opencli_stream_text;
152+
delete g.__opencli_stream_done;
153+
delete g.window;
154+
});
155+
156+
it('generates code that rejects on timeout', async () => {
157+
const g = globalThis as any;
158+
g.__opencli_stream_text = '';
159+
g.__opencli_stream_done = false;
160+
g.window = g;
161+
const code = waitForStreamCaptureJs(50, { minChars: 100, waitForDone: true });
162+
const promise = eval(code) as Promise<void>;
163+
await expect(promise).rejects.toThrow();
164+
delete g.__opencli_stream_text;
165+
delete g.__opencli_stream_done;
166+
delete g.window;
167+
});
168+
169+
it('uses custom prefix when provided', () => {
170+
const code = waitForStreamCaptureJs(1000, { prefix: '__my_prefix' });
171+
expect(code).toContain('__my_prefix_text');
172+
expect(code).toContain('__my_prefix_done');
173+
expect(code).not.toContain('__opencli_stream');
174+
});
175+
});

src/browser/dom-helpers.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,35 @@ export function waitForCaptureJs(maxMs: number): string {
200200
`;
201201
}
202202

203+
/**
204+
* Generate JS to wait until the streaming interceptor has captured data.
205+
* Polls window.__opencli_stream_text length. Optionally waits for stream completion.
206+
* 50ms interval, rejects after maxMs.
207+
*/
208+
export function waitForStreamCaptureJs(
209+
maxMs: number,
210+
opts: { minChars?: number; waitForDone?: boolean; prefix?: string } = {},
211+
): string {
212+
const minChars = opts.minChars ?? 1;
213+
const waitForDone = opts.waitForDone ?? false;
214+
const prefix = opts.prefix ?? '__opencli_stream';
215+
return `
216+
new Promise((resolve, reject) => {
217+
const deadline = Date.now() + ${maxMs};
218+
const check = () => {
219+
const text = window.${prefix}_text || '';
220+
const done = window.${prefix}_done || false;
221+
if (text.length >= ${minChars} && (${waitForDone} ? done : true)) {
222+
return resolve('captured');
223+
}
224+
if (Date.now() > deadline) return reject(new Error('Stream capture timeout'));
225+
setTimeout(check, 50);
226+
};
227+
check();
228+
})
229+
`;
230+
}
231+
203232
/**
204233
* Generate JS to wait until document.querySelector(selector) returns a match.
205234
* Uses MutationObserver for near-instant resolution; falls back to reject after timeoutMs.

src/browser/page.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import { sendCommand } from './daemon-client.js';
1515
import { wrapForEval } from './utils.js';
1616
import { saveBase64ToFile } from '../utils.js';
1717
import { generateStealthJs } from './stealth.js';
18-
import { waitForDomStableJs } from './dom-helpers.js';
19-
import { BasePage } from './base-page.js';
2018

19+
import { waitForDomStableJs, waitForCaptureJs, waitForStreamCaptureJs } from './dom-helpers.js';
20+
import { BasePage } from './base-page.js';
2121
export function isRetryableSettleError(err: unknown): boolean {
2222
const message = err instanceof Error ? err.message : String(err);
2323
return message.includes('Inspected target navigated or closed')
@@ -179,6 +179,15 @@ export class Page extends BasePage {
179179
throw new Error('setFileInput returned no count — command may not be supported by the extension');
180180
}
181181
}
182+
183+
// Override: waitForStreamCapture needs longer HTTP timeout for long-running browser promises
184+
async waitForStreamCapture(timeout: number = 30, opts?: { minChars?: number; waitForDone?: boolean }): Promise<void> {
185+
const maxMs = timeout * 1000;
186+
await sendCommand('exec', {
187+
code: waitForStreamCaptureJs(maxMs, opts),
188+
...this._cmdOpts(),
189+
}, maxMs + 10000); // HTTP timeout = browser timeout + 10s buffer
190+
}
182191
}
183192

184193
// (End of file)

src/interceptor.test.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import { describe, it, expect } from 'vitest';
6-
import { generateInterceptorJs, generateReadInterceptedJs, generateTapInterceptorJs } from './interceptor.js';
6+
import { generateInterceptorJs, generateReadInterceptedJs, generateTapInterceptorJs, generateStreamingInterceptorJs, generateReadStreamJs } from './interceptor.js';
77

88
describe('generateInterceptorJs', () => {
99
it('generates valid JavaScript function source', () => {
@@ -92,3 +92,132 @@ describe('generateTapInterceptorJs', () => {
9292
expect(tap.xhrPatch).toContain('!captured');
9393
});
9494
});
95+
96+
describe('generateStreamingInterceptorJs', () => {
97+
it('generates valid JavaScript function source', () => {
98+
const js = generateStreamingInterceptorJs('"api/stream"');
99+
expect(js.trim()).toMatch(/^\(\)\s*=>/);
100+
expect(js).toContain('"api/stream"');
101+
});
102+
103+
it('initializes all streaming state variables', () => {
104+
const js = generateStreamingInterceptorJs('"test"');
105+
expect(js).toContain('__opencli_stream_text');
106+
expect(js).toContain('__opencli_stream_events');
107+
expect(js).toContain('__opencli_stream_done');
108+
expect(js).toContain('__opencli_stream_errors');
109+
expect(js).toContain('__opencli_stream_sse_buf');
110+
});
111+
112+
it('resets SSE buffer on install', () => {
113+
const js = generateStreamingInterceptorJs('"test"');
114+
// SSE buffer should be reset at initialization time
115+
expect(js).toContain('__opencli_stream_sse_buf');
116+
expect(js).toContain("= ''");
117+
});
118+
119+
it('resets all state including SSE buffer on each new matching fetch', () => {
120+
const js = generateStreamingInterceptorJs('"test"');
121+
// Inside the fetch patch, SSE buffer should be cleared for new request
122+
const fetchResetCount = (js.match(/__opencli_stream_sse_buf/g) || []).length;
123+
// Should appear at least twice: init + fetch reset + XHR reset
124+
expect(fetchResetCount).toBeGreaterThanOrEqual(2);
125+
});
126+
127+
it('patches both fetch and XHR', () => {
128+
const js = generateStreamingInterceptorJs('"test"');
129+
expect(js).toContain('window.fetch');
130+
expect(js).toContain('XMLHttpRequest.prototype');
131+
expect(js).toContain('onprogress');
132+
expect(js).toContain('readystatechange');
133+
});
134+
135+
it('normalizes CRLF in SSE parsing', () => {
136+
const js = generateStreamingInterceptorJs('"test"');
137+
// SSE parser should normalize \r\n to \n
138+
expect(js).toContain(String.raw`replace(/\r\n/g, '\n')`);
139+
});
140+
141+
it('normalizes CRLF in fetch SSE boundary detection', () => {
142+
const js = generateStreamingInterceptorJs('"test"');
143+
// Fetch path should normalize CRLF before SSE boundary splitting
144+
expect(js).toContain('sseBuffer');
145+
expect(js).toContain(String.raw`replace(/\r\n/g, '\n')`);
146+
});
147+
148+
it('normalizes CRLF in XHR SSE boundary detection', () => {
149+
const js = generateStreamingInterceptorJs('"test"');
150+
// XHR progress path should normalize CRLF
151+
expect(js).toContain(String.raw`chunk.replace(/\r\n/g, '\n')`);
152+
});
153+
154+
it('includes SSE parser function', () => {
155+
const js = generateStreamingInterceptorJs('"test"');
156+
expect(js).toContain('__parseSse');
157+
expect(js).toContain('event:');
158+
expect(js).toContain('data:');
159+
});
160+
161+
it('uses custom prefix and patch guard', () => {
162+
const js = generateStreamingInterceptorJs('"test"', {
163+
arrayName: '__my_stream',
164+
patchGuard: '__my_stream_guard',
165+
});
166+
expect(js).toContain('__my_stream_text');
167+
expect(js).toContain('__my_stream_events');
168+
expect(js).toContain('__my_stream_guard');
169+
expect(js).not.toContain('__opencli_stream');
170+
});
171+
172+
it('uses custom maxChunks', () => {
173+
const js = generateStreamingInterceptorJs('"test"', { maxChunks: 100 });
174+
expect(js).toContain('100');
175+
});
176+
177+
it('XHR readystatechange uses authoritative responseText overwrite', () => {
178+
const js = generateStreamingInterceptorJs('"test"');
179+
// readystatechange readyState=4 should overwrite with full responseText
180+
expect(js).toContain('readyState === 4');
181+
expect(js).toContain('window.__opencli_stream_text = full');
182+
});
183+
184+
it('XHR load event is guarded by settled flag', () => {
185+
const js = generateStreamingInterceptorJs('"test"');
186+
expect(js).toContain('if (settled) return');
187+
});
188+
});
189+
190+
describe('generateReadStreamJs', () => {
191+
it('generates valid JavaScript to read streaming state', () => {
192+
const js = generateReadStreamJs();
193+
expect(js.trim()).toMatch(/^\(\)\s*=>/);
194+
expect(js).toContain('__opencli_stream_text');
195+
expect(js).toContain('__opencli_stream_events');
196+
expect(js).toContain('__opencli_stream_done');
197+
expect(js).toContain('__opencli_stream_errors');
198+
});
199+
200+
it('clears all state including SSE buffer by default', () => {
201+
const js = generateReadStreamJs();
202+
expect(js).toContain('__opencli_stream_text');
203+
expect(js).toContain('__opencli_stream_sse_buf');
204+
expect(js).toContain("= ''");
205+
expect(js).toContain('= []');
206+
});
207+
208+
it('does not clear state when clear=false (peek mode)', () => {
209+
const js = generateReadStreamJs('__opencli_stream', false);
210+
// Should NOT contain clearing statements
211+
expect(js).not.toContain("window.__opencli_stream_text = ''");
212+
expect(js).not.toContain("window.__opencli_stream_sse_buf = ''");
213+
// But should still contain the read statements
214+
expect(js).toContain('__opencli_stream_text');
215+
});
216+
217+
it('uses custom prefix', () => {
218+
const js = generateReadStreamJs('__my_prefix');
219+
expect(js).toContain('__my_prefix_text');
220+
expect(js).toContain('__my_prefix_events');
221+
expect(js).not.toContain('__opencli_stream');
222+
});
223+
});

0 commit comments

Comments
 (0)