@jackwener/opencli 1.7.10 → 1.7.11
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 +3 -3
- package/README.zh-CN.md +3 -3
- package/cli-manifest.json +26 -0
- package/clis/instagram/collection-delete.js +91 -0
- package/dist/src/adapter-shadow.d.ts +11 -0
- package/dist/src/adapter-shadow.js +72 -0
- package/dist/src/adapter-shadow.test.d.ts +1 -0
- package/dist/src/adapter-shadow.test.js +49 -0
- package/dist/src/browser/base-page.d.ts +6 -2
- package/dist/src/browser/base-page.js +88 -6
- package/dist/src/browser/base-page.test.js +61 -1
- package/dist/src/browser/cdp.js +48 -0
- package/dist/src/browser/cdp.test.js +23 -0
- package/dist/src/browser/dom-helpers.d.ts +1 -1
- package/dist/src/browser/dom-helpers.js +15 -3
- package/dist/src/browser/page.js +1 -1
- package/dist/src/browser/target-resolver.d.ts +8 -0
- package/dist/src/browser/target-resolver.js +75 -0
- package/dist/src/browser/verify-fixture.d.ts +1 -0
- package/dist/src/browser/verify-fixture.js +18 -0
- package/dist/src/browser/verify-fixture.test.js +16 -1
- package/dist/src/build-manifest.d.ts +68 -33
- package/dist/src/build-manifest.js +175 -29
- package/dist/src/build-manifest.test.js +75 -1
- package/dist/src/cli.js +10 -7
- package/dist/src/cli.test.js +58 -0
- package/dist/src/doctor.d.ts +2 -0
- package/dist/src/doctor.js +7 -0
- package/dist/src/doctor.test.js +36 -1
- package/dist/src/manifest-types.d.ts +39 -0
- package/dist/src/manifest-types.js +9 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -97,7 +97,7 @@ If you want to add your own commands, start with the [Extending OpenCLI guide](.
|
|
|
97
97
|
|------|------------------|
|
|
98
98
|
| Keep personal website commands in your own Git repo | `opencli plugin create` + `opencli plugin install file://...` |
|
|
99
99
|
| Quickly draft a private local adapter | `opencli browser init <site>/<command>` in `~/.opencli/clis/` |
|
|
100
|
-
| Modify an official adapter locally | `opencli adapter eject
|
|
100
|
+
| Modify an official adapter locally | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
|
|
101
101
|
| Publish or install third-party commands | `opencli plugin install github:user/repo` |
|
|
102
102
|
| Wrap an existing local binary | `opencli external register <name>` |
|
|
103
103
|
|
|
@@ -174,7 +174,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil
|
|
|
174
174
|
2. Discover the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback.
|
|
175
175
|
3. Decide the auth strategy — `PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`.
|
|
176
176
|
4. Decode response fields and design output columns.
|
|
177
|
-
5. `opencli browser init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
|
|
177
|
+
5. `opencli browser analyze <url>` for one-shot recon, then `opencli browser init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
|
|
178
178
|
6. Persist site knowledge to `~/.opencli/sites/<site>/` so the next adapter for the same site is faster.
|
|
179
179
|
|
|
180
180
|
### CLI Hub and desktop adapters
|
|
@@ -407,7 +407,7 @@ Before writing any adapter code, read the [`opencli-adapter-author` skill](./ski
|
|
|
407
407
|
- Recon the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
|
|
408
408
|
- Discover the right endpoint via `opencli browser network`, `eval`, or the interceptor fallback.
|
|
409
409
|
- Decide auth strategy (`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`).
|
|
410
|
-
-
|
|
410
|
+
- Run `opencli browser analyze <url>` for one-shot recon, decode response fields, design columns, scaffold with `opencli browser init`.
|
|
411
411
|
- Verify with `opencli browser verify <site>/<name>` before shipping.
|
|
412
412
|
|
|
413
413
|
For long-lived personal commands that should live in your own Git repo, use a local plugin instead; see [Extending OpenCLI](./docs/guide/extending-opencli.md). Quick private adapters can still live at `~/.opencli/clis/<site>/<name>.js`. Site knowledge (endpoints, field maps, fixtures) accumulates in `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context instead of zero.
|
package/README.zh-CN.md
CHANGED
|
@@ -81,7 +81,7 @@ opencli bilibili hot --limit 5
|
|
|
81
81
|
|------|----------|
|
|
82
82
|
| 把个人网站命令放在自己的 Git repo | `opencli plugin create` + `opencli plugin install file://...` |
|
|
83
83
|
| 快速写一个本机私人 adapter | `opencli browser init <site>/<command>`,放在 `~/.opencli/clis/` |
|
|
84
|
-
| 本地修改官方 adapter | `opencli adapter eject
|
|
84
|
+
| 本地修改官方 adapter | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
|
|
85
85
|
| 发布或安装第三方命令 | `opencli plugin install github:user/repo` |
|
|
86
86
|
| 包装已有本机 binary | `opencli external register <name>` |
|
|
87
87
|
|
|
@@ -158,7 +158,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
|
|
|
158
158
|
2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
|
|
159
159
|
3. 定认证策略——`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
|
|
160
160
|
4. 字段解码 + 设计输出列
|
|
161
|
-
5. `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
|
|
161
|
+
5. `opencli browser analyze <url>` 一步侦察,再 `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
|
|
162
162
|
6. 把站点知识沉到 `~/.opencli/sites/<site>/`,下次写同站点的其他命令直接吃缓存
|
|
163
163
|
|
|
164
164
|
### CLI 枢纽与桌面端适配器
|
|
@@ -505,7 +505,7 @@ opencli plugin uninstall my-tool # 卸载
|
|
|
505
505
|
- 侦察站点,选定 pattern(SPA / SSR / JSONP / Token / Streaming)
|
|
506
506
|
- 用 `opencli browser network`、`eval`、interceptor 等找到目标 endpoint
|
|
507
507
|
- 定认证策略(`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`)
|
|
508
|
-
-
|
|
508
|
+
- 先用 `opencli browser analyze <url>` 一步侦察,再字段解码、设计 columns、`opencli browser init` 生成骨架
|
|
509
509
|
- 交付前用 `opencli browser verify <site>/<name>` 验证
|
|
510
510
|
|
|
511
511
|
在仓库外写的私有适配器放到 `~/.opencli/clis/<site>/<name>.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites/<site>/`,下次写同站点的其他命令可以直接复用。
|
package/cli-manifest.json
CHANGED
|
@@ -8719,6 +8719,32 @@
|
|
|
8719
8719
|
"sourceFile": "instagram/collection-create.js",
|
|
8720
8720
|
"navigateBefore": "https://www.instagram.com"
|
|
8721
8721
|
},
|
|
8722
|
+
{
|
|
8723
|
+
"site": "instagram",
|
|
8724
|
+
"name": "collection-delete",
|
|
8725
|
+
"description": "Delete an Instagram saved-posts collection (folder) by name or id",
|
|
8726
|
+
"domain": "www.instagram.com",
|
|
8727
|
+
"strategy": "cookie",
|
|
8728
|
+
"browser": true,
|
|
8729
|
+
"args": [
|
|
8730
|
+
{
|
|
8731
|
+
"name": "target",
|
|
8732
|
+
"type": "str",
|
|
8733
|
+
"required": true,
|
|
8734
|
+
"positional": true,
|
|
8735
|
+
"help": "Collection name (case-insensitive) or numeric collection_id"
|
|
8736
|
+
}
|
|
8737
|
+
],
|
|
8738
|
+
"columns": [
|
|
8739
|
+
"status",
|
|
8740
|
+
"collectionId",
|
|
8741
|
+
"collectionName"
|
|
8742
|
+
],
|
|
8743
|
+
"type": "js",
|
|
8744
|
+
"modulePath": "instagram/collection-delete.js",
|
|
8745
|
+
"sourceFile": "instagram/collection-delete.js",
|
|
8746
|
+
"navigateBefore": "https://www.instagram.com"
|
|
8747
|
+
},
|
|
8722
8748
|
{
|
|
8723
8749
|
"site": "instagram",
|
|
8724
8750
|
"name": "comment",
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { cli } from '@jackwener/opencli/registry';
|
|
2
|
+
cli({
|
|
3
|
+
site: 'instagram',
|
|
4
|
+
name: 'collection-delete',
|
|
5
|
+
description: 'Delete an Instagram saved-posts collection (folder) by name or id',
|
|
6
|
+
domain: 'www.instagram.com',
|
|
7
|
+
args: [
|
|
8
|
+
{
|
|
9
|
+
name: 'target',
|
|
10
|
+
required: true,
|
|
11
|
+
positional: true,
|
|
12
|
+
help: 'Collection name (case-insensitive) or numeric collection_id',
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
columns: ['status', 'collectionId', 'collectionName'],
|
|
16
|
+
pipeline: [
|
|
17
|
+
{ navigate: 'https://www.instagram.com' },
|
|
18
|
+
{ evaluate: `(async () => {
|
|
19
|
+
const target = \${{ args.target | json }};
|
|
20
|
+
if (!target || !String(target).trim()) {
|
|
21
|
+
throw new Error('Collection target (name or id) cannot be empty');
|
|
22
|
+
}
|
|
23
|
+
const raw = String(target).trim();
|
|
24
|
+
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
|
|
25
|
+
if (!csrf) {
|
|
26
|
+
throw new Error('csrftoken cookie missing - make sure you are logged in to Instagram');
|
|
27
|
+
}
|
|
28
|
+
const headers = { 'X-IG-App-ID': '936619743392459' };
|
|
29
|
+
|
|
30
|
+
// Resolve name -> id via /collections/list/. Always go through this path so we can
|
|
31
|
+
// surface an explicit error on duplicate names or unknown names instead of relying
|
|
32
|
+
// on a 404.
|
|
33
|
+
const listRes = await fetch('https://www.instagram.com/api/v1/collections/list/?collection_types=%5B%22MEDIA%22%5D', {
|
|
34
|
+
credentials: 'include',
|
|
35
|
+
headers,
|
|
36
|
+
});
|
|
37
|
+
if (!listRes.ok) {
|
|
38
|
+
throw new Error('Failed to list collections: HTTP ' + listRes.status + ' - make sure you are logged in to Instagram');
|
|
39
|
+
}
|
|
40
|
+
const listData = await listRes.json();
|
|
41
|
+
const collections = listData?.items || [];
|
|
42
|
+
const isNumericId = /^\\d{6,}$/.test(raw);
|
|
43
|
+
let id = '';
|
|
44
|
+
let resolvedName = '';
|
|
45
|
+
if (isNumericId) {
|
|
46
|
+
const hit = collections.find((c) => String(c?.collection_id) === raw);
|
|
47
|
+
if (!hit) {
|
|
48
|
+
throw new Error('Collection id not found in your account: ' + raw);
|
|
49
|
+
}
|
|
50
|
+
id = String(hit.collection_id);
|
|
51
|
+
resolvedName = String(hit.collection_name || '');
|
|
52
|
+
} else {
|
|
53
|
+
const wanted = raw.toLowerCase();
|
|
54
|
+
const matches = collections.filter((c) => String(c?.collection_name || '').trim().toLowerCase() === wanted);
|
|
55
|
+
if (matches.length === 0) {
|
|
56
|
+
const names = collections.map((c) => c?.collection_name).filter(Boolean);
|
|
57
|
+
throw new Error('Collection not found: ' + raw + '. Available: ' + (names.length ? names.join(', ') : '(none)'));
|
|
58
|
+
}
|
|
59
|
+
if (matches.length > 1) {
|
|
60
|
+
const ids = matches.map((c) => c.collection_id).join(', ');
|
|
61
|
+
throw new Error('Multiple collections share the name "' + raw + '" (ids: ' + ids + '). Pass the numeric collection_id explicitly to disambiguate.');
|
|
62
|
+
}
|
|
63
|
+
id = String(matches[0].collection_id);
|
|
64
|
+
resolvedName = String(matches[0].collection_name || raw);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const fd = new FormData();
|
|
68
|
+
fd.append('module_name', 'collection_settings');
|
|
69
|
+
const res = await fetch('https://www.instagram.com/api/v1/collections/' + encodeURIComponent(id) + '/delete/', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
credentials: 'include',
|
|
72
|
+
headers: { ...headers, 'X-CSRFToken': csrf },
|
|
73
|
+
body: fd,
|
|
74
|
+
});
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
const body = await res.text().catch(() => '');
|
|
77
|
+
throw new Error('Failed to delete collection: HTTP ' + res.status + (body ? ' - ' + body.slice(0, 200) : ''));
|
|
78
|
+
}
|
|
79
|
+
const d = await res.json().catch(() => ({}));
|
|
80
|
+
if (d?.status && d.status !== 'ok') {
|
|
81
|
+
throw new Error('Instagram returned non-ok status: ' + JSON.stringify(d).slice(0, 300));
|
|
82
|
+
}
|
|
83
|
+
return [{
|
|
84
|
+
status: 'Deleted',
|
|
85
|
+
collectionId: id,
|
|
86
|
+
collectionName: resolvedName,
|
|
87
|
+
}];
|
|
88
|
+
})()
|
|
89
|
+
` },
|
|
90
|
+
],
|
|
91
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type AdapterShadow = {
|
|
2
|
+
name: string;
|
|
3
|
+
userPath: string;
|
|
4
|
+
builtinPath: string;
|
|
5
|
+
};
|
|
6
|
+
export type AdapterShadowOptions = {
|
|
7
|
+
userClisDir?: string;
|
|
8
|
+
builtinClisDir?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function findShadowedUserAdapters(opts?: AdapterShadowOptions): AdapterShadow[];
|
|
11
|
+
export declare function formatAdapterShadowIssue(shadows: AdapterShadow[]): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { findPackageRoot, getCliManifestPath } from './package-paths.js';
|
|
6
|
+
function defaultBuiltinClisDir() {
|
|
7
|
+
return path.join(findPackageRoot(fileURLToPath(import.meta.url)), 'clis');
|
|
8
|
+
}
|
|
9
|
+
function safeReaddir(dir) {
|
|
10
|
+
try {
|
|
11
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function loadBuiltinCommandFiles(builtinClisDir) {
|
|
18
|
+
try {
|
|
19
|
+
const raw = fs.readFileSync(getCliManifestPath(builtinClisDir), 'utf-8');
|
|
20
|
+
const entries = JSON.parse(raw);
|
|
21
|
+
const files = new Set();
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
const rel = entry.sourceFile ?? entry.modulePath;
|
|
24
|
+
if (rel)
|
|
25
|
+
files.add(path.resolve(builtinClisDir, rel));
|
|
26
|
+
}
|
|
27
|
+
return files;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return new Set();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function findShadowedUserAdapters(opts = {}) {
|
|
34
|
+
const userClisDir = opts.userClisDir ?? path.join(os.homedir(), '.opencli', 'clis');
|
|
35
|
+
const builtinClisDir = opts.builtinClisDir ?? defaultBuiltinClisDir();
|
|
36
|
+
const builtinCommandFiles = loadBuiltinCommandFiles(builtinClisDir);
|
|
37
|
+
const shadows = [];
|
|
38
|
+
for (const siteEntry of safeReaddir(userClisDir)) {
|
|
39
|
+
if (!siteEntry.isDirectory())
|
|
40
|
+
continue;
|
|
41
|
+
const site = siteEntry.name;
|
|
42
|
+
const userSiteDir = path.join(userClisDir, site);
|
|
43
|
+
const builtinSiteDir = path.join(builtinClisDir, site);
|
|
44
|
+
for (const commandEntry of safeReaddir(userSiteDir)) {
|
|
45
|
+
if (!commandEntry.isFile() || !commandEntry.name.endsWith('.js'))
|
|
46
|
+
continue;
|
|
47
|
+
const userPath = path.join(userSiteDir, commandEntry.name);
|
|
48
|
+
const builtinPath = path.join(builtinSiteDir, commandEntry.name);
|
|
49
|
+
const builtinResolved = path.resolve(builtinPath);
|
|
50
|
+
if (!builtinCommandFiles.has(builtinResolved))
|
|
51
|
+
continue;
|
|
52
|
+
shadows.push({
|
|
53
|
+
name: `${site}/${commandEntry.name.replace(/\.js$/, '')}`,
|
|
54
|
+
userPath,
|
|
55
|
+
builtinPath,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return shadows.sort((a, b) => a.name.localeCompare(b.name));
|
|
60
|
+
}
|
|
61
|
+
export function formatAdapterShadowIssue(shadows) {
|
|
62
|
+
const visible = shadows.slice(0, 10);
|
|
63
|
+
const lines = ['Local adapter overrides shadow packaged adapters:'];
|
|
64
|
+
for (const shadow of visible) {
|
|
65
|
+
lines.push(` ${shadow.name}: ${shadow.userPath} overrides ${shadow.builtinPath}`);
|
|
66
|
+
}
|
|
67
|
+
if (shadows.length > visible.length) {
|
|
68
|
+
lines.push(` ... and ${shadows.length - visible.length} more`);
|
|
69
|
+
}
|
|
70
|
+
lines.push('Remove the local ~/.opencli/clis copy, or run opencli adapter reset <site>, when you want packaged updates.');
|
|
71
|
+
return lines.join('\n');
|
|
72
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { findShadowedUserAdapters, formatAdapterShadowIssue } from './adapter-shadow.js';
|
|
6
|
+
describe('adapter shadow detection', () => {
|
|
7
|
+
it('reports user adapters that shadow packaged manifest commands', () => {
|
|
8
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-adapter-shadow-'));
|
|
9
|
+
try {
|
|
10
|
+
const userClisDir = path.join(root, 'user-clis');
|
|
11
|
+
const builtinRoot = path.join(root, 'pkg');
|
|
12
|
+
const builtinClisDir = path.join(builtinRoot, 'clis');
|
|
13
|
+
fs.mkdirSync(path.join(userClisDir, 'instagram'), { recursive: true });
|
|
14
|
+
fs.mkdirSync(path.join(userClisDir, 'twitter'), { recursive: true });
|
|
15
|
+
fs.mkdirSync(path.join(builtinClisDir, 'instagram'), { recursive: true });
|
|
16
|
+
fs.mkdirSync(path.join(builtinClisDir, 'twitter'), { recursive: true });
|
|
17
|
+
fs.writeFileSync(path.join(userClisDir, 'instagram', 'saved.js'), '', 'utf-8');
|
|
18
|
+
fs.writeFileSync(path.join(userClisDir, 'instagram', 'utils.js'), '', 'utf-8');
|
|
19
|
+
fs.writeFileSync(path.join(userClisDir, 'twitter', 'search.js'), '', 'utf-8');
|
|
20
|
+
fs.writeFileSync(path.join(builtinClisDir, 'instagram', 'saved.js'), '', 'utf-8');
|
|
21
|
+
fs.writeFileSync(path.join(builtinClisDir, 'instagram', 'utils.js'), '', 'utf-8');
|
|
22
|
+
fs.writeFileSync(path.join(builtinClisDir, 'twitter', 'search.js'), '', 'utf-8');
|
|
23
|
+
fs.writeFileSync(path.join(builtinRoot, 'cli-manifest.json'), `${JSON.stringify([
|
|
24
|
+
{ site: 'instagram', name: 'saved', sourceFile: 'instagram/saved.js' },
|
|
25
|
+
])}\n`, 'utf-8');
|
|
26
|
+
expect(findShadowedUserAdapters({ userClisDir, builtinClisDir })).toEqual([
|
|
27
|
+
{
|
|
28
|
+
name: 'instagram/saved',
|
|
29
|
+
userPath: path.join(userClisDir, 'instagram', 'saved.js'),
|
|
30
|
+
builtinPath: path.join(builtinClisDir, 'instagram', 'saved.js'),
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it('formats a concise doctor issue', () => {
|
|
39
|
+
const issue = formatAdapterShadowIssue([
|
|
40
|
+
{
|
|
41
|
+
name: 'instagram/saved',
|
|
42
|
+
userPath: '/home/me/.opencli/clis/instagram/saved.js',
|
|
43
|
+
builtinPath: '/pkg/clis/instagram/saved.js',
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
expect(issue).toContain('instagram/saved');
|
|
47
|
+
expect(issue).toContain('opencli adapter reset <site>');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -47,8 +47,12 @@ export declare abstract class BasePage implements IPage {
|
|
|
47
47
|
abstract tabs(): Promise<unknown[]>;
|
|
48
48
|
abstract selectTab(target: number | string): Promise<void>;
|
|
49
49
|
click(ref: string, opts?: ResolveOptions): Promise<ResolveSuccess>;
|
|
50
|
-
/**
|
|
51
|
-
protected tryNativeClick(
|
|
50
|
+
/** Uses native CDP click support when the concrete page exposes it. */
|
|
51
|
+
protected tryNativeClick(x: number, y: number): Promise<boolean>;
|
|
52
|
+
/** Uses native CDP text insertion when the concrete page exposes it. */
|
|
53
|
+
protected tryNativeType(text: string): Promise<boolean>;
|
|
54
|
+
/** Uses native CDP key events when the concrete page exposes them. */
|
|
55
|
+
protected tryNativeKeyPress(key: string, modifiers: string[]): Promise<boolean>;
|
|
52
56
|
typeText(ref: string, text: string, opts?: ResolveOptions): Promise<ResolveSuccess>;
|
|
53
57
|
pressKey(key: string): Promise<void>;
|
|
54
58
|
scrollTo(ref: string, opts?: ResolveOptions): Promise<unknown>;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { generateSnapshotJs, getFormStateJs } from './dom-snapshot.js';
|
|
12
12
|
import { pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
|
|
13
|
-
import { resolveTargetJs, clickResolvedJs, typeResolvedJs, scrollResolvedJs, } from './target-resolver.js';
|
|
13
|
+
import { resolveTargetJs, clickResolvedJs, typeResolvedJs, prepareNativeTypeResolvedJs, scrollResolvedJs, } from './target-resolver.js';
|
|
14
14
|
import { TargetError } from './target-errors.js';
|
|
15
15
|
import { CliError } from '../errors.js';
|
|
16
16
|
import { formatSnapshot } from '../snapshotFormatter.js';
|
|
@@ -36,6 +36,27 @@ function previewText(text) {
|
|
|
36
36
|
const preview = (text ?? '').replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
37
37
|
return preview ? `Response preview: ${preview}` : undefined;
|
|
38
38
|
}
|
|
39
|
+
function parseKeyChord(rawKey) {
|
|
40
|
+
const parts = rawKey.split('+').map(part => part.trim()).filter(Boolean);
|
|
41
|
+
if (parts.length <= 1)
|
|
42
|
+
return { key: rawKey, modifiers: [] };
|
|
43
|
+
const modifiers = [];
|
|
44
|
+
for (const token of parts.slice(0, -1)) {
|
|
45
|
+
const normalized = token.toLowerCase();
|
|
46
|
+
if (normalized === 'ctrl' || normalized === 'control')
|
|
47
|
+
modifiers.push('Ctrl');
|
|
48
|
+
else if (normalized === 'cmd' || normalized === 'command' || normalized === 'meta')
|
|
49
|
+
modifiers.push('Meta');
|
|
50
|
+
else if (normalized === 'option' || normalized === 'alt')
|
|
51
|
+
modifiers.push('Alt');
|
|
52
|
+
else if (normalized === 'shift')
|
|
53
|
+
modifiers.push('Shift');
|
|
54
|
+
else
|
|
55
|
+
return { key: rawKey, modifiers: [] };
|
|
56
|
+
}
|
|
57
|
+
const key = parts.at(-1);
|
|
58
|
+
return key ? { key, modifiers } : { key: rawKey, modifiers: [] };
|
|
59
|
+
}
|
|
39
60
|
export class BasePage {
|
|
40
61
|
_lastUrl = null;
|
|
41
62
|
/** Cached previous snapshot hashes for incremental diff marking */
|
|
@@ -147,17 +168,78 @@ export class BasePage {
|
|
|
147
168
|
}
|
|
148
169
|
throw new Error(`Click failed: ${result.error ?? 'JS click and CDP fallback both failed'}`);
|
|
149
170
|
}
|
|
150
|
-
/**
|
|
151
|
-
async tryNativeClick(
|
|
152
|
-
|
|
171
|
+
/** Uses native CDP click support when the concrete page exposes it. */
|
|
172
|
+
async tryNativeClick(x, y) {
|
|
173
|
+
const nativeClick = this.nativeClick;
|
|
174
|
+
if (typeof nativeClick !== 'function')
|
|
175
|
+
return false;
|
|
176
|
+
try {
|
|
177
|
+
await nativeClick.call(this, x, y);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Uses native CDP text insertion when the concrete page exposes it. */
|
|
185
|
+
async tryNativeType(text) {
|
|
186
|
+
const nativeType = this.nativeType;
|
|
187
|
+
if (typeof nativeType === 'function') {
|
|
188
|
+
try {
|
|
189
|
+
await nativeType.call(this, text);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// Fall through to the older dedicated insertText primitive if present.
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const insertText = this.insertText;
|
|
197
|
+
if (typeof insertText !== 'function')
|
|
198
|
+
return false;
|
|
199
|
+
try {
|
|
200
|
+
await insertText.call(this, text);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Uses native CDP key events when the concrete page exposes them. */
|
|
208
|
+
async tryNativeKeyPress(key, modifiers) {
|
|
209
|
+
const nativeKeyPress = this.nativeKeyPress;
|
|
210
|
+
if (typeof nativeKeyPress !== 'function')
|
|
211
|
+
return false;
|
|
212
|
+
try {
|
|
213
|
+
await nativeKeyPress.call(this, key, modifiers);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
153
219
|
}
|
|
154
220
|
async typeText(ref, text, opts = {}) {
|
|
155
221
|
const resolved = await runResolve(this, ref, opts);
|
|
156
|
-
|
|
222
|
+
let typed = false;
|
|
223
|
+
if (typeof this.nativeType === 'function' || typeof this.insertText === 'function') {
|
|
224
|
+
try {
|
|
225
|
+
const preparation = await this.evaluate(prepareNativeTypeResolvedJs());
|
|
226
|
+
typed = preparation?.ok === true && await this.tryNativeType(text);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// Native input is a reliability upgrade, not the only path. Preserve
|
|
230
|
+
// the existing DOM setter fallback if preparation fails.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!typed) {
|
|
234
|
+
await this.evaluate(typeResolvedJs(text));
|
|
235
|
+
}
|
|
157
236
|
return resolved;
|
|
158
237
|
}
|
|
159
238
|
async pressKey(key) {
|
|
160
|
-
|
|
239
|
+
const parsed = parseKeyChord(key);
|
|
240
|
+
if (!await this.tryNativeKeyPress(parsed.key, parsed.modifiers)) {
|
|
241
|
+
await this.evaluate(pressKeyJs(parsed.key, parsed.modifiers));
|
|
242
|
+
}
|
|
161
243
|
}
|
|
162
244
|
async scrollTo(ref, opts = {}) {
|
|
163
245
|
const resolved = await runResolve(this, ref, opts);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { CliError } from '../errors.js';
|
|
3
3
|
import { BasePage } from './base-page.js';
|
|
4
4
|
class TestPage extends BasePage {
|
|
@@ -15,6 +15,23 @@ class TestPage extends BasePage {
|
|
|
15
15
|
async tabs() { return []; }
|
|
16
16
|
async selectTab() { }
|
|
17
17
|
}
|
|
18
|
+
class ActionPage extends BasePage {
|
|
19
|
+
results = [];
|
|
20
|
+
scripts = [];
|
|
21
|
+
nativeType;
|
|
22
|
+
insertText;
|
|
23
|
+
nativeKeyPress;
|
|
24
|
+
async goto() { }
|
|
25
|
+
async evaluate(js) {
|
|
26
|
+
this.scripts.push(js);
|
|
27
|
+
return this.results.shift() ?? null;
|
|
28
|
+
}
|
|
29
|
+
async getCookies() { return []; }
|
|
30
|
+
async screenshot() { return ''; }
|
|
31
|
+
async tabs() { return []; }
|
|
32
|
+
async selectTab() { }
|
|
33
|
+
}
|
|
34
|
+
const resolveOk = { ok: true, matches_n: 1, match_level: 'exact' };
|
|
18
35
|
describe('BasePage.fetchJson', () => {
|
|
19
36
|
it('passes a narrow browser-context JSON request and parses the response in Node', async () => {
|
|
20
37
|
const page = new TestPage();
|
|
@@ -72,3 +89,46 @@ describe('BasePage.fetchJson', () => {
|
|
|
72
89
|
});
|
|
73
90
|
});
|
|
74
91
|
});
|
|
92
|
+
describe('BasePage native input routing', () => {
|
|
93
|
+
it('types rich-editor text via native Input.insertText when available', async () => {
|
|
94
|
+
const page = new ActionPage();
|
|
95
|
+
page.nativeType = vi.fn().mockResolvedValue(undefined);
|
|
96
|
+
page.results = [resolveOk, { ok: true, mode: 'contenteditable' }];
|
|
97
|
+
await expect(page.typeText('#editor', 'hello')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });
|
|
98
|
+
expect(page.nativeType).toHaveBeenCalledWith('hello');
|
|
99
|
+
expect(page.scripts).toHaveLength(2);
|
|
100
|
+
expect(page.scripts[1]).toContain('nearestContentEditableHost');
|
|
101
|
+
expect(page.scripts.join('\n')).not.toContain("return 'typed'");
|
|
102
|
+
});
|
|
103
|
+
it('keeps the DOM setter fallback when native text insertion is unavailable', async () => {
|
|
104
|
+
const page = new ActionPage();
|
|
105
|
+
page.results = [resolveOk, 'typed'];
|
|
106
|
+
await page.typeText('#q', 'hello');
|
|
107
|
+
expect(page.scripts).toHaveLength(2);
|
|
108
|
+
expect(page.scripts[1]).toContain('document.execCommand');
|
|
109
|
+
expect(page.scripts[1]).toContain("return 'typed'");
|
|
110
|
+
});
|
|
111
|
+
it('falls back to DOM typing if native text insertion fails', async () => {
|
|
112
|
+
const page = new ActionPage();
|
|
113
|
+
page.nativeType = vi.fn().mockRejectedValue(new Error('native failed'));
|
|
114
|
+
page.results = [resolveOk, { ok: true, mode: 'input' }, 'typed'];
|
|
115
|
+
await page.typeText('#q', 'hello');
|
|
116
|
+
expect(page.nativeType).toHaveBeenCalledWith('hello');
|
|
117
|
+
expect(page.scripts).toHaveLength(3);
|
|
118
|
+
expect(page.scripts[2]).toContain("return 'typed'");
|
|
119
|
+
});
|
|
120
|
+
it('presses key chords through native CDP key events when available', async () => {
|
|
121
|
+
const page = new ActionPage();
|
|
122
|
+
page.nativeKeyPress = vi.fn().mockResolvedValue(undefined);
|
|
123
|
+
await page.pressKey('Control+a');
|
|
124
|
+
expect(page.nativeKeyPress).toHaveBeenCalledWith('a', ['Ctrl']);
|
|
125
|
+
expect(page.scripts).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
it('falls back to synthetic keyboard events with parsed modifiers', async () => {
|
|
128
|
+
const page = new ActionPage();
|
|
129
|
+
await page.pressKey('Meta+N');
|
|
130
|
+
expect(page.scripts).toHaveLength(1);
|
|
131
|
+
expect(page.scripts[0]).toContain('key: "N"');
|
|
132
|
+
expect(page.scripts[0]).toContain('metaKey: true');
|
|
133
|
+
});
|
|
134
|
+
});
|
package/dist/src/browser/cdp.js
CHANGED
|
@@ -317,6 +317,54 @@ class CDPPage extends BasePage {
|
|
|
317
317
|
async selectTab(_target) {
|
|
318
318
|
// Not supported in direct CDP mode
|
|
319
319
|
}
|
|
320
|
+
async cdp(method, params = {}) {
|
|
321
|
+
return this.bridge.send(method, params);
|
|
322
|
+
}
|
|
323
|
+
async nativeClick(x, y) {
|
|
324
|
+
await this.cdp('Input.dispatchMouseEvent', {
|
|
325
|
+
type: 'mousePressed',
|
|
326
|
+
x,
|
|
327
|
+
y,
|
|
328
|
+
button: 'left',
|
|
329
|
+
clickCount: 1,
|
|
330
|
+
});
|
|
331
|
+
await this.cdp('Input.dispatchMouseEvent', {
|
|
332
|
+
type: 'mouseReleased',
|
|
333
|
+
x,
|
|
334
|
+
y,
|
|
335
|
+
button: 'left',
|
|
336
|
+
clickCount: 1,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
async nativeType(text) {
|
|
340
|
+
await this.cdp('Input.insertText', { text });
|
|
341
|
+
}
|
|
342
|
+
async insertText(text) {
|
|
343
|
+
await this.nativeType(text);
|
|
344
|
+
}
|
|
345
|
+
async nativeKeyPress(key, modifiers = []) {
|
|
346
|
+
let modifierFlags = 0;
|
|
347
|
+
for (const mod of modifiers) {
|
|
348
|
+
if (mod === 'Alt')
|
|
349
|
+
modifierFlags |= 1;
|
|
350
|
+
if (mod === 'Ctrl' || mod === 'Control')
|
|
351
|
+
modifierFlags |= 2;
|
|
352
|
+
if (mod === 'Meta')
|
|
353
|
+
modifierFlags |= 4;
|
|
354
|
+
if (mod === 'Shift')
|
|
355
|
+
modifierFlags |= 8;
|
|
356
|
+
}
|
|
357
|
+
await this.cdp('Input.dispatchKeyEvent', {
|
|
358
|
+
type: 'keyDown',
|
|
359
|
+
key,
|
|
360
|
+
modifiers: modifierFlags,
|
|
361
|
+
});
|
|
362
|
+
await this.cdp('Input.dispatchKeyEvent', {
|
|
363
|
+
type: 'keyUp',
|
|
364
|
+
key,
|
|
365
|
+
modifiers: modifierFlags,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
320
368
|
}
|
|
321
369
|
function isCookie(value) {
|
|
322
370
|
return isRecord(value)
|
|
@@ -49,4 +49,27 @@ describe('CDPBridge cookies', () => {
|
|
|
49
49
|
{ name: 'exact', value: '2', domain: 'example.com' },
|
|
50
50
|
]);
|
|
51
51
|
});
|
|
52
|
+
it('exposes native input helpers on direct CDP pages', async () => {
|
|
53
|
+
vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1');
|
|
54
|
+
const bridge = new CDPBridge();
|
|
55
|
+
const send = vi.spyOn(bridge, 'send').mockResolvedValue({});
|
|
56
|
+
const page = await bridge.connect();
|
|
57
|
+
send.mockClear();
|
|
58
|
+
expect(page.nativeType).toBeTypeOf('function');
|
|
59
|
+
expect(page.nativeKeyPress).toBeTypeOf('function');
|
|
60
|
+
expect(page.nativeClick).toBeTypeOf('function');
|
|
61
|
+
expect(page.cdp).toBeTypeOf('function');
|
|
62
|
+
await page.nativeType('hello');
|
|
63
|
+
await page.nativeKeyPress('a', ['Ctrl']);
|
|
64
|
+
await page.nativeClick(10, 20);
|
|
65
|
+
await page.cdp('Page.getLayoutMetrics', {});
|
|
66
|
+
expect(send.mock.calls).toEqual([
|
|
67
|
+
['Input.insertText', { text: 'hello' }],
|
|
68
|
+
['Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', modifiers: 2 }],
|
|
69
|
+
['Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', modifiers: 2 }],
|
|
70
|
+
['Input.dispatchMouseEvent', { type: 'mousePressed', x: 10, y: 20, button: 'left', clickCount: 1 }],
|
|
71
|
+
['Input.dispatchMouseEvent', { type: 'mouseReleased', x: 10, y: 20, button: 'left', clickCount: 1 }],
|
|
72
|
+
['Page.getLayoutMetrics', {}],
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
52
75
|
});
|
|
@@ -11,7 +11,7 @@ export declare function clickJs(ref: string): string;
|
|
|
11
11
|
* Uses native setter for React compat + execCommand for contenteditable. */
|
|
12
12
|
export declare function typeTextJs(ref: string, text: string): string;
|
|
13
13
|
/** Generate JS to press a keyboard key */
|
|
14
|
-
export declare function pressKeyJs(key: string): string;
|
|
14
|
+
export declare function pressKeyJs(key: string, modifiers?: string[]): string;
|
|
15
15
|
/** Generate JS to wait for text to appear in the page */
|
|
16
16
|
export declare function waitForTextJs(text: string, timeoutMs: number): string;
|
|
17
17
|
/** Generate JS for scroll */
|
|
@@ -80,12 +80,24 @@ export function typeTextJs(ref, text) {
|
|
|
80
80
|
`;
|
|
81
81
|
}
|
|
82
82
|
/** Generate JS to press a keyboard key */
|
|
83
|
-
export function pressKeyJs(key) {
|
|
83
|
+
export function pressKeyJs(key, modifiers = []) {
|
|
84
|
+
const hasCtrl = modifiers.includes('Ctrl') || modifiers.includes('Control');
|
|
85
|
+
const hasAlt = modifiers.includes('Alt');
|
|
86
|
+
const hasMeta = modifiers.includes('Meta');
|
|
87
|
+
const hasShift = modifiers.includes('Shift');
|
|
84
88
|
return `
|
|
85
89
|
(() => {
|
|
86
90
|
const el = document.activeElement || document.body;
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
const init = {
|
|
92
|
+
key: ${JSON.stringify(key)},
|
|
93
|
+
bubbles: true,
|
|
94
|
+
ctrlKey: ${hasCtrl},
|
|
95
|
+
altKey: ${hasAlt},
|
|
96
|
+
metaKey: ${hasMeta},
|
|
97
|
+
shiftKey: ${hasShift},
|
|
98
|
+
};
|
|
99
|
+
el.dispatchEvent(new KeyboardEvent('keydown', init));
|
|
100
|
+
el.dispatchEvent(new KeyboardEvent('keyup', init));
|
|
89
101
|
return 'pressed';
|
|
90
102
|
})()
|
|
91
103
|
`;
|