Skip to content

Commit 14b1dc8

Browse files
committed
feat(browser): add function form page evaluate
1 parent 0e168d5 commit 14b1dc8

14 files changed

Lines changed: 195 additions & 40 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Features
6+
7+
* **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.
8+
59
### ⚠ BREAKING CHANGES
610

711
* **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.

clis/twitter/following.js

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,10 @@ cli({
164164
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
165165

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

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

188188
// Get userId from screen_name
189-
const userLookup = await page.evaluate(`async () => {
190-
const url = ${JSON.stringify(buildUserByScreenNameUrl(userByScreenNameQueryId, targetUser))};
191-
const resp = await fetch(url, { headers: ${headers}, credentials: 'include' });
189+
const userLookup = await page.evaluate(async (url, headers) => {
190+
const resp = await fetch(url, { headers, credentials: 'include' });
192191
if (!resp.ok) return { error: resp.status };
193192
const d = await resp.json();
194193
return { userId: d.data?.user?.result?.rest_id || null };
195-
}`);
194+
}, buildUserByScreenNameUrl(userByScreenNameQueryId, targetUser), headers);
196195
if (userLookup?.error === 401 || userLookup?.error === 403) {
197196
throw new AuthRequiredError('x.com', `Twitter user lookup failed (HTTP ${userLookup.error})`);
198197
}
@@ -211,10 +210,10 @@ cli({
211210
for (let i = 0; i < maxPages && allUsers.length < limit; i++) {
212211
const fetchCount = Math.min(50, limit - allUsers.length + 10);
213212
const apiUrl = buildFollowingUrl(followingQueryId, userId, fetchCount, cursor);
214-
const data = await page.evaluate(`async () => {
215-
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
213+
const data = await page.evaluate(async (url, headers) => {
214+
const r = await fetch(url, { headers, credentials: 'include' });
216215
return r.ok ? await r.json() : { error: r.status };
217-
}`);
216+
}, apiUrl, headers);
218217
if (data?.error) {
219218
if (data.error === 401 || data.error === 403)
220219
throw new AuthRequiredError('x.com', `Twitter following request failed (HTTP ${data.error})`);

clis/twitter/following.test.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,14 @@ function createFollowingPage(followingResponses, { ct0 = 'token', userLookup = {
206206
goto: vi.fn().mockResolvedValue(undefined),
207207
wait: vi.fn().mockResolvedValue(undefined),
208208
getCookies: vi.fn(async () => (ct0 ? [{ name: 'ct0', value: ct0 }] : [])),
209-
evaluate: vi.fn(async (script) => {
209+
evaluate: vi.fn(async (script, ...args) => {
210+
if (typeof script === 'function') {
211+
const haystack = [script.toString(), ...args.map((arg) => String(arg))].join('\n');
212+
if (haystack.includes('/UserByScreenName')) return userLookup;
213+
if (haystack.includes('/Following')) return followingResponses.shift() || followingPayload([], null);
214+
if (haystack.includes('AppTabBar_Profile_Link')) return '/viewer';
215+
throw new Error(`Unexpected evaluate function: ${haystack.slice(0, 80)}`);
216+
}
210217
if (script.includes('operationName')) return null;
211218
if (script.includes('/UserByScreenName')) return userLookup;
212219
if (script.includes('/Following')) return followingResponses.shift() || followingPayload([], null);
@@ -229,12 +236,13 @@ describe('twitter following command', () => {
229236

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

240248
it('rejects invalid limits before navigating', async () => {

docs/developer/ts-adapter.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,12 @@ cli({
2828
// Navigate and extract data
2929
await page.goto('https://www.mysite.com');
3030

31-
const data = await page.evaluate(`
32-
(async () => {
33-
const res = await fetch('/api/search?q=${encodeURIComponent(String(query))}', {
34-
credentials: 'include'
35-
});
36-
return (await res.json()).results;
37-
})()
38-
`);
31+
const data = await page.evaluate(async (q: string) => {
32+
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
33+
credentials: 'include',
34+
});
35+
return (await res.json()).results;
36+
}, String(query));
3937

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

114112
- `page.goto(url)` — Navigate to a URL
115-
- `page.evaluate(script)` — Execute JavaScript in the page context
113+
- `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.
114+
- `page.evaluate(script)` — Execute a raw JavaScript string in the page context. Prefer function form for new adapter code.
116115
- `page.waitForSelector(selector)` — Wait for an element
117116
- `page.click(selector)` — Click an element
118117
- `page.type(selector, text)` — Type text into an input

skills/opencli-adapter-author/references/adapter-template.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,19 @@ const data = await page.fetchJson(`${BASE}/api/list`, {
264264
265265
它固定 `credentials: 'include'`,带 timeout,HTTP2xx /JSON 会抛统一 runtime error。adapter 里不用再手写 `page.evaluate(fetch(...))`;如果你需要额外包一层业务语义,按 [`typed-errors.md`](./typed-errors.md) 映射到 `CommandExecutionError` / `AuthRequiredError` / `EmptyResultError`
266266
267+
### 页面内 DOM 逻辑用 `page.evaluate(fn, ...args)`
268+
269+
新 adapter 优先写函数形式,外部变量通过参数传入:
270+
271+
```javascript
272+
const href = await page.evaluate((selector) => {
273+
const link = document.querySelector(selector);
274+
return link ? link.getAttribute('href') : null;
275+
}, 'a[data-testid="profile"]');
276+
```
277+
278+
`fn` 在浏览器页面上下文执行,不能读取 Node 侧闭包变量;参数必须能被 `JSON.stringify` 序列化。字符串形式 `page.evaluate('document.title')` 仍可用于简单表达式和既有代码,但不要再写依赖隐式 auto-IIFE 的模板字符串函数。
279+
267280
### HTML 不走 browser fetch
268281
269282
三个坑,踩一个就重写:

skills/opencli-adapter-author/references/api-discovery.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,9 @@ opencli browser eval "window.__pinia.state.value.someStore.someMethod({...})"
250250

251251
```javascript
252252
// func 里
253-
await page.evaluate(installInterceptorCode, { domain: 'api.xxx.com', path: '/foo' });
253+
await page.evaluateWithArgs(installInterceptorCode, {
254+
config: { domain: 'api.xxx.com', path: '/foo' },
255+
});
254256
await page.goto('https://xxx.com/trigger-page');
255257
// 等页面自己发那条请求
256258
const intercepted = await page.evaluate('window.__opencli_intercepted');

src/browser/base-page.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { describe, expect, it, vi } from 'vitest';
22
import { CliError } from '../errors.js';
33
import { BasePage } from './base-page.js';
44
import { TargetError } from './target-errors.js';
5-
import type { ScreenshotOptions } from '../types.js';
5+
import type { BrowserEvaluateFunction, ScreenshotOptions } from '../types.js';
66

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

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

3638
async goto(): Promise<void> {}
37-
async evaluate(js: string): Promise<unknown> {
38-
this.scripts.push(js);
39+
async evaluate<T = unknown>(js: string): Promise<T>;
40+
async evaluate<Args extends unknown[], T>(fn: BrowserEvaluateFunction<Args, T>, ...args: Args): Promise<Awaited<T>>;
41+
async evaluate(input: string | BrowserEvaluateFunction<unknown[], unknown>): Promise<unknown> {
42+
this.scripts.push(typeof input === 'string' ? input : input.toString());
3943
return this.results.shift() ?? null;
4044
}
4145
override async evaluateWithArgs(js: string, args: Record<string, unknown>): Promise<unknown> {

src/browser/base-page.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* getCookies, screenshot, tabs, etc.
1010
*/
1111

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

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

151152
/**
152153
* Safely evaluate JS with pre-serialized arguments.

src/browser/cdp.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
import { WebSocket, type RawData } from 'ws';
1212
import { request as httpRequest } from 'node:http';
1313
import { request as httpsRequest } from 'node:https';
14-
import type { BrowserCookie, IPage, ScreenshotOptions } from '../types.js';
14+
import type { BrowserCookie, BrowserEvaluateFunction, IPage, ScreenshotOptions } from '../types.js';
1515
import type { IBrowserFactory } from '../runtime.js';
16-
import { wrapForEval } from './utils.js';
16+
import { buildEvaluateExpression } from './utils.js';
1717
import { generateStealthJs } from './stealth.js';
1818
import { waitForDomStableJs } from './dom-helpers.js';
1919
import { isRecord, saveBase64ToFile } from '../utils.js';
@@ -221,8 +221,10 @@ class CDPPage extends BasePage {
221221
}
222222
}
223223

224-
async evaluate(js: string): Promise<unknown> {
225-
const expression = wrapForEval(js);
224+
async evaluate<T = unknown>(js: string): Promise<T>;
225+
async evaluate<Args extends unknown[], T>(fn: BrowserEvaluateFunction<Args, T>, ...args: Args): Promise<Awaited<T>>;
226+
async evaluate(input: string | BrowserEvaluateFunction<unknown[], unknown>, ...args: unknown[]): Promise<unknown> {
227+
const expression = buildEvaluateExpression(input, args);
226228
const result = await this.bridge.send('Runtime.evaluate', {
227229
expression,
228230
returnByValue: true,

src/browser/page.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,45 @@ describe('Page.evaluate', () => {
9292
expect(value).toBe(42);
9393
expect(sendCommandMock).toHaveBeenCalledTimes(2);
9494
});
95+
96+
it('serializes function-form evaluate calls with JSON args', async () => {
97+
sendCommandMock.mockResolvedValueOnce('/opencli');
98+
99+
const page = new Page('twitter', undefined, undefined, undefined, 'adapter');
100+
const href = await page.evaluate((selector: string) => {
101+
const link = document.querySelector(selector);
102+
return link ? link.getAttribute('href') : null;
103+
}, 'a[data-testid="AppTabBar_Profile_Link"]');
104+
105+
expect(href).toBe('/opencli');
106+
expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({
107+
session: 'twitter',
108+
surface: 'adapter',
109+
code: expect.stringContaining('(...["a[data-testid=\\"AppTabBar_Profile_Link\\"]"])'),
110+
}));
111+
const code = sendCommandMock.mock.calls[0]?.[1]?.code as string;
112+
expect(code).toContain('document.querySelector(selector)');
113+
});
114+
115+
it('rejects non-JSON-serializable evaluate args before sending to the daemon', async () => {
116+
const page = new Page('default');
117+
const circular: Record<string, unknown> = {};
118+
circular.self = circular;
119+
120+
await expect(page.evaluate((value: unknown) => value, circular)).rejects.toThrow('JSON-serializable');
121+
expect(sendCommandMock).not.toHaveBeenCalled();
122+
});
123+
124+
it('keeps string evaluate behavior unchanged', async () => {
125+
sendCommandMock.mockResolvedValueOnce(42);
126+
127+
const page = new Page('default');
128+
await expect(page.evaluate('21 + 21')).resolves.toBe(42);
129+
130+
expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({
131+
code: '21 + 21',
132+
}));
133+
});
95134
});
96135

97136
describe('Page network capture compatibility', () => {

0 commit comments

Comments
 (0)