@jackwener/opencli 1.7.13 → 1.7.15
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/cli-manifest.json +326 -44
- package/clis/bilibili/subtitle.js +1 -1
- package/clis/dianping/cityResolver.js +185 -0
- package/clis/dianping/dianping.test.js +154 -0
- package/clis/dianping/search.js +6 -3
- package/clis/douyin/_shared/browser-fetch.js +14 -2
- package/clis/douyin/_shared/browser-fetch.test.js +13 -0
- package/clis/douyin/stats.js +1 -1
- package/clis/douyin/update.js +1 -1
- package/clis/jike/search.js +1 -1
- package/clis/reddit/search.js +1 -1
- package/clis/reddit/subreddit.js +1 -1
- package/clis/reddit/user-comments.js +1 -1
- package/clis/reddit/user-posts.js +1 -1
- package/clis/reddit/user.js +1 -1
- package/clis/twitter/article.js +2 -1
- package/clis/twitter/bookmark-folder.js +189 -0
- package/clis/twitter/bookmark-folder.test.js +334 -0
- package/clis/twitter/bookmark-folders.js +117 -0
- package/clis/twitter/bookmark-folders.test.js +150 -0
- package/clis/twitter/bookmark.js +15 -6
- package/clis/twitter/bookmark.test.js +74 -0
- package/clis/twitter/bookmarks.js +7 -5
- package/clis/twitter/delete.js +11 -35
- package/clis/twitter/delete.test.js +21 -9
- package/clis/twitter/download.js +5 -5
- package/clis/twitter/followers.js +9 -3
- package/clis/twitter/following.js +11 -5
- package/clis/twitter/hide-reply.js +24 -5
- package/clis/twitter/hide-reply.test.js +76 -0
- package/clis/twitter/like.js +21 -11
- package/clis/twitter/like.test.js +73 -0
- package/clis/twitter/likes.js +8 -6
- package/clis/twitter/list-add.js +4 -4
- package/clis/twitter/list-remove.js +4 -4
- package/clis/twitter/list-tweets.js +6 -4
- package/clis/twitter/lists.js +3 -3
- package/clis/twitter/notifications.js +2 -2
- package/clis/twitter/profile.js +4 -3
- package/clis/twitter/quote.js +167 -0
- package/clis/twitter/quote.test.js +194 -0
- package/clis/twitter/reply.js +24 -178
- package/clis/twitter/reply.test.js +29 -11
- package/clis/twitter/retweet.js +94 -0
- package/clis/twitter/retweet.test.js +73 -0
- package/clis/twitter/search.js +175 -23
- package/clis/twitter/search.test.js +266 -1
- package/clis/twitter/shared.js +81 -0
- package/clis/twitter/shared.test.js +134 -1
- package/clis/twitter/thread.js +6 -4
- package/clis/twitter/timeline.js +8 -6
- package/clis/twitter/tweets.js +5 -3
- package/clis/twitter/unbookmark.js +13 -6
- package/clis/twitter/unbookmark.test.js +73 -0
- package/clis/twitter/unlike.js +80 -0
- package/clis/twitter/unlike.test.js +75 -0
- package/clis/twitter/unretweet.js +94 -0
- package/clis/twitter/unretweet.test.js +73 -0
- package/clis/twitter/utils.js +286 -0
- package/clis/twitter/utils.test.js +169 -0
- package/dist/src/browser/ax-snapshot.d.ts +37 -0
- package/dist/src/browser/ax-snapshot.js +217 -0
- package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
- package/dist/src/browser/ax-snapshot.test.js +91 -0
- package/dist/src/browser/base-page.d.ts +51 -0
- package/dist/src/browser/base-page.js +545 -2
- package/dist/src/browser/base-page.test.js +520 -4
- package/dist/src/browser/bridge.js +47 -45
- package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -0
- package/dist/src/browser/cdp.js +5 -0
- package/dist/src/browser/cdp.test.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +3 -1
- package/dist/src/browser/find.d.ts +9 -1
- package/dist/src/browser/find.js +219 -0
- package/dist/src/browser/find.test.js +61 -1
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +13 -0
- package/dist/src/browser/page.test.js +28 -0
- package/dist/src/browser/target-errors.d.ts +3 -1
- package/dist/src/browser/target-errors.js +2 -0
- package/dist/src/browser/target-resolver.d.ts +14 -0
- package/dist/src/browser/target-resolver.js +28 -0
- package/dist/src/browser/visual-refs.d.ts +11 -0
- package/dist/src/browser/visual-refs.js +108 -0
- package/dist/src/browser.test.js +18 -0
- package/dist/src/build-manifest.d.ts +23 -0
- package/dist/src/build-manifest.js +34 -0
- package/dist/src/build-manifest.test.js +108 -1
- package/dist/src/cli.js +560 -58
- package/dist/src/cli.test.js +689 -1
- package/dist/src/commanderAdapter.js +23 -4
- package/dist/src/help.d.ts +36 -0
- package/dist/src/help.js +301 -5
- package/dist/src/types.d.ts +82 -0
- package/package.json +1 -1
- package/scripts/typed-error-lint-baseline.json +18 -18
|
@@ -54,62 +54,64 @@ export class BrowserBridge {
|
|
|
54
54
|
const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
|
|
55
55
|
const timeoutMs = effectiveSeconds * 1000;
|
|
56
56
|
const health = await getDaemonHealth({ contextId });
|
|
57
|
+
// Detect stale daemon before any fast path. A stale daemon can still have
|
|
58
|
+
// the extension connected, so this cannot live only in the no-extension branch.
|
|
59
|
+
const daemonVersion = health.status?.daemonVersion;
|
|
60
|
+
const isStale = !!health.status && (!daemonVersion || daemonVersion !== PKG_VERSION);
|
|
61
|
+
let staleDaemonReplaced = false;
|
|
62
|
+
if (isStale) {
|
|
63
|
+
// Stale daemon — restart it so all browser commands run against the
|
|
64
|
+
// currently installed package code, not the old daemon binary.
|
|
65
|
+
const reason = daemonVersion
|
|
66
|
+
? `v${daemonVersion} ≠ v${PKG_VERSION}`
|
|
67
|
+
: `pre-version daemon, CLI is v${PKG_VERSION}`;
|
|
68
|
+
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
|
69
|
+
process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
|
|
70
|
+
}
|
|
71
|
+
const shutdownAccepted = await requestDaemonShutdown();
|
|
72
|
+
const portReleased = shutdownAccepted && await waitForDaemonStop(3000);
|
|
73
|
+
if (!portReleased) {
|
|
74
|
+
// Stale daemon replacement failed — don't blindly spawn on an occupied port
|
|
75
|
+
throw new BrowserConnectError('Stale daemon could not be replaced', `A stale daemon (${reason}) is running but did not shut down.\n` +
|
|
76
|
+
' Run manually: opencli daemon stop && opencli doctor', 'daemon-not-running');
|
|
77
|
+
}
|
|
78
|
+
// Port released — fall through to spawn a fresh daemon
|
|
79
|
+
staleDaemonReplaced = true;
|
|
80
|
+
}
|
|
57
81
|
// Fast path: everything ready
|
|
58
|
-
if (health.state === 'ready')
|
|
82
|
+
if (!staleDaemonReplaced && health.state === 'ready')
|
|
59
83
|
return;
|
|
60
|
-
if (health.state === 'profile-required') {
|
|
84
|
+
if (!staleDaemonReplaced && health.state === 'profile-required') {
|
|
61
85
|
throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
|
|
62
86
|
'Run opencli profile list to see connected profiles.', 'profile-required');
|
|
63
87
|
}
|
|
64
|
-
if (health.state === 'profile-disconnected') {
|
|
88
|
+
if (!staleDaemonReplaced && health.state === 'profile-disconnected') {
|
|
65
89
|
const label = contextId ?? health.status.contextId ?? 'unknown';
|
|
66
90
|
throw new BrowserConnectError(`Browser profile "${label}" is not connected`, 'Open the matching Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 'profile-disconnected');
|
|
67
91
|
}
|
|
68
92
|
// Daemon running but no extension
|
|
69
|
-
if (health.state === 'no-extension') {
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// Stale daemon — restart it so extension gets a fresh WebSocket endpoint
|
|
75
|
-
const reason = daemonVersion
|
|
76
|
-
? `v${daemonVersion} ≠ v${PKG_VERSION}`
|
|
77
|
-
: `pre-version daemon, CLI is v${PKG_VERSION}`;
|
|
78
|
-
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
|
79
|
-
process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
|
|
80
|
-
}
|
|
81
|
-
const shutdownAccepted = await requestDaemonShutdown();
|
|
82
|
-
const portReleased = shutdownAccepted && await waitForDaemonStop(3000);
|
|
83
|
-
if (!portReleased) {
|
|
84
|
-
// Stale daemon replacement failed — don't blindly spawn on an occupied port
|
|
85
|
-
throw new BrowserConnectError('Stale daemon could not be replaced', `A stale daemon (${reason}) is running but did not shut down.\n` +
|
|
86
|
-
' Run manually: opencli daemon stop && opencli doctor', 'daemon-not-running');
|
|
87
|
-
}
|
|
88
|
-
// Port released — fall through to spawn a fresh daemon
|
|
93
|
+
if (!staleDaemonReplaced && health.state === 'no-extension') {
|
|
94
|
+
// Same version — wait for extension to connect
|
|
95
|
+
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
|
96
|
+
process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
|
|
97
|
+
process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
|
|
89
98
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (await this._pollUntilReady(timeoutMs, contextId))
|
|
97
|
-
return;
|
|
98
|
-
const finalHealth = await getDaemonHealth({ contextId });
|
|
99
|
-
if (finalHealth.state === 'profile-required') {
|
|
100
|
-
throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
|
|
101
|
-
'Run opencli profile list to see connected profiles.', 'profile-required');
|
|
102
|
-
}
|
|
103
|
-
if (finalHealth.state === 'profile-disconnected') {
|
|
104
|
-
const label = contextId ?? finalHealth.status.contextId ?? 'unknown';
|
|
105
|
-
throw new BrowserConnectError(`Browser profile "${label}" is not connected`, 'Open the matching Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 'profile-disconnected');
|
|
106
|
-
}
|
|
107
|
-
throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
|
|
108
|
-
'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
|
|
109
|
-
'If not installed:\n' +
|
|
110
|
-
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
|
|
111
|
-
' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
|
|
99
|
+
if (await this._pollUntilReady(timeoutMs, contextId))
|
|
100
|
+
return;
|
|
101
|
+
const finalHealth = await getDaemonHealth({ contextId });
|
|
102
|
+
if (finalHealth.state === 'profile-required') {
|
|
103
|
+
throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
|
|
104
|
+
'Run opencli profile list to see connected profiles.', 'profile-required');
|
|
112
105
|
}
|
|
106
|
+
if (finalHealth.state === 'profile-disconnected') {
|
|
107
|
+
const label = contextId ?? finalHealth.status.contextId ?? 'unknown';
|
|
108
|
+
throw new BrowserConnectError(`Browser profile "${label}" is not connected`, 'Open the matching Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 'profile-disconnected');
|
|
109
|
+
}
|
|
110
|
+
throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
|
|
111
|
+
'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
|
|
112
|
+
'If not installed:\n' +
|
|
113
|
+
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
|
|
114
|
+
' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
|
|
113
115
|
}
|
|
114
116
|
// No daemon — spawn one
|
|
115
117
|
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
3
|
+
function installDom(html) {
|
|
4
|
+
const dom = new JSDOM(html, { pretendToBeVisual: true });
|
|
5
|
+
globalThis.window = dom.window;
|
|
6
|
+
globalThis.document = dom.window.document;
|
|
7
|
+
globalThis.Event = dom.window.Event;
|
|
8
|
+
globalThis.MouseEvent = dom.window.MouseEvent;
|
|
9
|
+
return dom.window.document;
|
|
10
|
+
}
|
|
11
|
+
function dispatchNativeMouseSequence(target) {
|
|
12
|
+
for (const type of ['mousemove', 'pointerdown', 'mousedown', 'mouseup', 'pointerup', 'click']) {
|
|
13
|
+
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
describe('CDP-primary click dropdown fixtures', () => {
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
Reflect.deleteProperty(globalThis, 'window');
|
|
19
|
+
Reflect.deleteProperty(globalThis, 'document');
|
|
20
|
+
});
|
|
21
|
+
it('captures the Radix/shadcn class of dropdowns that open on pointerdown and select on pointerup', () => {
|
|
22
|
+
const document = installDom(`
|
|
23
|
+
<button id="trigger" role="combobox" aria-expanded="false">Category</button>
|
|
24
|
+
<div id="portal-root"></div>
|
|
25
|
+
<output id="value"></output>
|
|
26
|
+
`);
|
|
27
|
+
const trigger = document.querySelector('#trigger');
|
|
28
|
+
const portal = document.querySelector('#portal-root');
|
|
29
|
+
const value = document.querySelector('#value');
|
|
30
|
+
trigger.addEventListener('pointerdown', () => {
|
|
31
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
32
|
+
portal.innerHTML = `
|
|
33
|
+
<div role="listbox">
|
|
34
|
+
<div id="meals" role="option">Meals</div>
|
|
35
|
+
</div>
|
|
36
|
+
`;
|
|
37
|
+
portal.querySelector('#meals').addEventListener('pointerup', () => {
|
|
38
|
+
value.textContent = 'Meals';
|
|
39
|
+
trigger.textContent = 'Meals';
|
|
40
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
// Baseline: DOM el.click() dispatches click only. This is the old OpenCLI
|
|
44
|
+
// failure mode: the command reports success but the dropdown never opens.
|
|
45
|
+
trigger.click();
|
|
46
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
|
47
|
+
expect(portal.querySelector('[role="option"]')).toBeNull();
|
|
48
|
+
expect(value.textContent).toBe('');
|
|
49
|
+
// CDP-style mouse input opens the portal and can commit the option.
|
|
50
|
+
dispatchNativeMouseSequence(trigger);
|
|
51
|
+
const option = portal.querySelector('#meals');
|
|
52
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('true');
|
|
53
|
+
dispatchNativeMouseSequence(option);
|
|
54
|
+
expect(value.textContent).toBe('Meals');
|
|
55
|
+
expect(trigger.textContent).toBe('Meals');
|
|
56
|
+
});
|
|
57
|
+
it('captures the MUI autocomplete class that opens on mousedown and commits on mousedown in a popper', () => {
|
|
58
|
+
const document = installDom(`
|
|
59
|
+
<label for="category">Category</label>
|
|
60
|
+
<input id="category" role="combobox" value="" />
|
|
61
|
+
<div id="mui-popper"></div>
|
|
62
|
+
<output id="value"></output>
|
|
63
|
+
`);
|
|
64
|
+
const input = document.querySelector('#category');
|
|
65
|
+
const popper = document.querySelector('#mui-popper');
|
|
66
|
+
const value = document.querySelector('#value');
|
|
67
|
+
input.addEventListener('mousedown', () => {
|
|
68
|
+
popper.innerHTML = `
|
|
69
|
+
<ul role="listbox">
|
|
70
|
+
<li id="travel" role="option">Travel</li>
|
|
71
|
+
</ul>
|
|
72
|
+
`;
|
|
73
|
+
popper.querySelector('#travel').addEventListener('mousedown', () => {
|
|
74
|
+
input.value = 'Travel';
|
|
75
|
+
value.textContent = 'Travel';
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
input.click();
|
|
79
|
+
expect(popper.querySelector('[role="option"]')).toBeNull();
|
|
80
|
+
expect(input.value).toBe('');
|
|
81
|
+
dispatchNativeMouseSequence(input);
|
|
82
|
+
const option = popper.querySelector('#travel');
|
|
83
|
+
dispatchNativeMouseSequence(option);
|
|
84
|
+
expect(input.value).toBe('Travel');
|
|
85
|
+
expect(value.textContent).toBe('Travel');
|
|
86
|
+
});
|
|
87
|
+
});
|
package/dist/src/browser/cdp.js
CHANGED
|
@@ -369,6 +369,11 @@ class CDPPage extends BasePage {
|
|
|
369
369
|
});
|
|
370
370
|
}
|
|
371
371
|
async nativeClick(x, y) {
|
|
372
|
+
await this.cdp('Input.dispatchMouseEvent', {
|
|
373
|
+
type: 'mouseMoved',
|
|
374
|
+
x,
|
|
375
|
+
y,
|
|
376
|
+
});
|
|
372
377
|
await this.cdp('Input.dispatchMouseEvent', {
|
|
373
378
|
type: 'mousePressed',
|
|
374
379
|
x,
|
|
@@ -69,6 +69,7 @@ describe('CDPBridge cookies', () => {
|
|
|
69
69
|
['Input.insertText', { text: 'hello' }],
|
|
70
70
|
['Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', modifiers: 2 }],
|
|
71
71
|
['Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', modifiers: 2 }],
|
|
72
|
+
['Input.dispatchMouseEvent', { type: 'mouseMoved', x: 10, y: 20 }],
|
|
72
73
|
['Input.dispatchMouseEvent', { type: 'mousePressed', x: 10, y: 20, button: 'left', clickCount: 1 }],
|
|
73
74
|
['Input.dispatchMouseEvent', { type: 'mouseReleased', x: 10, y: 20, button: 'left', clickCount: 1 }],
|
|
74
75
|
['Page.handleJavaScriptDialog', { accept: true, promptText: 'ok' }],
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { BrowserSessionInfo } from '../types.js';
|
|
7
7
|
export interface DaemonCommand {
|
|
8
8
|
id: string;
|
|
9
|
-
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'cdp' | 'frames';
|
|
9
|
+
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames';
|
|
10
10
|
/** Target page identity (targetId). Cross-layer contract with the extension. */
|
|
11
11
|
page?: string;
|
|
12
12
|
code?: string;
|
|
@@ -32,6 +32,8 @@ export interface DaemonCommand {
|
|
|
32
32
|
text?: string;
|
|
33
33
|
/** URL substring filter pattern for network capture */
|
|
34
34
|
pattern?: string;
|
|
35
|
+
/** Download wait timeout in milliseconds */
|
|
36
|
+
timeoutMs?: number;
|
|
35
37
|
cdpMethod?: string;
|
|
36
38
|
cdpParams?: Record<string, unknown>;
|
|
37
39
|
/** When true, the owned automation container is created in the foreground */
|
|
@@ -57,7 +57,7 @@ export interface FindResult {
|
|
|
57
57
|
}
|
|
58
58
|
export interface FindError {
|
|
59
59
|
error: {
|
|
60
|
-
code: 'invalid_selector' | 'selector_not_found';
|
|
60
|
+
code: 'invalid_selector' | 'selector_not_found' | 'semantic_not_found';
|
|
61
61
|
message: string;
|
|
62
62
|
hint?: string;
|
|
63
63
|
};
|
|
@@ -68,9 +68,17 @@ export interface FindOptions {
|
|
|
68
68
|
/** Max chars of trimmed text per entry. Default 120. */
|
|
69
69
|
textMax?: number;
|
|
70
70
|
}
|
|
71
|
+
export interface SemanticFindOptions extends FindOptions {
|
|
72
|
+
role?: string;
|
|
73
|
+
name?: string;
|
|
74
|
+
label?: string;
|
|
75
|
+
text?: string;
|
|
76
|
+
testid?: string;
|
|
77
|
+
}
|
|
71
78
|
/**
|
|
72
79
|
* Build the browser-side JS that performs the CSS query and emits the
|
|
73
80
|
* FindResult (or FindError) envelope. Evaluated inside `page.evaluate`.
|
|
74
81
|
*/
|
|
75
82
|
export declare function buildFindJs(selector: string, opts?: FindOptions): string;
|
|
83
|
+
export declare function buildSemanticFindJs(opts: SemanticFindOptions): string;
|
|
76
84
|
export declare function isFindError(result: unknown): result is FindError;
|
package/dist/src/browser/find.js
CHANGED
|
@@ -174,6 +174,225 @@ export function buildFindJs(selector, opts = {}) {
|
|
|
174
174
|
})()
|
|
175
175
|
`;
|
|
176
176
|
}
|
|
177
|
+
export function buildSemanticFindJs(opts) {
|
|
178
|
+
const criteria = JSON.stringify({
|
|
179
|
+
role: opts.role ?? '',
|
|
180
|
+
name: opts.name ?? '',
|
|
181
|
+
label: opts.label ?? '',
|
|
182
|
+
text: opts.text ?? '',
|
|
183
|
+
testid: opts.testid ?? '',
|
|
184
|
+
});
|
|
185
|
+
const limit = opts.limit ?? 50;
|
|
186
|
+
const textMax = opts.textMax ?? 120;
|
|
187
|
+
const whitelist = JSON.stringify(FIND_ATTR_WHITELIST);
|
|
188
|
+
return `
|
|
189
|
+
(() => {
|
|
190
|
+
const CRITERIA = ${criteria};
|
|
191
|
+
const LIMIT = ${limit};
|
|
192
|
+
const TEXT_MAX = ${textMax};
|
|
193
|
+
const ATTR_WHITELIST = ${whitelist};
|
|
194
|
+
|
|
195
|
+
${COMPOUND_INFO_JS}
|
|
196
|
+
|
|
197
|
+
function normalize(value) {
|
|
198
|
+
return String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function includesNeedle(value, needle) {
|
|
202
|
+
const n = normalize(needle);
|
|
203
|
+
if (!n) return true;
|
|
204
|
+
return normalize(value).includes(n);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function nativeRole(el) {
|
|
208
|
+
const explicit = el.getAttribute('role');
|
|
209
|
+
if (explicit) return explicit;
|
|
210
|
+
const tag = el.tagName.toLowerCase();
|
|
211
|
+
const type = (el.getAttribute('type') || '').toLowerCase();
|
|
212
|
+
if (tag === 'button') return 'button';
|
|
213
|
+
if (tag === 'a' && el.getAttribute('href')) return 'link';
|
|
214
|
+
if (tag === 'textarea') return 'textbox';
|
|
215
|
+
if (tag === 'select') return 'combobox';
|
|
216
|
+
if (tag === 'option') return 'option';
|
|
217
|
+
if (tag === 'input') {
|
|
218
|
+
if (type === 'button' || type === 'submit' || type === 'reset') return 'button';
|
|
219
|
+
if (type === 'checkbox') return 'checkbox';
|
|
220
|
+
if (type === 'radio') return 'radio';
|
|
221
|
+
if (type === 'range') return 'slider';
|
|
222
|
+
if (type === 'search') return 'searchbox';
|
|
223
|
+
return 'textbox';
|
|
224
|
+
}
|
|
225
|
+
return '';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function labelText(el) {
|
|
229
|
+
const parts = [];
|
|
230
|
+
function cssEscape(value) {
|
|
231
|
+
try {
|
|
232
|
+
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
|
|
233
|
+
} catch (_) {}
|
|
234
|
+
return String(value).replace(/["\\\\]/g, '\\\\$&');
|
|
235
|
+
}
|
|
236
|
+
if (el.id) {
|
|
237
|
+
try {
|
|
238
|
+
const label = document.querySelector('label[for="' + cssEscape(el.id) + '"]');
|
|
239
|
+
if (label) parts.push(label.textContent || '');
|
|
240
|
+
} catch (_) {}
|
|
241
|
+
}
|
|
242
|
+
const parentLabel = el.closest?.('label');
|
|
243
|
+
if (parentLabel) parts.push(parentLabel.textContent || '');
|
|
244
|
+
return parts.join(' ');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function byIdText(ids) {
|
|
248
|
+
if (!ids) return '';
|
|
249
|
+
const parts = [];
|
|
250
|
+
for (const id of String(ids).split(/\\s+/)) {
|
|
251
|
+
if (!id) continue;
|
|
252
|
+
try {
|
|
253
|
+
const el = document.getElementById(id);
|
|
254
|
+
if (el) parts.push(el.textContent || '');
|
|
255
|
+
} catch (_) {}
|
|
256
|
+
}
|
|
257
|
+
return parts.join(' ');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function accessibleName(el) {
|
|
261
|
+
return [
|
|
262
|
+
el.getAttribute('aria-label') || '',
|
|
263
|
+
byIdText(el.getAttribute('aria-labelledby')),
|
|
264
|
+
labelText(el),
|
|
265
|
+
el.getAttribute('alt') || '',
|
|
266
|
+
el.getAttribute('title') || '',
|
|
267
|
+
el.getAttribute('placeholder') || '',
|
|
268
|
+
el.getAttribute('value') || '',
|
|
269
|
+
el.textContent || '',
|
|
270
|
+
].filter(Boolean).join(' ');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function pickAttrs(el) {
|
|
274
|
+
const out = {};
|
|
275
|
+
for (const key of ATTR_WHITELIST) {
|
|
276
|
+
const v = el.getAttribute(key);
|
|
277
|
+
if (v != null && v !== '') out[key] = v;
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function isVisible(el) {
|
|
283
|
+
const rect = el.getBoundingClientRect();
|
|
284
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
285
|
+
try {
|
|
286
|
+
const style = getComputedStyle(el);
|
|
287
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
288
|
+
if (parseFloat(style.opacity || '1') === 0) return false;
|
|
289
|
+
} catch (_) {}
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function fingerprintOf(el) {
|
|
294
|
+
return {
|
|
295
|
+
tag: el.tagName.toLowerCase(),
|
|
296
|
+
role: el.getAttribute('role') || '',
|
|
297
|
+
text: (el.textContent || '').trim().slice(0, 30),
|
|
298
|
+
ariaLabel: el.getAttribute('aria-label') || '',
|
|
299
|
+
id: el.id || '',
|
|
300
|
+
testId: el.getAttribute('data-testid') || el.getAttribute('data-test') || '',
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function matches(el) {
|
|
305
|
+
const role = nativeRole(el);
|
|
306
|
+
const name = accessibleName(el);
|
|
307
|
+
const label = labelText(el);
|
|
308
|
+
const text = el.textContent || '';
|
|
309
|
+
const testid = el.getAttribute('data-testid') || el.getAttribute('data-test') || el.getAttribute('test-id') || '';
|
|
310
|
+
if (CRITERIA.role && normalize(role) !== normalize(CRITERIA.role)) return false;
|
|
311
|
+
if (CRITERIA.name && !includesNeedle(name, CRITERIA.name)) return false;
|
|
312
|
+
if (CRITERIA.label && !includesNeedle(label, CRITERIA.label)) return false;
|
|
313
|
+
if (CRITERIA.text && !includesNeedle(text, CRITERIA.text)) return false;
|
|
314
|
+
if (CRITERIA.testid && !includesNeedle(testid, CRITERIA.testid)) return false;
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const candidates = Array.from(document.querySelectorAll([
|
|
319
|
+
'a[href]',
|
|
320
|
+
'button',
|
|
321
|
+
'input',
|
|
322
|
+
'textarea',
|
|
323
|
+
'select',
|
|
324
|
+
'option',
|
|
325
|
+
'[role]',
|
|
326
|
+
'[aria-label]',
|
|
327
|
+
'[aria-labelledby]',
|
|
328
|
+
'[data-testid]',
|
|
329
|
+
'[data-test]',
|
|
330
|
+
'[test-id]',
|
|
331
|
+
'label',
|
|
332
|
+
'[contenteditable="true"]',
|
|
333
|
+
].join(',')));
|
|
334
|
+
const matchesList = candidates.filter(matches);
|
|
335
|
+
|
|
336
|
+
if (matchesList.length === 0) {
|
|
337
|
+
return {
|
|
338
|
+
error: {
|
|
339
|
+
code: 'semantic_not_found',
|
|
340
|
+
message: 'Semantic locator matched 0 elements',
|
|
341
|
+
hint: 'Try browser state, --source ax, or relax --role/--name/--label/--text/--testid.',
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const identity = (window.__opencli_ref_identity = window.__opencli_ref_identity || {});
|
|
347
|
+
let maxRef = 0;
|
|
348
|
+
for (const k in identity) {
|
|
349
|
+
const n = parseInt(k, 10);
|
|
350
|
+
if (!isNaN(n) && n > maxRef) maxRef = n;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
const tagged = document.querySelectorAll('[data-opencli-ref]');
|
|
354
|
+
for (let t = 0; t < tagged.length; t++) {
|
|
355
|
+
const v = tagged[t].getAttribute('data-opencli-ref');
|
|
356
|
+
const n = v != null && /^\\d+$/.test(v) ? parseInt(v, 10) : NaN;
|
|
357
|
+
if (!isNaN(n) && n > maxRef) maxRef = n;
|
|
358
|
+
}
|
|
359
|
+
} catch (_) {}
|
|
360
|
+
|
|
361
|
+
const take = Math.min(matchesList.length, LIMIT);
|
|
362
|
+
const entries = [];
|
|
363
|
+
for (let i = 0; i < take; i++) {
|
|
364
|
+
const el = matchesList[i];
|
|
365
|
+
const refAttr = el.getAttribute('data-opencli-ref');
|
|
366
|
+
let refNum = refAttr != null && /^\\d+$/.test(refAttr) ? parseInt(refAttr, 10) : null;
|
|
367
|
+
if (refNum === null) {
|
|
368
|
+
refNum = ++maxRef;
|
|
369
|
+
try { el.setAttribute('data-opencli-ref', '' + refNum); } catch (_) {}
|
|
370
|
+
identity['' + refNum] = fingerprintOf(el);
|
|
371
|
+
} else if (!identity['' + refNum]) {
|
|
372
|
+
identity['' + refNum] = fingerprintOf(el);
|
|
373
|
+
}
|
|
374
|
+
const text = (el.textContent || '').trim();
|
|
375
|
+
const entry = {
|
|
376
|
+
nth: i,
|
|
377
|
+
ref: refNum,
|
|
378
|
+
tag: el.tagName.toLowerCase(),
|
|
379
|
+
role: nativeRole(el),
|
|
380
|
+
text: text.length > TEXT_MAX ? text.slice(0, TEXT_MAX) : text,
|
|
381
|
+
attrs: pickAttrs(el),
|
|
382
|
+
visible: isVisible(el),
|
|
383
|
+
};
|
|
384
|
+
const compound = compoundInfoOf(el);
|
|
385
|
+
if (compound) entry.compound = compound;
|
|
386
|
+
entries.push(entry);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
matches_n: matchesList.length,
|
|
391
|
+
entries,
|
|
392
|
+
};
|
|
393
|
+
})()
|
|
394
|
+
`;
|
|
395
|
+
}
|
|
177
396
|
export function isFindError(result) {
|
|
178
397
|
return !!result && typeof result === 'object' && 'error' in result;
|
|
179
398
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
3
|
+
import { buildFindJs, buildSemanticFindJs, FIND_ATTR_WHITELIST, isFindError } from './find.js';
|
|
3
4
|
/**
|
|
4
5
|
* These tests validate the shape and options of the generated JS string
|
|
5
6
|
* (no DOM available in the default vitest unit env). Runtime behavior of
|
|
@@ -118,3 +119,62 @@ describe('isFindError', () => {
|
|
|
118
119
|
expect(isFindError('string')).toBe(false);
|
|
119
120
|
});
|
|
120
121
|
});
|
|
122
|
+
describe('buildSemanticFindJs', () => {
|
|
123
|
+
function runSemanticFind(html, opts) {
|
|
124
|
+
const dom = new JSDOM(html, { runScripts: 'outside-only' });
|
|
125
|
+
return {
|
|
126
|
+
dom,
|
|
127
|
+
result: dom.window.eval(buildSemanticFindJs(opts)),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
it('produces syntactically valid JS and embeds semantic criteria safely', () => {
|
|
131
|
+
const js = buildSemanticFindJs({ role: 'button', name: 'Save "now"', testid: 'submit' });
|
|
132
|
+
expect(() => new Function(`return (${js});`)).not.toThrow();
|
|
133
|
+
expect(js).toContain(JSON.stringify({
|
|
134
|
+
role: 'button',
|
|
135
|
+
name: 'Save "now"',
|
|
136
|
+
label: '',
|
|
137
|
+
text: '',
|
|
138
|
+
testid: 'submit',
|
|
139
|
+
}));
|
|
140
|
+
});
|
|
141
|
+
it('matches native roles, accessible name, labels, text, and test ids', () => {
|
|
142
|
+
const js = buildSemanticFindJs({ role: 'button', name: 'Save', label: 'Category', text: 'Travel', testid: 'category' });
|
|
143
|
+
expect(js).toContain('function nativeRole(el)');
|
|
144
|
+
expect(js).toContain('function accessibleName(el)');
|
|
145
|
+
expect(js).toContain('function labelText(el)');
|
|
146
|
+
expect(js).toContain('CRITERIA.role');
|
|
147
|
+
expect(js).toContain('CRITERIA.name');
|
|
148
|
+
expect(js).toContain('CRITERIA.label');
|
|
149
|
+
expect(js).toContain('CRITERIA.text');
|
|
150
|
+
expect(js).toContain('CRITERIA.testid');
|
|
151
|
+
});
|
|
152
|
+
it('allocates refs exactly like CSS find so downstream actions can click them', () => {
|
|
153
|
+
const js = buildSemanticFindJs({ role: 'button', name: 'Save' });
|
|
154
|
+
expect(js).toContain("el.setAttribute('data-opencli-ref'");
|
|
155
|
+
expect(js).toContain('__opencli_ref_identity');
|
|
156
|
+
expect(js).toContain("identity['' + refNum] = fingerprintOf(el)");
|
|
157
|
+
expect(js).toContain("document.querySelectorAll('[data-opencli-ref]')");
|
|
158
|
+
});
|
|
159
|
+
it('executes semantic role/name/testid matching and allocates a clickable ref', () => {
|
|
160
|
+
const { dom, result } = runSemanticFind('<button aria-label="Save expense" data-testid="save-button">Ignored copy</button>', { role: 'button', name: 'Save', testid: 'save' });
|
|
161
|
+
expect(result).toMatchObject({
|
|
162
|
+
matches_n: 1,
|
|
163
|
+
entries: [
|
|
164
|
+
{ nth: 0, ref: 1, tag: 'button', role: 'button', attrs: { 'aria-label': 'Save expense', 'data-testid': 'save-button' } },
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
const button = dom.window.document.querySelector('button');
|
|
168
|
+
expect(button.getAttribute('data-opencli-ref')).toBe('1');
|
|
169
|
+
expect(dom.window.__opencli_ref_identity['1']).toMatchObject({ tag: 'button', ariaLabel: 'Save expense' });
|
|
170
|
+
});
|
|
171
|
+
it('matches associated labels and placeholders for form controls', () => {
|
|
172
|
+
const { result } = runSemanticFind('<label for="category">Category</label><input id="category" placeholder="Expense category" value="Travel" />', { role: 'textbox', label: 'Category', name: 'Expense category' });
|
|
173
|
+
expect(result).toMatchObject({
|
|
174
|
+
matches_n: 1,
|
|
175
|
+
entries: [
|
|
176
|
+
{ nth: 0, ref: 1, tag: 'input', role: 'textbox' },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* by the navigate action and pass it to all subsequent commands. This ensures
|
|
9
9
|
* page-scoped operations target the correct page without guessing.
|
|
10
10
|
*/
|
|
11
|
-
import type { BrowserCookie, ScreenshotOptions } from '../types.js';
|
|
11
|
+
import type { BrowserCookie, BrowserDownloadWaitResult, ScreenshotOptions } from '../types.js';
|
|
12
12
|
import { BasePage } from './base-page.js';
|
|
13
13
|
/**
|
|
14
14
|
* Page — implements IPage by talking to the daemon via HTTP.
|
|
@@ -53,6 +53,7 @@ export declare class Page extends BasePage {
|
|
|
53
53
|
screenshot(options?: ScreenshotOptions): Promise<string>;
|
|
54
54
|
startNetworkCapture(pattern?: string): Promise<boolean>;
|
|
55
55
|
readNetworkCapture(): Promise<unknown[]>;
|
|
56
|
+
waitForDownload(pattern?: string, timeoutMs?: number): Promise<BrowserDownloadWaitResult>;
|
|
56
57
|
/**
|
|
57
58
|
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
|
|
58
59
|
* Chrome reads the files directly from the local filesystem, avoiding the
|
package/dist/src/browser/page.js
CHANGED
|
@@ -246,6 +246,14 @@ export class Page extends BasePage {
|
|
|
246
246
|
return [];
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
|
+
async waitForDownload(pattern = '', timeoutMs = 30_000) {
|
|
250
|
+
const result = await sendCommand('wait-download', {
|
|
251
|
+
pattern,
|
|
252
|
+
timeoutMs,
|
|
253
|
+
...this._cmdOpts(),
|
|
254
|
+
});
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
249
257
|
/**
|
|
250
258
|
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
|
|
251
259
|
* Chrome reads the files directly from the local filesystem, avoiding the
|
|
@@ -360,6 +368,11 @@ export class Page extends BasePage {
|
|
|
360
368
|
`);
|
|
361
369
|
}
|
|
362
370
|
async nativeClick(x, y) {
|
|
371
|
+
await this.cdp('Input.dispatchMouseEvent', {
|
|
372
|
+
type: 'mouseMoved',
|
|
373
|
+
x,
|
|
374
|
+
y,
|
|
375
|
+
});
|
|
363
376
|
await this.cdp('Input.dispatchMouseEvent', {
|
|
364
377
|
type: 'mousePressed',
|
|
365
378
|
x, y,
|
|
@@ -102,6 +102,34 @@ describe('Page network capture compatibility', () => {
|
|
|
102
102
|
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
103
103
|
});
|
|
104
104
|
});
|
|
105
|
+
describe('Page download waits', () => {
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
sendCommandMock.mockReset();
|
|
108
|
+
sendCommandFullMock.mockReset();
|
|
109
|
+
warnMock.mockReset();
|
|
110
|
+
});
|
|
111
|
+
it('sends wait-download through the daemon with workspace and timeout', async () => {
|
|
112
|
+
sendCommandMock.mockResolvedValueOnce({
|
|
113
|
+
downloaded: true,
|
|
114
|
+
filename: '/tmp/receipt.pdf',
|
|
115
|
+
state: 'complete',
|
|
116
|
+
elapsedMs: 5,
|
|
117
|
+
});
|
|
118
|
+
const page = new Page('site:mercury');
|
|
119
|
+
const result = await page.waitForDownload('receipt', 1234);
|
|
120
|
+
expect(result).toEqual({
|
|
121
|
+
downloaded: true,
|
|
122
|
+
filename: '/tmp/receipt.pdf',
|
|
123
|
+
state: 'complete',
|
|
124
|
+
elapsedMs: 5,
|
|
125
|
+
});
|
|
126
|
+
expect(sendCommandMock).toHaveBeenCalledWith('wait-download', expect.objectContaining({
|
|
127
|
+
workspace: 'site:mercury',
|
|
128
|
+
pattern: 'receipt',
|
|
129
|
+
timeoutMs: 1234,
|
|
130
|
+
}));
|
|
131
|
+
});
|
|
132
|
+
});
|
|
105
133
|
describe('Page CDP helpers', () => {
|
|
106
134
|
beforeEach(() => {
|
|
107
135
|
sendCommandMock.mockReset();
|
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
* - selector_ambiguous: >1 matches for a write op without --nth
|
|
17
17
|
* - selector_nth_out_of_range: --nth beyond matches_n
|
|
18
18
|
* - not_editable: target exists but cannot accept text input
|
|
19
|
+
* - not_checkable: target exists but cannot be checked/unchecked
|
|
20
|
+
* - not_file_input: target exists but is not a usable file input
|
|
19
21
|
*/
|
|
20
|
-
export type TargetErrorCode = 'not_found' | 'stale_ref' | 'invalid_selector' | 'selector_not_found' | 'selector_ambiguous' | 'selector_nth_out_of_range' | 'not_editable';
|
|
22
|
+
export type TargetErrorCode = 'not_found' | 'stale_ref' | 'invalid_selector' | 'selector_not_found' | 'selector_ambiguous' | 'selector_nth_out_of_range' | 'not_editable' | 'not_checkable' | 'not_file_input';
|
|
21
23
|
export interface TargetErrorInfo {
|
|
22
24
|
code: TargetErrorCode;
|
|
23
25
|
message: string;
|