Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/developer/ts-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,25 @@ The `page` parameter provides browser interaction methods:
- `page.click(selector)` — Click an element
- `page.type(selector, text)` — Type text into an input

### Streaming API Interception

For capturing streaming responses (SSE / chunked transfer) — works in background tabs where DOM rendering is throttled but fetch streams and XHR progress events are not.

```typescript
await page.installStreamingInterceptor('StreamGenerate');
// ... trigger the streaming request ...
await page.waitForStreamCapture(60, { minChars: 100, waitForDone: true });
const { text, events, done, errors } = await page.getStreamedResponses();
// Or peek without clearing:
const { text } = await page.getStreamedResponses({ clear: false });
```

| Method | Description |
|--------|-------------|
| `installStreamingInterceptor(pattern)` | Patch fetch + XHR to capture streaming responses |
| `waitForStreamCapture(timeout, opts?)` | Poll until `minChars` reached and/or stream `done` |
| `getStreamedResponses(opts?)` | Read captured text/events; pass `{ clear: false }` to peek |

## The `kwargs` Object

Contains parsed CLI arguments as key-value pairs. Always destructure with defaults:
Expand Down
21 changes: 21 additions & 0 deletions docs/developer/yaml-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,27 @@ Download media files.
```
:::

### `stream-intercept`
Capture streaming responses (SSE / chunked transfer) incrementally. Unlike `intercept`, this reads the response body as a stream — ideal for AI chat endpoints that stream replies, and works in background tabs where DOM rendering is throttled. Requires `browser: true`.

::: v-pre
```yaml
- stream-intercept:
capture: "StreamGenerate"
trigger: "click:@send"
timeout: 60
waitForDone: true
```
:::

| Param | Default | Description |
|-------|---------|-------------|
| `capture` | (required) | URL substring to match |
| `trigger` | `""` | Action before capture: `click:@ref`, `navigate:url`, `evaluate:js`, `scroll` |
| `timeout` | `60` | Max seconds to wait for stream data |
| `waitForDone` | `true` | Wait until the stream completes (not just first bytes) |
| `returnEvents` | `false` | Return parsed SSE events instead of raw text |

## Template Expressions

::: v-pre
Expand Down
17 changes: 17 additions & 0 deletions src/browser/base-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
waitForTextJs,
waitForCaptureJs,
waitForSelectorJs,
waitForStreamCaptureJs,
scrollJs,
autoScrollJs,
networkRequestsJs,
Expand Down Expand Up @@ -159,6 +160,22 @@ export abstract class BasePage implements IPage {
await this.evaluate(waitForCaptureJs(maxMs));
}

async installStreamingInterceptor(pattern: string): Promise<void> {
const { generateStreamingInterceptorJs } = await import('../interceptor.js');
await this.evaluate(generateStreamingInterceptorJs(JSON.stringify(pattern)));
}

async getStreamedResponses(opts?: { clear?: boolean }): Promise<{ text: string; events: any[]; done: boolean; errors: any[] }> {
const { generateReadStreamJs } = await import('../interceptor.js');
const clear = opts?.clear !== false; // default true for backwards compat
return (await this.evaluate(generateReadStreamJs('__opencli_stream', clear))) as { text: string; events: any[]; done: boolean; errors: any[] };
}

async waitForStreamCapture(timeout: number = 30, opts?: { minChars?: number; waitForDone?: boolean }): Promise<void> {
const maxMs = timeout * 1000;
await this.evaluate(waitForStreamCaptureJs(maxMs, opts));
}

/** Fallback basic snapshot */
protected async _basicSnapshot(opts: Pick<SnapshotOptions, 'interactive' | 'compact' | 'maxDepth' | 'raw'> = {}): Promise<unknown> {
const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));
Expand Down
29 changes: 28 additions & 1 deletion src/browser/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { BrowserCookie, IPage, ScreenshotOptions } from '../types.js';
import type { IBrowserFactory } from '../runtime.js';
import { wrapForEval } from './utils.js';
import { generateStealthJs } from './stealth.js';
import { waitForDomStableJs } from './dom-helpers.js';
import { waitForDomStableJs, waitForCaptureJs } from './dom-helpers.js';
import { isRecord, saveBase64ToFile } from '../utils.js';
import { getAllElectronApps } from '../electron-apps.js';
import { BasePage } from './base-page.js';
Expand Down Expand Up @@ -234,6 +234,33 @@ class CDPPage extends BasePage {
async selectTab(_index: number): Promise<void> {
// Not supported in direct CDP mode
}

async consoleMessages(_level?: string): Promise<unknown[]> {
return [];
}

async getCurrentUrl(): Promise<string | null> {
return this._lastUrl;
}

async installInterceptor(pattern: string): Promise<void> {
const { generateInterceptorJs } = await import('../interceptor.js');
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
arrayName: '__opencli_xhr',
patchGuard: '__opencli_interceptor_patched',
}));
}

async getInterceptedRequests(): Promise<unknown[]> {
const { generateReadInterceptedJs } = await import('../interceptor.js');
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
return Array.isArray(result) ? result : [];
}

async waitForCapture(timeout: number = 10): Promise<void> {
const maxMs = timeout * 1000;
await this.evaluate(waitForCaptureJs(maxMs));
}
}

function isCookie(value: unknown): value is BrowserCookie {
Expand Down
3 changes: 2 additions & 1 deletion src/browser/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export async function isExtensionConnected(): Promise<boolean> {
export async function sendCommand(
action: DaemonCommand['action'],
params: Omit<DaemonCommand, 'id' | 'action'> = {},
timeoutMs: number = 30000,
): Promise<unknown> {
const maxRetries = 4;

Expand All @@ -126,7 +127,7 @@ export async function sendCommand(
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(command),
timeout: 30000,
timeout: timeoutMs,
});

const result = (await res.json()) as DaemonResult;
Expand Down
63 changes: 62 additions & 1 deletion src/browser/dom-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { autoScrollJs, waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js';
import { autoScrollJs, waitForCaptureJs, waitForSelectorJs, waitForStreamCaptureJs } from './dom-helpers.js';

describe('autoScrollJs', () => {
it('returns early without error when document.body is null', async () => {
Expand Down Expand Up @@ -112,3 +112,64 @@ describe('waitForSelectorJs', () => {
delete g.MutationObserver;
});
});

describe('waitForStreamCaptureJs', () => {
it('returns a non-empty string with default prefix', () => {
const code = waitForStreamCaptureJs(1000);
expect(typeof code).toBe('string');
expect(code.length).toBeGreaterThan(0);
expect(code).toContain('__opencli_stream_text');
expect(code).toContain('__opencli_stream_done');
});

it('generates code that resolves when minChars is reached', async () => {
const g = globalThis as any;
g.__opencli_stream_text = '';
g.__opencli_stream_done = false;
g.window = g;
const code = waitForStreamCaptureJs(1000, { minChars: 5 });
const promise = eval(code) as Promise<void>;
// Simulate data arriving
g.__opencli_stream_text = 'hello world';
await expect(promise).resolves.not.toThrow();
delete g.__opencli_stream_text;
delete g.__opencli_stream_done;
delete g.window;
});

it('generates code that resolves when done flag is set', async () => {
const g = globalThis as any;
g.__opencli_stream_text = '';
g.__opencli_stream_done = false;
g.window = g;
const code = waitForStreamCaptureJs(1000, { waitForDone: true });
const promise = eval(code) as Promise<void>;
// Simulate stream completion — need both minChars AND done
g.__opencli_stream_text = 'data arrived';
g.__opencli_stream_done = true;
await expect(promise).resolves.not.toThrow();
delete g.__opencli_stream_text;
delete g.__opencli_stream_done;
delete g.window;
});

it('generates code that rejects on timeout', async () => {
const g = globalThis as any;
g.__opencli_stream_text = '';
g.__opencli_stream_done = false;
g.window = g;
const code = waitForStreamCaptureJs(50, { minChars: 100, waitForDone: true });
const promise = eval(code) as Promise<void>;
await expect(promise).rejects.toThrow();
delete g.__opencli_stream_text;
delete g.__opencli_stream_done;
delete g.window;
});

it('uses custom prefix when provided', () => {
const code = waitForStreamCaptureJs(1000, { prefix: '__my_prefix' });
expect(code).toContain('__my_prefix_text');
expect(code).toContain('__my_prefix_done');
expect(code).not.toContain('__opencli_stream');
});
});
29 changes: 29 additions & 0 deletions src/browser/dom-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,35 @@ export function waitForCaptureJs(maxMs: number): string {
`;
}

/**
* Generate JS to wait until the streaming interceptor has captured data.
* Polls window.__opencli_stream_text length. Optionally waits for stream completion.
* 50ms interval, rejects after maxMs.
*/
export function waitForStreamCaptureJs(
maxMs: number,
opts: { minChars?: number; waitForDone?: boolean; prefix?: string } = {},
): string {
const minChars = opts.minChars ?? 1;
const waitForDone = opts.waitForDone ?? false;
const prefix = opts.prefix ?? '__opencli_stream';
return `
new Promise((resolve, reject) => {
const deadline = Date.now() + ${maxMs};
const check = () => {
const text = window.${prefix}_text || '';
const done = window.${prefix}_done || false;
if (text.length >= ${minChars} && (${waitForDone} ? done : true)) {
return resolve('captured');
}
if (Date.now() > deadline) return reject(new Error('Stream capture timeout'));
setTimeout(check, 50);
};
check();
})
`;
}

/**
* Generate JS to wait until document.querySelector(selector) returns a match.
* Uses MutationObserver for near-instant resolution; falls back to reject after timeoutMs.
Expand Down
13 changes: 11 additions & 2 deletions src/browser/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import { sendCommand } from './daemon-client.js';
import { wrapForEval } from './utils.js';
import { saveBase64ToFile } from '../utils.js';
import { generateStealthJs } from './stealth.js';
import { waitForDomStableJs } from './dom-helpers.js';
import { BasePage } from './base-page.js';

import { waitForDomStableJs, waitForCaptureJs, waitForStreamCaptureJs } from './dom-helpers.js';
import { BasePage } from './base-page.js';
export function isRetryableSettleError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return message.includes('Inspected target navigated or closed')
Expand Down Expand Up @@ -179,6 +179,15 @@ export class Page extends BasePage {
throw new Error('setFileInput returned no count — command may not be supported by the extension');
}
}

// Override: waitForStreamCapture needs longer HTTP timeout for long-running browser promises
async waitForStreamCapture(timeout: number = 30, opts?: { minChars?: number; waitForDone?: boolean }): Promise<void> {
const maxMs = timeout * 1000;
await sendCommand('exec', {
code: waitForStreamCaptureJs(maxMs, opts),
...this._cmdOpts(),
}, maxMs + 10000); // HTTP timeout = browser timeout + 10s buffer
}
}

// (End of file)
4 changes: 3 additions & 1 deletion src/clis/douyin/draft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ function createPageMock(
installInterceptor: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
waitForCapture: vi.fn().mockResolvedValue(undefined),
screenshot: vi.fn().mockResolvedValue(''),
installStreamingInterceptor: vi.fn().mockResolvedValue(undefined),
getStreamedResponses: vi.fn().mockResolvedValue({ text: '', events: [], done: false, errors: [] }),
waitForStreamCapture: vi.fn().mockResolvedValue(undefined), screenshot: vi.fn().mockResolvedValue(''),
setFileInput: vi.fn().mockResolvedValue(undefined),
...overrides,
};
Expand Down
4 changes: 3 additions & 1 deletion src/clis/facebook/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ function createMockPage(): IPage {
installInterceptor: vi.fn(),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
waitForCapture: vi.fn().mockResolvedValue(undefined),
screenshot: vi.fn().mockResolvedValue(''),
installStreamingInterceptor: vi.fn().mockResolvedValue(undefined),
getStreamedResponses: vi.fn().mockResolvedValue({ text: '', events: [], done: false, errors: [] }),
waitForStreamCapture: vi.fn().mockResolvedValue(undefined), screenshot: vi.fn().mockResolvedValue(''),
};
}

Expand Down
4 changes: 3 additions & 1 deletion src/clis/substack/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ function createPageMock(evaluateResult: unknown): IPage {
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
installStreamingInterceptor: vi.fn().mockResolvedValue(undefined),
getStreamedResponses: vi.fn().mockResolvedValue({ text: '', events: [], done: false, errors: [] }),
waitForStreamCapture: vi.fn().mockResolvedValue(undefined), };
}

describe('substack utils wait selectors', () => {
Expand Down
4 changes: 3 additions & 1 deletion src/clis/xiaohongshu/comments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ function createPageMock(evaluateResult: any): IPage {
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
installStreamingInterceptor: vi.fn().mockResolvedValue(undefined),
getStreamedResponses: vi.fn().mockResolvedValue({ text: '', events: [], done: false, errors: [] }),
waitForStreamCapture: vi.fn().mockResolvedValue(undefined), };
}

describe('xiaohongshu comments', () => {
Expand Down
4 changes: 3 additions & 1 deletion src/clis/xiaohongshu/creator-note-detail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ function createPageMock(evaluateResult: any): IPage {
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
installStreamingInterceptor: vi.fn().mockResolvedValue(undefined),
getStreamedResponses: vi.fn().mockResolvedValue({ text: '', events: [], done: false, errors: [] }),
waitForStreamCapture: vi.fn().mockResolvedValue(undefined), };
}

describe('xiaohongshu creator-note-detail', () => {
Expand Down
4 changes: 3 additions & 1 deletion src/clis/xiaohongshu/creator-notes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ function createPageMock(evaluateResult: any, interceptedRequests: any[] = []): I
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
installStreamingInterceptor: vi.fn().mockResolvedValue(undefined),
getStreamedResponses: vi.fn().mockResolvedValue({ text: '', events: [], done: false, errors: [] }),
waitForStreamCapture: vi.fn().mockResolvedValue(undefined), };
}

describe('xiaohongshu creator-notes', () => {
Expand Down
4 changes: 3 additions & 1 deletion src/clis/xiaohongshu/download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ function createPageMock(evaluateResult: any): IPage {
getCookies: vi.fn().mockResolvedValue([{ name: 'sid', value: 'secret', domain: '.xiaohongshu.com' }]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
installStreamingInterceptor: vi.fn().mockResolvedValue(undefined),
getStreamedResponses: vi.fn().mockResolvedValue({ text: '', events: [], done: false, errors: [] }),
waitForStreamCapture: vi.fn().mockResolvedValue(undefined), };
}

describe('xiaohongshu download', () => {
Expand Down
4 changes: 3 additions & 1 deletion src/clis/xiaohongshu/note.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ function createPageMock(evaluateResult: any): IPage {
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
installStreamingInterceptor: vi.fn().mockResolvedValue(undefined),
getStreamedResponses: vi.fn().mockResolvedValue({ text: '', events: [], done: false, errors: [] }),
waitForStreamCapture: vi.fn().mockResolvedValue(undefined), };
}

describe('parseNoteId', () => {
Expand Down
4 changes: 3 additions & 1 deletion src/clis/xiaohongshu/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ function createPageMock(evaluateResults: any[], overrides: Partial<IPage> = {}):
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
...overrides,
installStreamingInterceptor: vi.fn().mockResolvedValue(undefined),
getStreamedResponses: vi.fn().mockResolvedValue({ text: '', events: [], done: false, errors: [] }),
waitForStreamCapture: vi.fn().mockResolvedValue(undefined), ...overrides,
};
}

Expand Down
4 changes: 3 additions & 1 deletion src/clis/xiaohongshu/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ function createPageMock(evaluateResults: any[]): IPage {
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
installStreamingInterceptor: vi.fn().mockResolvedValue(undefined),
getStreamedResponses: vi.fn().mockResolvedValue({ text: '', events: [], done: false, errors: [] }),
waitForStreamCapture: vi.fn().mockResolvedValue(undefined), };
}

describe('xiaohongshu search', () => {
Expand Down
Loading