@jackwener/opencli 1.7.3 → 1.7.4
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.
- package/README.md +16 -16
- package/README.zh-CN.md +28 -15
- package/cli-manifest.json +547 -10
- package/clis/bilibili/favorite.js +18 -13
- package/clis/binance/depth.js +3 -4
- package/clis/boss/utils.js +2 -3
- package/clis/chatgpt-app/ax.js +6 -3
- package/clis/douban/search.js +1 -0
- package/clis/douban/search.test.js +11 -0
- package/clis/douban/subject.js +20 -93
- package/clis/douban/subject.test.js +11 -0
- package/clis/douban/utils.js +250 -8
- package/clis/douban/utils.test.js +179 -4
- package/clis/doubao/utils.js +319 -130
- package/clis/doubao/utils.test.js +241 -2
- package/clis/eastmoney/hot-rank.js +50 -0
- package/clis/eastmoney/hot-rank.test.js +59 -0
- package/clis/grok/image.test.ts +107 -0
- package/clis/grok/image.ts +356 -0
- package/clis/tdx/hot-rank.js +47 -0
- package/clis/tdx/hot-rank.test.js +59 -0
- package/clis/ths/hot-rank.js +49 -0
- package/clis/ths/hot-rank.test.js +64 -0
- package/clis/twitter/bookmarks.js +2 -1
- package/clis/uiverse/_shared.js +368 -0
- package/clis/uiverse/_shared.test.js +55 -0
- package/clis/uiverse/code.js +47 -0
- package/clis/uiverse/preview.js +71 -0
- package/clis/xiaohongshu/comments.js +2 -2
- package/clis/xiaohongshu/comments.test.js +46 -25
- package/clis/xiaohongshu/download.js +6 -7
- package/clis/xiaohongshu/download.test.js +17 -5
- package/clis/xiaohongshu/note-helpers.js +46 -12
- package/clis/xiaohongshu/note.js +3 -5
- package/clis/xiaohongshu/note.test.js +52 -25
- package/clis/xiaoyuzhou/auth.js +303 -0
- package/clis/xiaoyuzhou/auth.test.js +124 -0
- package/clis/xiaoyuzhou/download.js +49 -0
- package/clis/xiaoyuzhou/download.test.js +125 -0
- package/clis/xiaoyuzhou/transcript.js +76 -0
- package/clis/xiaoyuzhou/transcript.test.js +195 -0
- package/clis/youtube/feed.js +120 -0
- package/clis/youtube/history.js +118 -0
- package/clis/youtube/like.js +62 -0
- package/clis/youtube/playlist.js +97 -0
- package/clis/youtube/subscribe.js +71 -0
- package/clis/youtube/subscriptions.js +57 -0
- package/clis/youtube/unlike.js +62 -0
- package/clis/youtube/unsubscribe.js +71 -0
- package/clis/youtube/utils.js +122 -0
- package/clis/youtube/utils.test.js +32 -1
- package/clis/youtube/watch-later.js +76 -0
- package/dist/src/browser/base-page.js +25 -5
- package/dist/src/browser/bridge.d.ts +2 -0
- package/dist/src/browser/bridge.js +51 -14
- package/dist/src/browser/cdp.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/dom-snapshot.js +13 -1
- package/dist/src/browser/page.d.ts +4 -1
- package/dist/src/browser/page.js +48 -8
- package/dist/src/browser/page.test.js +61 -1
- package/dist/src/browser/target-errors.d.ts +23 -0
- package/dist/src/browser/target-errors.js +29 -0
- package/dist/src/browser/target-errors.test.d.ts +1 -0
- package/dist/src/browser/target-errors.test.js +61 -0
- package/dist/src/browser/target-resolver.d.ts +57 -0
- package/dist/src/browser/target-resolver.js +298 -0
- package/dist/src/browser/target-resolver.test.d.ts +1 -0
- package/dist/src/browser/target-resolver.test.js +43 -0
- package/dist/src/browser.test.js +38 -1
- package/dist/src/cli.js +45 -37
- package/dist/src/commands/daemon.d.ts +4 -2
- package/dist/src/commands/daemon.js +22 -2
- package/dist/src/commands/daemon.test.js +65 -2
- package/dist/src/daemon.js +2 -0
- package/dist/src/doctor.d.ts +1 -0
- package/dist/src/doctor.js +32 -9
- package/dist/src/doctor.test.js +28 -12
- package/dist/src/external-clis.yaml +2 -2
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +3 -3
- package/dist/src/output.js +1 -5
- package/dist/src/output.test.js +0 -21
- package/dist/src/pipeline/steps/transform.js +1 -1
- package/dist/src/pipeline/template.d.ts +1 -0
- package/dist/src/pipeline/template.js +11 -3
- package/dist/src/pipeline/template.test.js +3 -0
- package/dist/src/pipeline/transform.test.js +14 -0
- package/dist/src/plugin.d.ts +7 -1
- package/dist/src/plugin.js +23 -1
- package/dist/src/plugin.test.js +15 -1
- package/dist/src/types.d.ts +1 -1
- package/package.json +1 -1
|
@@ -18,6 +18,8 @@ export declare class Page extends BasePage {
|
|
|
18
18
|
constructor(workspace?: string);
|
|
19
19
|
/** Active page identity (targetId), set after navigate and used in all subsequent commands */
|
|
20
20
|
private _page;
|
|
21
|
+
private _networkCaptureUnsupported;
|
|
22
|
+
private _networkCaptureWarned;
|
|
21
23
|
/** Helper: spread workspace into command params */
|
|
22
24
|
private _wsOpt;
|
|
23
25
|
/** Helper: spread workspace + page identity into command params */
|
|
@@ -30,6 +32,7 @@ export declare class Page extends BasePage {
|
|
|
30
32
|
getActivePage(): string | undefined;
|
|
31
33
|
/** @deprecated Use getActivePage() instead */
|
|
32
34
|
getActiveTabId(): number | undefined;
|
|
35
|
+
private _markUnsupportedNetworkCapture;
|
|
33
36
|
evaluate(js: string): Promise<unknown>;
|
|
34
37
|
getCookies(opts?: {
|
|
35
38
|
domain?: string;
|
|
@@ -43,7 +46,7 @@ export declare class Page extends BasePage {
|
|
|
43
46
|
* Capture a screenshot via CDP Page.captureScreenshot.
|
|
44
47
|
*/
|
|
45
48
|
screenshot(options?: ScreenshotOptions): Promise<string>;
|
|
46
|
-
startNetworkCapture(pattern?: string): Promise<
|
|
49
|
+
startNetworkCapture(pattern?: string): Promise<boolean>;
|
|
47
50
|
readNetworkCapture(): Promise<unknown[]>;
|
|
48
51
|
/**
|
|
49
52
|
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
|
package/dist/src/browser/page.js
CHANGED
|
@@ -15,6 +15,13 @@ import { generateStealthJs } from './stealth.js';
|
|
|
15
15
|
import { waitForDomStableJs } from './dom-helpers.js';
|
|
16
16
|
import { BasePage } from './base-page.js';
|
|
17
17
|
import { classifyBrowserError } from './errors.js';
|
|
18
|
+
import { log } from '../logger.js';
|
|
19
|
+
function isUnsupportedNetworkCaptureError(err) {
|
|
20
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
21
|
+
const normalized = message.toLowerCase();
|
|
22
|
+
return (normalized.includes('unknown action') && normalized.includes('network-capture'))
|
|
23
|
+
|| (normalized.includes('network capture') && normalized.includes('not supported'));
|
|
24
|
+
}
|
|
18
25
|
/**
|
|
19
26
|
* Page — implements IPage by talking to the daemon via HTTP.
|
|
20
27
|
*/
|
|
@@ -26,6 +33,8 @@ export class Page extends BasePage {
|
|
|
26
33
|
}
|
|
27
34
|
/** Active page identity (targetId), set after navigate and used in all subsequent commands */
|
|
28
35
|
_page;
|
|
36
|
+
_networkCaptureUnsupported = false;
|
|
37
|
+
_networkCaptureWarned = false;
|
|
29
38
|
/** Helper: spread workspace into command params */
|
|
30
39
|
_wsOpt() {
|
|
31
40
|
return { workspace: this.workspace };
|
|
@@ -97,6 +106,14 @@ export class Page extends BasePage {
|
|
|
97
106
|
getActiveTabId() {
|
|
98
107
|
return undefined;
|
|
99
108
|
}
|
|
109
|
+
_markUnsupportedNetworkCapture() {
|
|
110
|
+
this._networkCaptureUnsupported = true;
|
|
111
|
+
if (this._networkCaptureWarned)
|
|
112
|
+
return;
|
|
113
|
+
this._networkCaptureWarned = true;
|
|
114
|
+
log.warn('Browser Bridge extension does not support network capture; continuing without it. ' +
|
|
115
|
+
'Explore output may miss API endpoints until you reload or reinstall the extension.');
|
|
116
|
+
}
|
|
100
117
|
async evaluate(js) {
|
|
101
118
|
const code = wrapForEval(js);
|
|
102
119
|
try {
|
|
@@ -125,6 +142,8 @@ export class Page extends BasePage {
|
|
|
125
142
|
finally {
|
|
126
143
|
this._page = undefined;
|
|
127
144
|
this._lastUrl = null;
|
|
145
|
+
this._networkCaptureUnsupported = false;
|
|
146
|
+
this._networkCaptureWarned = false;
|
|
128
147
|
}
|
|
129
148
|
}
|
|
130
149
|
async tabs() {
|
|
@@ -152,16 +171,37 @@ export class Page extends BasePage {
|
|
|
152
171
|
return base64;
|
|
153
172
|
}
|
|
154
173
|
async startNetworkCapture(pattern = '') {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
174
|
+
if (this._networkCaptureUnsupported)
|
|
175
|
+
return false;
|
|
176
|
+
try {
|
|
177
|
+
await sendCommand('network-capture-start', {
|
|
178
|
+
pattern,
|
|
179
|
+
...this._cmdOpts(),
|
|
180
|
+
});
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
if (!isUnsupportedNetworkCaptureError(err))
|
|
185
|
+
throw err;
|
|
186
|
+
this._markUnsupportedNetworkCapture();
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
159
189
|
}
|
|
160
190
|
async readNetworkCapture() {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
191
|
+
if (this._networkCaptureUnsupported)
|
|
192
|
+
return [];
|
|
193
|
+
try {
|
|
194
|
+
const result = await sendCommand('network-capture-read', {
|
|
195
|
+
...this._cmdOpts(),
|
|
196
|
+
});
|
|
197
|
+
return Array.isArray(result) ? result : [];
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
if (!isUnsupportedNetworkCaptureError(err))
|
|
201
|
+
throw err;
|
|
202
|
+
this._markUnsupportedNetworkCapture();
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
165
205
|
}
|
|
166
206
|
/**
|
|
167
207
|
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
const { sendCommandMock } = vi.hoisted(() => ({
|
|
2
|
+
const { sendCommandMock, sendCommandFullMock } = vi.hoisted(() => ({
|
|
3
3
|
sendCommandMock: vi.fn(),
|
|
4
|
+
sendCommandFullMock: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
const { warnMock } = vi.hoisted(() => ({
|
|
7
|
+
warnMock: vi.fn(),
|
|
4
8
|
}));
|
|
5
9
|
vi.mock('./daemon-client.js', () => ({
|
|
6
10
|
sendCommand: sendCommandMock,
|
|
11
|
+
sendCommandFull: sendCommandFullMock,
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('../logger.js', () => ({
|
|
14
|
+
log: {
|
|
15
|
+
warn: warnMock,
|
|
16
|
+
},
|
|
7
17
|
}));
|
|
8
18
|
import { Page } from './page.js';
|
|
9
19
|
describe('Page.getCurrentUrl', () => {
|
|
10
20
|
beforeEach(() => {
|
|
11
21
|
sendCommandMock.mockReset();
|
|
22
|
+
sendCommandFullMock.mockReset();
|
|
23
|
+
warnMock.mockReset();
|
|
12
24
|
});
|
|
13
25
|
it('reads the real browser URL when no local navigation cache exists', async () => {
|
|
14
26
|
sendCommandMock.mockResolvedValueOnce('https://notebooklm.google.com/notebook/nb-live');
|
|
@@ -31,6 +43,8 @@ describe('Page.getCurrentUrl', () => {
|
|
|
31
43
|
describe('Page.evaluate', () => {
|
|
32
44
|
beforeEach(() => {
|
|
33
45
|
sendCommandMock.mockReset();
|
|
46
|
+
sendCommandFullMock.mockReset();
|
|
47
|
+
warnMock.mockReset();
|
|
34
48
|
});
|
|
35
49
|
it('retries once when the inspected target navigated during exec', async () => {
|
|
36
50
|
sendCommandMock
|
|
@@ -42,3 +56,49 @@ describe('Page.evaluate', () => {
|
|
|
42
56
|
expect(sendCommandMock).toHaveBeenCalledTimes(2);
|
|
43
57
|
});
|
|
44
58
|
});
|
|
59
|
+
describe('Page network capture compatibility', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
sendCommandMock.mockReset();
|
|
62
|
+
sendCommandFullMock.mockReset();
|
|
63
|
+
warnMock.mockReset();
|
|
64
|
+
});
|
|
65
|
+
it('treats unknown network-capture-start as unsupported and memoizes it', async () => {
|
|
66
|
+
sendCommandMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-start'));
|
|
67
|
+
const page = new Page('site:notebooklm');
|
|
68
|
+
await expect(page.startNetworkCapture()).resolves.toBe(false);
|
|
69
|
+
await expect(page.startNetworkCapture()).resolves.toBe(false);
|
|
70
|
+
expect(sendCommandMock).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('does not support network capture'));
|
|
73
|
+
expect(sendCommandMock).toHaveBeenCalledWith('network-capture-start', expect.objectContaining({
|
|
74
|
+
workspace: 'site:notebooklm',
|
|
75
|
+
}));
|
|
76
|
+
});
|
|
77
|
+
it('returns an empty capture when network-capture-read is unsupported', async () => {
|
|
78
|
+
sendCommandMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-read'));
|
|
79
|
+
const page = new Page('site:notebooklm');
|
|
80
|
+
await expect(page.readNetworkCapture()).resolves.toEqual([]);
|
|
81
|
+
await expect(page.readNetworkCapture()).resolves.toEqual([]);
|
|
82
|
+
expect(sendCommandMock).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(sendCommandMock).toHaveBeenCalledWith('network-capture-read', expect.objectContaining({
|
|
85
|
+
workspace: 'site:notebooklm',
|
|
86
|
+
}));
|
|
87
|
+
});
|
|
88
|
+
it('rethrows unrelated network capture failures', async () => {
|
|
89
|
+
sendCommandMock.mockRejectedValueOnce(new Error('Extension disconnected'));
|
|
90
|
+
const page = new Page('site:notebooklm');
|
|
91
|
+
await expect(page.startNetworkCapture()).rejects.toThrow('Extension disconnected');
|
|
92
|
+
expect(sendCommandMock).toHaveBeenCalledTimes(1);
|
|
93
|
+
expect(warnMock).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
it('warns only once even if both start and read hit the compatibility fallback', async () => {
|
|
96
|
+
sendCommandMock
|
|
97
|
+
.mockRejectedValueOnce(new Error('Unknown action: network-capture-start'))
|
|
98
|
+
.mockRejectedValueOnce(new Error('Unknown action: network-capture-read'));
|
|
99
|
+
const page = new Page('site:notebooklm');
|
|
100
|
+
await expect(page.startNetworkCapture()).resolves.toBe(false);
|
|
101
|
+
await expect(page.readNetworkCapture()).resolves.toEqual([]);
|
|
102
|
+
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured error types for the target resolution system.
|
|
3
|
+
*
|
|
4
|
+
* Every browser action (click, type, select, get) that targets a DOM element
|
|
5
|
+
* goes through the unified resolver. When resolution fails, one of these
|
|
6
|
+
* structured errors is thrown so that AI agents and adapter authors get
|
|
7
|
+
* actionable diagnostics instead of a generic "Element not found".
|
|
8
|
+
*/
|
|
9
|
+
export type TargetErrorCode = 'not_found' | 'ambiguous' | 'stale_ref';
|
|
10
|
+
export interface TargetErrorInfo {
|
|
11
|
+
code: TargetErrorCode;
|
|
12
|
+
message: string;
|
|
13
|
+
hint: string;
|
|
14
|
+
candidates?: string[];
|
|
15
|
+
}
|
|
16
|
+
export declare class TargetError extends Error {
|
|
17
|
+
readonly code: TargetErrorCode;
|
|
18
|
+
readonly hint: string;
|
|
19
|
+
readonly candidates?: string[];
|
|
20
|
+
constructor(info: TargetErrorInfo);
|
|
21
|
+
/** Serialize for structured output to AI agents */
|
|
22
|
+
toJSON(): TargetErrorInfo;
|
|
23
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured error types for the target resolution system.
|
|
3
|
+
*
|
|
4
|
+
* Every browser action (click, type, select, get) that targets a DOM element
|
|
5
|
+
* goes through the unified resolver. When resolution fails, one of these
|
|
6
|
+
* structured errors is thrown so that AI agents and adapter authors get
|
|
7
|
+
* actionable diagnostics instead of a generic "Element not found".
|
|
8
|
+
*/
|
|
9
|
+
export class TargetError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
hint;
|
|
12
|
+
candidates;
|
|
13
|
+
constructor(info) {
|
|
14
|
+
super(info.message);
|
|
15
|
+
this.name = 'TargetError';
|
|
16
|
+
this.code = info.code;
|
|
17
|
+
this.hint = info.hint;
|
|
18
|
+
this.candidates = info.candidates;
|
|
19
|
+
}
|
|
20
|
+
/** Serialize for structured output to AI agents */
|
|
21
|
+
toJSON() {
|
|
22
|
+
return {
|
|
23
|
+
code: this.code,
|
|
24
|
+
message: this.message,
|
|
25
|
+
hint: this.hint,
|
|
26
|
+
...(this.candidates && { candidates: this.candidates }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { TargetError } from './target-errors.js';
|
|
3
|
+
describe('TargetError', () => {
|
|
4
|
+
it('creates not_found error with code and hint', () => {
|
|
5
|
+
const err = new TargetError({
|
|
6
|
+
code: 'not_found',
|
|
7
|
+
message: 'ref=99 not found in DOM',
|
|
8
|
+
hint: 'Re-run `opencli browser state` to get a fresh snapshot.',
|
|
9
|
+
});
|
|
10
|
+
expect(err).toBeInstanceOf(Error);
|
|
11
|
+
expect(err.name).toBe('TargetError');
|
|
12
|
+
expect(err.code).toBe('not_found');
|
|
13
|
+
expect(err.message).toBe('ref=99 not found in DOM');
|
|
14
|
+
expect(err.hint).toContain('fresh snapshot');
|
|
15
|
+
expect(err.candidates).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
it('creates ambiguous error with candidates', () => {
|
|
18
|
+
const err = new TargetError({
|
|
19
|
+
code: 'ambiguous',
|
|
20
|
+
message: 'CSS selector ".btn" matched 3 elements',
|
|
21
|
+
hint: 'Use a more specific selector.',
|
|
22
|
+
candidates: ['<button> "Login"', '<button> "Sign Up"', '<button> "Cancel"'],
|
|
23
|
+
});
|
|
24
|
+
expect(err.code).toBe('ambiguous');
|
|
25
|
+
expect(err.candidates).toHaveLength(3);
|
|
26
|
+
expect(err.candidates[0]).toContain('Login');
|
|
27
|
+
});
|
|
28
|
+
it('creates stale_ref error', () => {
|
|
29
|
+
const err = new TargetError({
|
|
30
|
+
code: 'stale_ref',
|
|
31
|
+
message: 'ref=12 was <button>"Login" but now points to <div>"Header"',
|
|
32
|
+
hint: 'Re-run `opencli browser state` to refresh.',
|
|
33
|
+
});
|
|
34
|
+
expect(err.code).toBe('stale_ref');
|
|
35
|
+
expect(err.message).toContain('was <button>');
|
|
36
|
+
});
|
|
37
|
+
it('serializes to JSON for structured output', () => {
|
|
38
|
+
const err = new TargetError({
|
|
39
|
+
code: 'ambiguous',
|
|
40
|
+
message: 'matched 3',
|
|
41
|
+
hint: 'be specific',
|
|
42
|
+
candidates: ['a', 'b'],
|
|
43
|
+
});
|
|
44
|
+
const json = err.toJSON();
|
|
45
|
+
expect(json).toEqual({
|
|
46
|
+
code: 'ambiguous',
|
|
47
|
+
message: 'matched 3',
|
|
48
|
+
hint: 'be specific',
|
|
49
|
+
candidates: ['a', 'b'],
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
it('omits candidates from JSON when not present', () => {
|
|
53
|
+
const err = new TargetError({
|
|
54
|
+
code: 'not_found',
|
|
55
|
+
message: 'gone',
|
|
56
|
+
hint: 'refresh',
|
|
57
|
+
});
|
|
58
|
+
const json = err.toJSON();
|
|
59
|
+
expect(json).not.toHaveProperty('candidates');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified target resolver for browser actions.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the ad-hoc 4-strategy fallback in dom-helpers.ts with a
|
|
5
|
+
* principled resolution pipeline:
|
|
6
|
+
*
|
|
7
|
+
* 1. Input classification: numeric → ref path, CSS-like → CSS path
|
|
8
|
+
* 2. Ref path: lookup by data-opencli-ref, then verify fingerprint
|
|
9
|
+
* 3. CSS path: querySelectorAll + uniqueness check
|
|
10
|
+
* 4. Structured errors: stale_ref / ambiguous / not_found
|
|
11
|
+
*
|
|
12
|
+
* All JS is generated as strings for page.evaluate() — runs in the browser.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Generate JS that resolves a target to a single DOM element.
|
|
16
|
+
*
|
|
17
|
+
* Returns a JS expression that evaluates to:
|
|
18
|
+
* { ok: true, el: Element } — success (el is assigned to `__resolved`)
|
|
19
|
+
* { ok: false, code, message, hint, candidates } — structured error
|
|
20
|
+
*
|
|
21
|
+
* The resolved element is stored in `__resolved` for the caller to use.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveTargetJs(ref: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Generate JS for click that uses the unified resolver.
|
|
26
|
+
* Assumes resolveTargetJs has been called and __resolved is set.
|
|
27
|
+
*/
|
|
28
|
+
export declare function clickResolvedJs(): string;
|
|
29
|
+
/**
|
|
30
|
+
* Generate JS for type that uses the unified resolver.
|
|
31
|
+
*/
|
|
32
|
+
export declare function typeResolvedJs(text: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Generate JS for scrollTo that uses the unified resolver.
|
|
35
|
+
* Assumes resolveTargetJs has been called and __resolved is set.
|
|
36
|
+
*/
|
|
37
|
+
export declare function scrollResolvedJs(): string;
|
|
38
|
+
/**
|
|
39
|
+
* Generate JS to get text content of resolved element.
|
|
40
|
+
*/
|
|
41
|
+
export declare function getTextResolvedJs(): string;
|
|
42
|
+
/**
|
|
43
|
+
* Generate JS to get value of resolved input/textarea element.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getValueResolvedJs(): string;
|
|
46
|
+
/**
|
|
47
|
+
* Generate JS to get all attributes of resolved element.
|
|
48
|
+
*/
|
|
49
|
+
export declare function getAttributesResolvedJs(): string;
|
|
50
|
+
/**
|
|
51
|
+
* Generate JS to select an option on a resolved <select> element.
|
|
52
|
+
*/
|
|
53
|
+
export declare function selectResolvedJs(option: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* Generate JS to check if resolved element is an autocomplete/combobox field.
|
|
56
|
+
*/
|
|
57
|
+
export declare function isAutocompleteResolvedJs(): string;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified target resolver for browser actions.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the ad-hoc 4-strategy fallback in dom-helpers.ts with a
|
|
5
|
+
* principled resolution pipeline:
|
|
6
|
+
*
|
|
7
|
+
* 1. Input classification: numeric → ref path, CSS-like → CSS path
|
|
8
|
+
* 2. Ref path: lookup by data-opencli-ref, then verify fingerprint
|
|
9
|
+
* 3. CSS path: querySelectorAll + uniqueness check
|
|
10
|
+
* 4. Structured errors: stale_ref / ambiguous / not_found
|
|
11
|
+
*
|
|
12
|
+
* All JS is generated as strings for page.evaluate() — runs in the browser.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Generate JS that resolves a target to a single DOM element.
|
|
16
|
+
*
|
|
17
|
+
* Returns a JS expression that evaluates to:
|
|
18
|
+
* { ok: true, el: Element } — success (el is assigned to `__resolved`)
|
|
19
|
+
* { ok: false, code, message, hint, candidates } — structured error
|
|
20
|
+
*
|
|
21
|
+
* The resolved element is stored in `__resolved` for the caller to use.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveTargetJs(ref) {
|
|
24
|
+
const safeRef = JSON.stringify(ref);
|
|
25
|
+
return `
|
|
26
|
+
(() => {
|
|
27
|
+
const ref = ${safeRef};
|
|
28
|
+
const identity = window.__opencli_ref_identity || {};
|
|
29
|
+
|
|
30
|
+
// ── Classify input ──
|
|
31
|
+
const isNumeric = /^\\d+$/.test(ref);
|
|
32
|
+
const isCssLike = !isNumeric && /^[a-zA-Z#.\\[]/.test(ref);
|
|
33
|
+
|
|
34
|
+
if (isNumeric) {
|
|
35
|
+
// ── Ref path ──
|
|
36
|
+
let el = document.querySelector('[data-opencli-ref="' + ref + '"]');
|
|
37
|
+
if (!el) el = document.querySelector('[data-ref="' + ref + '"]');
|
|
38
|
+
|
|
39
|
+
if (!el) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
code: 'not_found',
|
|
43
|
+
message: 'ref=' + ref + ' not found in DOM',
|
|
44
|
+
hint: 'The element may have been removed. Re-run \`opencli browser state\` to get a fresh snapshot.',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Fingerprint verification (identity vector) ──
|
|
49
|
+
const fp = identity[ref];
|
|
50
|
+
if (fp) {
|
|
51
|
+
const tag = el.tagName.toLowerCase();
|
|
52
|
+
const text = (el.textContent || '').trim().slice(0, 30);
|
|
53
|
+
const role = el.getAttribute('role') || '';
|
|
54
|
+
const ariaLabel = el.getAttribute('aria-label') || '';
|
|
55
|
+
const id = el.id || '';
|
|
56
|
+
const testId = el.getAttribute('data-testid') || el.getAttribute('data-test') || '';
|
|
57
|
+
|
|
58
|
+
// Hard fail: tag must always match
|
|
59
|
+
const tagMatch = fp.tag === tag;
|
|
60
|
+
|
|
61
|
+
// Soft signals: each non-empty stored field that mismatches counts against
|
|
62
|
+
var mismatches = 0;
|
|
63
|
+
var checks = 0;
|
|
64
|
+
if (fp.id) { checks++; if (fp.id !== id) mismatches++; }
|
|
65
|
+
if (fp.testId) { checks++; if (fp.testId !== testId) mismatches++; }
|
|
66
|
+
if (fp.ariaLabel) { checks++; if (fp.ariaLabel !== ariaLabel) mismatches++; }
|
|
67
|
+
if (fp.role) { checks++; if (fp.role !== role) mismatches++; }
|
|
68
|
+
if (fp.text) {
|
|
69
|
+
checks++;
|
|
70
|
+
// Text: allow prefix match (page text can grow), but empty current text never matches
|
|
71
|
+
if (!text || (!text.startsWith(fp.text) && !fp.text.startsWith(text))) mismatches++;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Stale if tag changed, or if any uniquely identifying field (id/testId) changed,
|
|
75
|
+
// or if majority of soft signals mismatch
|
|
76
|
+
var isStale = !tagMatch;
|
|
77
|
+
if (!isStale && checks > 0) {
|
|
78
|
+
// id and testId are strong identifiers — any mismatch on these is decisive
|
|
79
|
+
if (fp.id && fp.id !== id) isStale = true;
|
|
80
|
+
else if (fp.testId && fp.testId !== testId) isStale = true;
|
|
81
|
+
// For remaining signals, stale if more than half mismatch
|
|
82
|
+
else if (mismatches > checks / 2) isStale = true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (isStale) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
code: 'stale_ref',
|
|
89
|
+
message: 'ref=' + ref + ' was <' + fp.tag + '>' + (fp.text ? '"' + fp.text + '"' : '')
|
|
90
|
+
+ ' but now points to <' + tag + '>' + (text ? '"' + text.slice(0, 30) + '"' : ''),
|
|
91
|
+
hint: 'The page has changed since the last snapshot. Re-run \`opencli browser state\` to refresh.',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
window.__resolved = el;
|
|
97
|
+
return { ok: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isCssLike) {
|
|
101
|
+
// ── CSS selector path ──
|
|
102
|
+
let matches;
|
|
103
|
+
try {
|
|
104
|
+
matches = document.querySelectorAll(ref);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
code: 'not_found',
|
|
109
|
+
message: 'Invalid CSS selector: ' + ref,
|
|
110
|
+
hint: 'Check the selector syntax. Use ref numbers from snapshot for reliable targeting.',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (matches.length === 0) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
code: 'not_found',
|
|
118
|
+
message: 'CSS selector "' + ref + '" matched 0 elements',
|
|
119
|
+
hint: 'The element may not exist or may be hidden. Re-run \`opencli browser state\` to check.',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (matches.length > 1) {
|
|
124
|
+
const candidates = [];
|
|
125
|
+
const limit = Math.min(matches.length, 5);
|
|
126
|
+
for (let i = 0; i < limit; i++) {
|
|
127
|
+
const m = matches[i];
|
|
128
|
+
const tag = m.tagName.toLowerCase();
|
|
129
|
+
const text = (m.textContent || '').trim().slice(0, 40);
|
|
130
|
+
const id = m.id ? '#' + m.id : '';
|
|
131
|
+
candidates.push('<' + tag + id + '>' + (text ? ' "' + text + '"' : ''));
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
code: 'ambiguous',
|
|
136
|
+
message: 'CSS selector "' + ref + '" matched ' + matches.length + ' elements',
|
|
137
|
+
hint: 'Use a more specific selector, or use ref numbers from \`opencli browser state\` snapshot.',
|
|
138
|
+
candidates: candidates,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
window.__resolved = matches[0];
|
|
143
|
+
return { ok: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Unrecognized input ──
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
code: 'not_found',
|
|
150
|
+
message: 'Cannot parse target: ' + ref,
|
|
151
|
+
hint: 'Use a numeric ref from snapshot (e.g. "12") or a CSS selector (e.g. "#submit").',
|
|
152
|
+
};
|
|
153
|
+
})()
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Generate JS for click that uses the unified resolver.
|
|
158
|
+
* Assumes resolveTargetJs has been called and __resolved is set.
|
|
159
|
+
*/
|
|
160
|
+
export function clickResolvedJs() {
|
|
161
|
+
return `
|
|
162
|
+
(() => {
|
|
163
|
+
const el = window.__resolved;
|
|
164
|
+
if (!el) throw new Error('No resolved element');
|
|
165
|
+
el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
166
|
+
const rect = el.getBoundingClientRect();
|
|
167
|
+
const x = Math.round(rect.left + rect.width / 2);
|
|
168
|
+
const y = Math.round(rect.top + rect.height / 2);
|
|
169
|
+
try {
|
|
170
|
+
el.click();
|
|
171
|
+
return { status: 'clicked', x, y, w: Math.round(rect.width), h: Math.round(rect.height) };
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return { status: 'js_failed', x, y, w: Math.round(rect.width), h: Math.round(rect.height), error: e.message };
|
|
174
|
+
}
|
|
175
|
+
})()
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Generate JS for type that uses the unified resolver.
|
|
180
|
+
*/
|
|
181
|
+
export function typeResolvedJs(text) {
|
|
182
|
+
const safeText = JSON.stringify(text);
|
|
183
|
+
return `
|
|
184
|
+
(() => {
|
|
185
|
+
const el = window.__resolved;
|
|
186
|
+
if (!el) throw new Error('No resolved element');
|
|
187
|
+
el.focus();
|
|
188
|
+
if (el.isContentEditable) {
|
|
189
|
+
const sel = window.getSelection();
|
|
190
|
+
const range = document.createRange();
|
|
191
|
+
range.selectNodeContents(el);
|
|
192
|
+
sel.removeAllRanges();
|
|
193
|
+
sel.addRange(range);
|
|
194
|
+
document.execCommand('delete', false);
|
|
195
|
+
document.execCommand('insertText', false, ${safeText});
|
|
196
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
197
|
+
} else {
|
|
198
|
+
const proto = el instanceof HTMLTextAreaElement
|
|
199
|
+
? HTMLTextAreaElement.prototype
|
|
200
|
+
: HTMLInputElement.prototype;
|
|
201
|
+
const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
202
|
+
if (nativeSetter) {
|
|
203
|
+
nativeSetter.call(el, ${safeText});
|
|
204
|
+
} else {
|
|
205
|
+
el.value = ${safeText};
|
|
206
|
+
}
|
|
207
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
208
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
209
|
+
}
|
|
210
|
+
return 'typed';
|
|
211
|
+
})()
|
|
212
|
+
`;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Generate JS for scrollTo that uses the unified resolver.
|
|
216
|
+
* Assumes resolveTargetJs has been called and __resolved is set.
|
|
217
|
+
*/
|
|
218
|
+
export function scrollResolvedJs() {
|
|
219
|
+
return `
|
|
220
|
+
(() => {
|
|
221
|
+
const el = window.__resolved;
|
|
222
|
+
if (!el) throw new Error('No resolved element');
|
|
223
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
|
|
224
|
+
return { scrolled: true, tag: el.tagName.toLowerCase(), text: (el.textContent || '').trim().slice(0, 80) };
|
|
225
|
+
})()
|
|
226
|
+
`;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Generate JS to get text content of resolved element.
|
|
230
|
+
*/
|
|
231
|
+
export function getTextResolvedJs() {
|
|
232
|
+
return `
|
|
233
|
+
(() => {
|
|
234
|
+
const el = window.__resolved;
|
|
235
|
+
if (!el) throw new Error('No resolved element');
|
|
236
|
+
return el.textContent?.trim() ?? null;
|
|
237
|
+
})()
|
|
238
|
+
`;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Generate JS to get value of resolved input/textarea element.
|
|
242
|
+
*/
|
|
243
|
+
export function getValueResolvedJs() {
|
|
244
|
+
return `
|
|
245
|
+
(() => {
|
|
246
|
+
const el = window.__resolved;
|
|
247
|
+
if (!el) throw new Error('No resolved element');
|
|
248
|
+
return el.value ?? null;
|
|
249
|
+
})()
|
|
250
|
+
`;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Generate JS to get all attributes of resolved element.
|
|
254
|
+
*/
|
|
255
|
+
export function getAttributesResolvedJs() {
|
|
256
|
+
return `
|
|
257
|
+
(() => {
|
|
258
|
+
const el = window.__resolved;
|
|
259
|
+
if (!el) throw new Error('No resolved element');
|
|
260
|
+
return JSON.stringify(Object.fromEntries([...el.attributes].map(a => [a.name, a.value])));
|
|
261
|
+
})()
|
|
262
|
+
`;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Generate JS to select an option on a resolved <select> element.
|
|
266
|
+
*/
|
|
267
|
+
export function selectResolvedJs(option) {
|
|
268
|
+
const safeOption = JSON.stringify(option);
|
|
269
|
+
return `
|
|
270
|
+
(() => {
|
|
271
|
+
const el = window.__resolved;
|
|
272
|
+
if (!el) throw new Error('No resolved element');
|
|
273
|
+
if (el.tagName !== 'SELECT') return { error: 'Not a <select>' };
|
|
274
|
+
const match = Array.from(el.options).find(o => o.text.trim() === ${safeOption} || o.value === ${safeOption});
|
|
275
|
+
if (!match) return { error: 'Option not found', available: Array.from(el.options).map(o => o.text.trim()) };
|
|
276
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
|
277
|
+
if (setter) setter.call(el, match.value); else el.value = match.value;
|
|
278
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
279
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
280
|
+
return { selected: match.text };
|
|
281
|
+
})()
|
|
282
|
+
`;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Generate JS to check if resolved element is an autocomplete/combobox field.
|
|
286
|
+
*/
|
|
287
|
+
export function isAutocompleteResolvedJs() {
|
|
288
|
+
return `
|
|
289
|
+
(() => {
|
|
290
|
+
const el = window.__resolved;
|
|
291
|
+
if (!el) return false;
|
|
292
|
+
const role = el.getAttribute('role');
|
|
293
|
+
const ac = el.getAttribute('aria-autocomplete');
|
|
294
|
+
const list = el.getAttribute('list');
|
|
295
|
+
return role === 'combobox' || ac === 'list' || ac === 'both' || !!list;
|
|
296
|
+
})()
|
|
297
|
+
`;
|
|
298
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|