Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features

* **browser** — add `page.evaluate(fn, ...args)` for type-safe browser-context evaluation with JSON-serialized arguments. String evaluation remains supported, but new adapter code should use function form to avoid implicit `wrapForEval` auto-IIFE magic.

### ⚠ BREAKING CHANGES

* **browser** — replace the `--session <name>` flag with a `<session>` positional argument that immediately follows `browser`. `opencli browser work click 12` instead of `opencli browser --session work click 12`; `opencli browser work bind` instead of `opencli browser bind --session work`. Required-flag semantics are now encoded structurally as a positional, matching the Docker/git convention for required operation-target identifiers. The internal `--session` flag is preserved for the daemon protocol and for direct `program.parseAsync` callers but is no longer part of the user-facing surface.
Expand Down
21 changes: 10 additions & 11 deletions clis/twitter/following.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,10 @@ cli({
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');

if (!targetUser) {
const href = await page.evaluate(`() => {
const href = await page.evaluate(() => {
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
return link ? link.getAttribute('href') : null;
}`);
});
if (!href)
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
targetUser = normalizeScreenName(href.replace('/', ''));
Expand All @@ -178,21 +178,20 @@ cli({

const followingQueryId = await resolveTwitterQueryId(page, 'Following', FOLLOWING_QUERY_ID);
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
const headers = JSON.stringify({
const headers = {
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
'X-Csrf-Token': ct0,
'X-Twitter-Auth-Type': 'OAuth2Session',
'X-Twitter-Active-User': 'yes',
});
};

// Get userId from screen_name
const userLookup = await page.evaluate(`async () => {
const url = ${JSON.stringify(buildUserByScreenNameUrl(userByScreenNameQueryId, targetUser))};
const resp = await fetch(url, { headers: ${headers}, credentials: 'include' });
const userLookup = await page.evaluate(async (url, headers) => {
const resp = await fetch(url, { headers, credentials: 'include' });
if (!resp.ok) return { error: resp.status };
const d = await resp.json();
return { userId: d.data?.user?.result?.rest_id || null };
}`);
}, buildUserByScreenNameUrl(userByScreenNameQueryId, targetUser), headers);
if (userLookup?.error === 401 || userLookup?.error === 403) {
throw new AuthRequiredError('x.com', `Twitter user lookup failed (HTTP ${userLookup.error})`);
}
Expand All @@ -211,10 +210,10 @@ cli({
for (let i = 0; i < maxPages && allUsers.length < limit; i++) {
const fetchCount = Math.min(50, limit - allUsers.length + 10);
const apiUrl = buildFollowingUrl(followingQueryId, userId, fetchCount, cursor);
const data = await page.evaluate(`async () => {
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
const data = await page.evaluate(async (url, headers) => {
const r = await fetch(url, { headers, credentials: 'include' });
return r.ok ? await r.json() : { error: r.status };
}`);
}, apiUrl, headers);
if (data?.error) {
if (data.error === 401 || data.error === 403)
throw new AuthRequiredError('x.com', `Twitter following request failed (HTTP ${data.error})`);
Expand Down
16 changes: 12 additions & 4 deletions clis/twitter/following.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,14 @@ function createFollowingPage(followingResponses, { ct0 = 'token', userLookup = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
getCookies: vi.fn(async () => (ct0 ? [{ name: 'ct0', value: ct0 }] : [])),
evaluate: vi.fn(async (script) => {
evaluate: vi.fn(async (script, ...args) => {
if (typeof script === 'function') {
const haystack = [script.toString(), ...args.map((arg) => String(arg))].join('\n');
if (haystack.includes('/UserByScreenName')) return userLookup;
if (haystack.includes('/Following')) return followingResponses.shift() || followingPayload([], null);
if (haystack.includes('AppTabBar_Profile_Link')) return '/viewer';
throw new Error(`Unexpected evaluate function: ${haystack.slice(0, 80)}`);
}
if (script.includes('operationName')) return null;
if (script.includes('/UserByScreenName')) return userLookup;
if (script.includes('/Following')) return followingResponses.shift() || followingPayload([], null);
Expand All @@ -229,12 +236,13 @@ describe('twitter following command', () => {

expect(rows.map((row) => row.screen_name)).toEqual(['alice', 'bob', 'carol']);
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
const userLookupScript = page.evaluate.mock.calls.find(([script]) => script.includes('/UserByScreenName'))?.[0] || '';
const callText = (call) => call.map((part) => typeof part === 'function' ? part.toString() : String(part)).join('\n');
const userLookupScript = callText(page.evaluate.mock.calls.find((call) => callText(call).includes('/UserByScreenName')) || []);
expect(decodeURIComponent(userLookupScript)).toContain('"screen_name":"elonmusk"');
expect(decodeURIComponent(userLookupScript)).not.toContain('"screen_name":"@elonmusk"');
const followingCalls = page.evaluate.mock.calls.filter(([script]) => script.includes('/Following'));
const followingCalls = page.evaluate.mock.calls.filter((call) => callText(call).includes('/Following'));
expect(followingCalls).toHaveLength(2);
expect(decodeURIComponent(followingCalls[1][0])).toContain('"cursor":"cursor-1"');
expect(decodeURIComponent(callText(followingCalls[1]))).toContain('"cursor":"cursor-1"');
});

it('rejects invalid limits before navigating', async () => {
Expand Down
17 changes: 8 additions & 9 deletions docs/developer/ts-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,12 @@ cli({
// Navigate and extract data
await page.goto('https://www.mysite.com');

const data = await page.evaluate(`
(async () => {
const res = await fetch('/api/search?q=${encodeURIComponent(String(query))}', {
credentials: 'include'
});
return (await res.json()).results;
})()
`);
const data = await page.evaluate(async (q: string) => {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
credentials: 'include',
});
return (await res.json()).results;
}, String(query));

if (!Array.isArray(data)) throw new CommandExecutionError('MySite returned an unexpected response');
if (!data.length) throw new EmptyResultError('mysite search', 'Try a different keyword');
Expand Down Expand Up @@ -112,7 +110,8 @@ persistence with `--site-session persistent`.
The `page` parameter provides browser interaction methods:

- `page.goto(url)` — Navigate to a URL
- `page.evaluate(script)` — Execute JavaScript in the page context
- `page.evaluate(fn, ...args)` — Execute a serializable function in the page context. Pass Node-side values through JSON-serializable args; the function cannot close over local variables.
- `page.evaluate(script)` — Execute a raw JavaScript string in the page context. Prefer function form for new adapter code.
- `page.waitForSelector(selector)` — Wait for an element
- `page.click(selector)` — Click an element
- `page.type(selector, text)` — Type text into an input
Expand Down
13 changes: 13 additions & 0 deletions skills/opencli-adapter-author/references/adapter-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,19 @@ const data = await page.fetchJson(`${BASE}/api/list`, {

它固定 `credentials: 'include'`,带 timeout,HTTP 非 2xx / 非 JSON 会抛统一 runtime error。adapter 里不用再手写 `page.evaluate(fetch(...))`;如果你需要额外包一层业务语义,按 [`typed-errors.md`](./typed-errors.md) 映射到 `CommandExecutionError` / `AuthRequiredError` / `EmptyResultError`。

### 页面内 DOM 逻辑用 `page.evaluate(fn, ...args)`

新 adapter 优先写函数形式,外部变量通过参数传入:

```javascript
const href = await page.evaluate((selector) => {
const link = document.querySelector(selector);
return link ? link.getAttribute('href') : null;
}, 'a[data-testid="profile"]');
```

`fn` 在浏览器页面上下文执行,不能读取 Node 侧闭包变量;参数必须能被 `JSON.stringify` 序列化。字符串形式 `page.evaluate('document.title')` 仍可用于简单表达式和既有代码,但不要再写依赖隐式 auto-IIFE 的模板字符串函数。

### HTML 不走 browser fetch

三个坑,踩一个就重写:
Expand Down
4 changes: 3 additions & 1 deletion skills/opencli-adapter-author/references/api-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,9 @@ opencli browser eval "window.__pinia.state.value.someStore.someMethod({...})"

```javascript
// func 里
await page.evaluate(installInterceptorCode, { domain: 'api.xxx.com', path: '/foo' });
await page.evaluateWithArgs(installInterceptorCode, {
config: { domain: 'api.xxx.com', path: '/foo' },
});
await page.goto('https://xxx.com/trigger-page');
// 等页面自己发那条请求
const intercepted = await page.evaluate('window.__opencli_intercepted');
Expand Down
10 changes: 7 additions & 3 deletions src/browser/base-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { describe, expect, it, vi } from 'vitest';
import { CliError } from '../errors.js';
import { BasePage } from './base-page.js';
import { TargetError } from './target-errors.js';
import type { ScreenshotOptions } from '../types.js';
import type { BrowserEvaluateFunction, ScreenshotOptions } from '../types.js';

class TestPage extends BasePage {
result: unknown;
args: Record<string, unknown> | undefined;

async goto(): Promise<void> {}
async evaluate<T = unknown>(_js: string): Promise<T>;
async evaluate<Args extends unknown[], T>(_fn: BrowserEvaluateFunction<Args, T>, ..._args: Args): Promise<Awaited<T>>;
async evaluate(): Promise<unknown> { return null; }
override async evaluateWithArgs(_js: string, args: Record<string, unknown>): Promise<unknown> {
this.args = args;
Expand All @@ -34,8 +36,10 @@ class ActionPage extends BasePage {
cdp?: (method: string, params?: Record<string, unknown>) => Promise<unknown>;

async goto(): Promise<void> {}
async evaluate(js: string): Promise<unknown> {
this.scripts.push(js);
async evaluate<T = unknown>(js: string): Promise<T>;
async evaluate<Args extends unknown[], T>(fn: BrowserEvaluateFunction<Args, T>, ...args: Args): Promise<Awaited<T>>;
async evaluate(input: string | BrowserEvaluateFunction<unknown[], unknown>): Promise<unknown> {
this.scripts.push(typeof input === 'string' ? input : input.toString());
return this.results.shift() ?? null;
}
override async evaluateWithArgs(js: string, args: Record<string, unknown>): Promise<unknown> {
Expand Down
5 changes: 3 additions & 2 deletions src/browser/base-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* getCookies, screenshot, tabs, etc.
*/

import type { BrowserCookie, FetchJsonOptions, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import type { BrowserCookie, BrowserEvaluateFunction, FetchJsonOptions, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import { generateSnapshotJs, getFormStateJs } from './dom-snapshot.js';
import { buildAxSnapshotFromTrees, findAxRefReplacement, type AxSnapshotTree, type BrowserRef } from './ax-snapshot.js';
import {
Expand Down Expand Up @@ -146,7 +146,8 @@ export abstract class BasePage implements IPage {
// ── Transport-specific methods (must be implemented by subclasses) ──

abstract goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number; allowBoundNavigation?: boolean }): Promise<void>;
abstract evaluate(js: string): Promise<unknown>;
abstract evaluate<T = unknown>(js: string): Promise<T>;
abstract evaluate<Args extends unknown[], T>(fn: BrowserEvaluateFunction<Args, T>, ...args: Args): Promise<Awaited<T>>;

/**
* Safely evaluate JS with pre-serialized arguments.
Expand Down
10 changes: 6 additions & 4 deletions src/browser/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
import { WebSocket, type RawData } from 'ws';
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import type { BrowserCookie, IPage, ScreenshotOptions } from '../types.js';
import type { BrowserCookie, BrowserEvaluateFunction, IPage, ScreenshotOptions } from '../types.js';
import type { IBrowserFactory } from '../runtime.js';
import { wrapForEval } from './utils.js';
import { buildEvaluateExpression } from './utils.js';
import { generateStealthJs } from './stealth.js';
import { waitForDomStableJs } from './dom-helpers.js';
import { isRecord, saveBase64ToFile } from '../utils.js';
Expand Down Expand Up @@ -221,8 +221,10 @@ class CDPPage extends BasePage {
}
}

async evaluate(js: string): Promise<unknown> {
const expression = wrapForEval(js);
async evaluate<T = unknown>(js: string): Promise<T>;
async evaluate<Args extends unknown[], T>(fn: BrowserEvaluateFunction<Args, T>, ...args: Args): Promise<Awaited<T>>;
async evaluate(input: string | BrowserEvaluateFunction<unknown[], unknown>, ...args: unknown[]): Promise<unknown> {
const expression = buildEvaluateExpression(input, args);
const result = await this.bridge.send('Runtime.evaluate', {
expression,
returnByValue: true,
Expand Down
39 changes: 39 additions & 0 deletions src/browser/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,45 @@ describe('Page.evaluate', () => {
expect(value).toBe(42);
expect(sendCommandMock).toHaveBeenCalledTimes(2);
});

it('serializes function-form evaluate calls with JSON args', async () => {
sendCommandMock.mockResolvedValueOnce('/opencli');

const page = new Page('twitter', undefined, undefined, undefined, 'adapter');
const href = await page.evaluate((selector: string) => {
const link = document.querySelector(selector);
return link ? link.getAttribute('href') : null;
}, 'a[data-testid="AppTabBar_Profile_Link"]');

expect(href).toBe('/opencli');
expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({
session: 'twitter',
surface: 'adapter',
code: expect.stringContaining('(...["a[data-testid=\\"AppTabBar_Profile_Link\\"]"])'),
}));
const code = sendCommandMock.mock.calls[0]?.[1]?.code as string;
expect(code).toContain('document.querySelector(selector)');
});

it('rejects non-JSON-serializable evaluate args before sending to the daemon', async () => {
const page = new Page('default');
const circular: Record<string, unknown> = {};
circular.self = circular;

await expect(page.evaluate((value: unknown) => value, circular)).rejects.toThrow('JSON-serializable');
expect(sendCommandMock).not.toHaveBeenCalled();
});

it('keeps string evaluate behavior unchanged', async () => {
sendCommandMock.mockResolvedValueOnce(42);

const page = new Page('default');
await expect(page.evaluate('21 + 21')).resolves.toBe(42);

expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({
code: '21 + 21',
}));
});
});

describe('Page network capture compatibility', () => {
Expand Down
12 changes: 7 additions & 5 deletions src/browser/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
* page-scoped operations target the correct page without guessing.
*/

import type { BrowserCookie, BrowserDownloadWaitResult, ScreenshotOptions } from '../types.js';
import type { BrowserCookie, BrowserDownloadWaitResult, BrowserEvaluateFunction, ScreenshotOptions } from '../types.js';
import { sendCommand, sendCommandFull } from './daemon-client.js';
import { wrapForEval } from './utils.js';
import { buildEvaluateExpression } from './utils.js';
import { saveBase64ToFile } from '../utils.js';
import { generateStealthJs } from './stealth.js';
import { waitForDomStableJs } from './dom-helpers.js';
Expand Down Expand Up @@ -141,8 +141,10 @@ export class Page extends BasePage {
);
}

async evaluate(js: string): Promise<unknown> {
const code = wrapForEval(js);
async evaluate<T = unknown>(js: string): Promise<T>;
async evaluate<Args extends unknown[], T>(fn: BrowserEvaluateFunction<Args, T>, ...args: Args): Promise<Awaited<T>>;
async evaluate(input: string | BrowserEvaluateFunction<unknown[], unknown>, ...args: unknown[]): Promise<unknown> {
const code = buildEvaluateExpression(input, args);
try {
return await sendCommand('exec', { code, ...this._cmdOpts() });
} catch (err) {
Expand Down Expand Up @@ -302,7 +304,7 @@ export class Page extends BasePage {
}

async evaluateInFrame(js: string, frameIndex: number): Promise<unknown> {
const code = wrapForEval(js);
const code = buildEvaluateExpression(js);
return sendCommand('exec', { code, frameIndex, ...this._cmdOpts() });
}

Expand Down
36 changes: 36 additions & 0 deletions src/browser/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { buildEvaluateExpression, wrapForEval } from './utils.js';

describe('browser eval utils', () => {
it('keeps existing string eval wrapping behavior', () => {
expect(wrapForEval('21 + 21')).toBe('21 + 21');
expect(wrapForEval('() => 42')).toBe('(() => 42)()');
});

it('serializes function eval arguments as JSON', () => {
const code = buildEvaluateExpression((selector: string) => {
return document.querySelector(selector)?.textContent ?? null;
}, ['.title']);

expect(code).toContain('document.querySelector(selector)');
expect(code).toContain('(...[".title"])');
});

it('accepts compact async arrow functions', () => {
const fn = new Function('return async()=>42')() as () => Promise<number>;
expect(buildEvaluateExpression(fn)).toBe('(async()=>42)(...[])');
});

it('rejects string eval with stray args', () => {
expect(() => buildEvaluateExpression('document.title', ['ignored']))
.toThrow('use page.evaluate(fn, ...args)');
});

it('rejects non-JSON-serializable function args', () => {
const circular: Record<string, unknown> = {};
circular.self = circular;

expect(() => buildEvaluateExpression((value: unknown) => value, [circular]))
.toThrow('JSON-serializable');
});
});
Loading
Loading