@jackwener/opencli 1.7.4 → 1.7.6
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 +76 -51
- package/README.zh-CN.md +78 -62
- package/cli-manifest.json +4558 -2979
- package/clis/antigravity/serve.js +71 -25
- package/clis/baidu-scholar/search.js +87 -0
- package/clis/baidu-scholar/search.test.js +23 -0
- package/clis/bilibili/video.js +61 -0
- package/clis/bilibili/video.test.js +81 -0
- package/clis/deepseek/ask.js +94 -0
- package/clis/deepseek/ask.test.js +73 -0
- package/clis/deepseek/history.js +25 -0
- package/clis/deepseek/new.js +20 -0
- package/clis/deepseek/read.js +22 -0
- package/clis/deepseek/status.js +24 -0
- package/clis/deepseek/utils.js +291 -0
- package/clis/deepseek/utils.test.js +37 -0
- package/clis/eastmoney/_secid.js +78 -0
- package/clis/eastmoney/announcement.js +52 -0
- package/clis/eastmoney/convertible.js +73 -0
- package/clis/eastmoney/etf.js +65 -0
- package/clis/eastmoney/holders.js +78 -0
- package/clis/eastmoney/index-board.js +96 -0
- package/clis/eastmoney/kline.js +87 -0
- package/clis/eastmoney/kuaixun.js +54 -0
- package/clis/eastmoney/longhu.js +67 -0
- package/clis/eastmoney/money-flow.js +78 -0
- package/clis/eastmoney/northbound.js +57 -0
- package/clis/eastmoney/quote.js +107 -0
- package/clis/eastmoney/rank.js +94 -0
- package/clis/eastmoney/sectors.js +76 -0
- package/clis/google-scholar/search.js +58 -0
- package/clis/google-scholar/search.test.js +23 -0
- package/clis/gov-law/commands.test.js +39 -0
- package/clis/gov-law/recent.js +22 -0
- package/clis/gov-law/search.js +41 -0
- package/clis/gov-law/shared.js +51 -0
- package/clis/gov-policy/commands.test.js +27 -0
- package/clis/gov-policy/recent.js +47 -0
- package/clis/gov-policy/search.js +48 -0
- package/clis/jianyu/search.js +139 -3
- package/clis/jianyu/search.test.js +25 -0
- package/clis/jianyu/shared/procurement-detail.js +15 -0
- package/clis/jianyu/shared/procurement-detail.test.js +12 -0
- package/clis/nowcoder/companies.js +23 -0
- package/clis/nowcoder/creators.js +27 -0
- package/clis/nowcoder/detail.js +61 -0
- package/clis/nowcoder/experience.js +36 -0
- package/clis/nowcoder/hot.js +24 -0
- package/clis/nowcoder/jobs.js +21 -0
- package/clis/nowcoder/notifications.js +29 -0
- package/clis/nowcoder/papers.js +40 -0
- package/clis/nowcoder/practice.js +37 -0
- package/clis/nowcoder/recommend.js +30 -0
- package/clis/nowcoder/referral.js +39 -0
- package/clis/nowcoder/salary.js +40 -0
- package/clis/nowcoder/search.js +49 -0
- package/clis/nowcoder/suggest.js +33 -0
- package/clis/nowcoder/topics.js +27 -0
- package/clis/nowcoder/trending.js +25 -0
- package/clis/twitter/list-add.js +337 -0
- package/clis/twitter/list-add.test.js +15 -0
- package/clis/twitter/list-remove.js +297 -0
- package/clis/twitter/list-remove.test.js +14 -0
- package/clis/twitter/list-tweets.js +185 -0
- package/clis/twitter/list-tweets.test.js +108 -0
- package/clis/twitter/lists.js +134 -47
- package/clis/twitter/lists.test.js +105 -38
- package/clis/twitter/shared.js +7 -2
- package/clis/twitter/tweets.js +218 -0
- package/clis/twitter/tweets.test.js +125 -0
- package/clis/wanfang/search.js +66 -0
- package/clis/wanfang/search.test.js +23 -0
- package/clis/web/read.js +1 -1
- package/clis/weixin/download.js +3 -2
- package/clis/xiaohongshu/publish.js +149 -28
- package/clis/xiaohongshu/publish.test.js +319 -6
- package/clis/xiaoyuzhou/download.js +8 -4
- package/clis/xiaoyuzhou/download.test.js +23 -13
- package/clis/xiaoyuzhou/episode.js +9 -4
- package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
- package/clis/xiaoyuzhou/podcast.js +9 -4
- package/clis/xiaoyuzhou/utils.js +0 -40
- package/clis/xiaoyuzhou/utils.test.js +15 -75
- package/clis/youtube/channel.js +35 -0
- package/clis/zsxq/dynamics.js +1 -1
- package/clis/zsxq/utils.js +6 -3
- package/clis/zsxq/utils.test.js +31 -0
- package/dist/src/browser/base-page.d.ts +14 -4
- package/dist/src/browser/base-page.js +35 -25
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +13 -4
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.js +175 -0
- package/dist/src/browser/daemon-client.d.ts +6 -4
- package/dist/src/browser/daemon-client.js +6 -1
- package/dist/src/browser/daemon-client.test.js +40 -1
- package/dist/src/browser/dom-snapshot.d.ts +7 -0
- package/dist/src/browser/dom-snapshot.js +83 -5
- package/dist/src/browser/dom-snapshot.test.js +65 -0
- package/dist/src/browser/extract.d.ts +69 -0
- package/dist/src/browser/extract.js +132 -0
- package/dist/src/browser/extract.test.js +129 -0
- package/dist/src/browser/find.d.ts +76 -0
- package/dist/src/browser/find.js +179 -0
- package/dist/src/browser/find.test.js +120 -0
- package/dist/src/browser/html-tree.d.ts +75 -0
- package/dist/src/browser/html-tree.js +112 -0
- package/dist/src/browser/html-tree.test.d.ts +1 -0
- package/dist/src/browser/html-tree.test.js +181 -0
- package/dist/src/browser/network-cache.d.ts +48 -0
- package/dist/src/browser/network-cache.js +66 -0
- package/dist/src/browser/network-cache.test.d.ts +1 -0
- package/dist/src/browser/network-cache.test.js +58 -0
- package/dist/src/browser/network-key.d.ts +22 -0
- package/dist/src/browser/network-key.js +66 -0
- package/dist/src/browser/network-key.test.d.ts +1 -0
- package/dist/src/browser/network-key.test.js +49 -0
- package/dist/src/browser/page.d.ts +14 -4
- package/dist/src/browser/page.js +48 -7
- package/dist/src/browser/page.test.js +97 -0
- package/dist/src/browser/shape-filter.d.ts +52 -0
- package/dist/src/browser/shape-filter.js +101 -0
- package/dist/src/browser/shape-filter.test.d.ts +1 -0
- package/dist/src/browser/shape-filter.test.js +101 -0
- package/dist/src/browser/shape.d.ts +23 -0
- package/dist/src/browser/shape.js +95 -0
- package/dist/src/browser/shape.test.d.ts +1 -0
- package/dist/src/browser/shape.test.js +82 -0
- package/dist/src/browser/target-errors.d.ts +14 -1
- package/dist/src/browser/target-errors.js +13 -0
- package/dist/src/browser/target-errors.test.js +39 -6
- package/dist/src/browser/target-resolver.d.ts +57 -10
- package/dist/src/browser/target-resolver.js +195 -75
- package/dist/src/browser/target-resolver.test.js +80 -5
- package/dist/src/cli.js +849 -267
- package/dist/src/cli.test.js +961 -90
- package/dist/src/commanderAdapter.d.ts +0 -1
- package/dist/src/commanderAdapter.js +2 -16
- package/dist/src/commanderAdapter.test.js +1 -1
- package/dist/src/completion-shared.js +2 -5
- package/dist/src/daemon.js +8 -0
- package/dist/src/download/article-download.d.ts +1 -0
- package/dist/src/download/article-download.js +3 -0
- package/dist/src/download/article-download.test.d.ts +1 -0
- package/dist/src/download/article-download.test.js +39 -0
- package/dist/src/execution.js +7 -2
- package/dist/src/execution.test.js +54 -0
- package/dist/src/main.js +16 -0
- package/dist/src/plugin.d.ts +1 -8
- package/dist/src/plugin.js +1 -27
- package/dist/src/plugin.test.js +1 -59
- package/dist/src/registry.d.ts +1 -0
- package/dist/src/registry.js +3 -2
- package/dist/src/registry.test.js +22 -0
- package/dist/src/types.d.ts +32 -8
- package/package.json +1 -1
- package/clis/twitter/lists-parser.js +0 -77
- package/clis/twitter/lists.d.ts +0 -5
- package/dist/src/cascade.d.ts +0 -46
- package/dist/src/cascade.js +0 -135
- package/dist/src/explore.d.ts +0 -99
- package/dist/src/explore.js +0 -402
- package/dist/src/generate-verified.d.ts +0 -105
- package/dist/src/generate-verified.js +0 -696
- package/dist/src/generate-verified.test.js +0 -925
- package/dist/src/generate.d.ts +0 -46
- package/dist/src/generate.js +0 -117
- package/dist/src/record.d.ts +0 -96
- package/dist/src/record.js +0 -657
- package/dist/src/record.test.js +0 -293
- package/dist/src/skill-generate.d.ts +0 -30
- package/dist/src/skill-generate.js +0 -75
- package/dist/src/skill-generate.test.js +0 -173
- package/dist/src/synthesize.d.ts +0 -97
- package/dist/src/synthesize.js +0 -208
- /package/dist/src/{generate-verified.test.d.ts → browser/compound.test.d.ts} +0 -0
- /package/dist/src/{record.test.d.ts → browser/extract.test.d.ts} +0 -0
- /package/dist/src/{skill-generate.test.d.ts → browser/find.test.d.ts} +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compound-component expansion for high-agent-failure form controls.
|
|
3
|
+
*
|
|
4
|
+
* Agents burn turns on three recurring input categories because the raw
|
|
5
|
+
* attribute dump from `browser state` under-specifies them:
|
|
6
|
+
*
|
|
7
|
+
* - date / time / datetime-local / month / week — agents type
|
|
8
|
+
* free-form strings and the browser silently ignores mismatched formats.
|
|
9
|
+
* - select — the snapshot caps visible options at ~6; agents don't know
|
|
10
|
+
* the full option set, can't match by label, and waste turns clicking
|
|
11
|
+
* to open the dropdown just to read options.
|
|
12
|
+
* - file — the snapshot shows current filenames but not `accept` or
|
|
13
|
+
* `multiple`; agents re-upload or pick unsupported MIME types.
|
|
14
|
+
*
|
|
15
|
+
* `compoundInfoOf(el)` returns a structured JSON summary agents can rely
|
|
16
|
+
* on. Included in `browser find --css` envelope so the agent gets the
|
|
17
|
+
* rich view without extra round-trips.
|
|
18
|
+
*
|
|
19
|
+
* Emitted as a JS source string (`COMPOUND_INFO_JS`) so it can be inlined
|
|
20
|
+
* into the generated evaluate scripts under find / snapshot / eval.
|
|
21
|
+
*/
|
|
22
|
+
export type DateLikeControl = 'date' | 'time' | 'datetime-local' | 'month' | 'week';
|
|
23
|
+
export interface DateCompound {
|
|
24
|
+
control: DateLikeControl;
|
|
25
|
+
format: string;
|
|
26
|
+
current: string;
|
|
27
|
+
min?: string;
|
|
28
|
+
max?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface SelectOption {
|
|
31
|
+
label: string;
|
|
32
|
+
value: string;
|
|
33
|
+
selected: boolean;
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface SelectCompound {
|
|
37
|
+
control: 'select';
|
|
38
|
+
multiple: boolean;
|
|
39
|
+
current: string | string[];
|
|
40
|
+
options: SelectOption[];
|
|
41
|
+
options_total: number;
|
|
42
|
+
}
|
|
43
|
+
export interface FileCompound {
|
|
44
|
+
control: 'file';
|
|
45
|
+
multiple: boolean;
|
|
46
|
+
current: string[];
|
|
47
|
+
accept?: string;
|
|
48
|
+
}
|
|
49
|
+
export type CompoundInfo = DateCompound | SelectCompound | FileCompound;
|
|
50
|
+
/** Max options included in a SelectCompound.options[]. Above this, `options_total` still reflects the true count. */
|
|
51
|
+
export declare const COMPOUND_SELECT_OPTIONS_CAP = 50;
|
|
52
|
+
/** Max characters per option label / file name. */
|
|
53
|
+
export declare const COMPOUND_LABEL_CAP = 80;
|
|
54
|
+
/**
|
|
55
|
+
* JavaScript source declaring `compoundInfoOf(el)`. Inlined into the JS
|
|
56
|
+
* emitted by `buildFindJs` (and any other evaluate script that needs the
|
|
57
|
+
* rich compound view). Returns a `CompoundInfo` object or `null`.
|
|
58
|
+
*/
|
|
59
|
+
export declare const COMPOUND_INFO_JS = "\nfunction compoundInfoOf(el) {\n if (!el || !el.tagName) return null;\n const tag = el.tagName;\n const LABEL_CAP = 80;\n const OPTS_CAP = 50;\n if (tag === 'INPUT') {\n const type = (el.getAttribute('type') || 'text').toLowerCase();\n const FORMATS = {\n 'date': 'YYYY-MM-DD',\n 'time': 'HH:MM',\n 'datetime-local': 'YYYY-MM-DDTHH:MM',\n 'month': 'YYYY-MM',\n 'week': 'YYYY-W##',\n };\n if (FORMATS[type]) {\n const info = {\n control: type,\n format: FORMATS[type],\n current: (el.value == null ? '' : String(el.value)),\n };\n const min = el.getAttribute('min');\n if (min) info.min = min;\n const max = el.getAttribute('max');\n if (max) info.max = max;\n return info;\n }\n if (type === 'file') {\n const info = {\n control: 'file',\n multiple: !!el.multiple,\n current: [],\n };\n const accept = el.getAttribute('accept');\n if (accept) info.accept = accept;\n try {\n if (el.files && el.files.length) {\n for (let i = 0; i < el.files.length; i++) {\n const name = (el.files[i].name || '').slice(0, LABEL_CAP);\n info.current.push(name);\n }\n }\n } catch (_) {}\n return info;\n }\n return null;\n }\n if (tag === 'SELECT') {\n const multiple = !!el.multiple;\n const options = [];\n const selectedLabels = [];\n let total = 0;\n try {\n const opts = el.options || [];\n total = opts.length;\n // Walk ALL options so `current` reflects selections that sit beyond the\n // serialization cap. Only the first OPTS_CAP entries get pushed into\n // options[]; anything past the cap still contributes to selectedLabels\n // so agents see the true current state of big dropdowns.\n for (let i = 0; i < opts.length; i++) {\n const o = opts[i];\n const labelRaw = (o.label != null && o.label !== '') ? o.label : (o.text || '');\n const label = String(labelRaw).trim().slice(0, LABEL_CAP);\n if (i < OPTS_CAP) {\n const entry = { label: label, value: o.value, selected: !!o.selected };\n if (o.disabled) entry.disabled = true;\n options.push(entry);\n }\n if (o.selected) selectedLabels.push(label);\n }\n } catch (_) {}\n return {\n control: 'select',\n multiple: multiple,\n current: multiple ? selectedLabels : (selectedLabels[0] || ''),\n options: options,\n options_total: total,\n };\n }\n return null;\n}\n";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compound-component expansion for high-agent-failure form controls.
|
|
3
|
+
*
|
|
4
|
+
* Agents burn turns on three recurring input categories because the raw
|
|
5
|
+
* attribute dump from `browser state` under-specifies them:
|
|
6
|
+
*
|
|
7
|
+
* - date / time / datetime-local / month / week — agents type
|
|
8
|
+
* free-form strings and the browser silently ignores mismatched formats.
|
|
9
|
+
* - select — the snapshot caps visible options at ~6; agents don't know
|
|
10
|
+
* the full option set, can't match by label, and waste turns clicking
|
|
11
|
+
* to open the dropdown just to read options.
|
|
12
|
+
* - file — the snapshot shows current filenames but not `accept` or
|
|
13
|
+
* `multiple`; agents re-upload or pick unsupported MIME types.
|
|
14
|
+
*
|
|
15
|
+
* `compoundInfoOf(el)` returns a structured JSON summary agents can rely
|
|
16
|
+
* on. Included in `browser find --css` envelope so the agent gets the
|
|
17
|
+
* rich view without extra round-trips.
|
|
18
|
+
*
|
|
19
|
+
* Emitted as a JS source string (`COMPOUND_INFO_JS`) so it can be inlined
|
|
20
|
+
* into the generated evaluate scripts under find / snapshot / eval.
|
|
21
|
+
*/
|
|
22
|
+
/** Max options included in a SelectCompound.options[]. Above this, `options_total` still reflects the true count. */
|
|
23
|
+
export const COMPOUND_SELECT_OPTIONS_CAP = 50;
|
|
24
|
+
/** Max characters per option label / file name. */
|
|
25
|
+
export const COMPOUND_LABEL_CAP = 80;
|
|
26
|
+
/**
|
|
27
|
+
* JavaScript source declaring `compoundInfoOf(el)`. Inlined into the JS
|
|
28
|
+
* emitted by `buildFindJs` (and any other evaluate script that needs the
|
|
29
|
+
* rich compound view). Returns a `CompoundInfo` object or `null`.
|
|
30
|
+
*/
|
|
31
|
+
export const COMPOUND_INFO_JS = `
|
|
32
|
+
function compoundInfoOf(el) {
|
|
33
|
+
if (!el || !el.tagName) return null;
|
|
34
|
+
const tag = el.tagName;
|
|
35
|
+
const LABEL_CAP = ${COMPOUND_LABEL_CAP};
|
|
36
|
+
const OPTS_CAP = ${COMPOUND_SELECT_OPTIONS_CAP};
|
|
37
|
+
if (tag === 'INPUT') {
|
|
38
|
+
const type = (el.getAttribute('type') || 'text').toLowerCase();
|
|
39
|
+
const FORMATS = {
|
|
40
|
+
'date': 'YYYY-MM-DD',
|
|
41
|
+
'time': 'HH:MM',
|
|
42
|
+
'datetime-local': 'YYYY-MM-DDTHH:MM',
|
|
43
|
+
'month': 'YYYY-MM',
|
|
44
|
+
'week': 'YYYY-W##',
|
|
45
|
+
};
|
|
46
|
+
if (FORMATS[type]) {
|
|
47
|
+
const info = {
|
|
48
|
+
control: type,
|
|
49
|
+
format: FORMATS[type],
|
|
50
|
+
current: (el.value == null ? '' : String(el.value)),
|
|
51
|
+
};
|
|
52
|
+
const min = el.getAttribute('min');
|
|
53
|
+
if (min) info.min = min;
|
|
54
|
+
const max = el.getAttribute('max');
|
|
55
|
+
if (max) info.max = max;
|
|
56
|
+
return info;
|
|
57
|
+
}
|
|
58
|
+
if (type === 'file') {
|
|
59
|
+
const info = {
|
|
60
|
+
control: 'file',
|
|
61
|
+
multiple: !!el.multiple,
|
|
62
|
+
current: [],
|
|
63
|
+
};
|
|
64
|
+
const accept = el.getAttribute('accept');
|
|
65
|
+
if (accept) info.accept = accept;
|
|
66
|
+
try {
|
|
67
|
+
if (el.files && el.files.length) {
|
|
68
|
+
for (let i = 0; i < el.files.length; i++) {
|
|
69
|
+
const name = (el.files[i].name || '').slice(0, LABEL_CAP);
|
|
70
|
+
info.current.push(name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch (_) {}
|
|
74
|
+
return info;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (tag === 'SELECT') {
|
|
79
|
+
const multiple = !!el.multiple;
|
|
80
|
+
const options = [];
|
|
81
|
+
const selectedLabels = [];
|
|
82
|
+
let total = 0;
|
|
83
|
+
try {
|
|
84
|
+
const opts = el.options || [];
|
|
85
|
+
total = opts.length;
|
|
86
|
+
// Walk ALL options so \`current\` reflects selections that sit beyond the
|
|
87
|
+
// serialization cap. Only the first OPTS_CAP entries get pushed into
|
|
88
|
+
// options[]; anything past the cap still contributes to selectedLabels
|
|
89
|
+
// so agents see the true current state of big dropdowns.
|
|
90
|
+
for (let i = 0; i < opts.length; i++) {
|
|
91
|
+
const o = opts[i];
|
|
92
|
+
const labelRaw = (o.label != null && o.label !== '') ? o.label : (o.text || '');
|
|
93
|
+
const label = String(labelRaw).trim().slice(0, LABEL_CAP);
|
|
94
|
+
if (i < OPTS_CAP) {
|
|
95
|
+
const entry = { label: label, value: o.value, selected: !!o.selected };
|
|
96
|
+
if (o.disabled) entry.disabled = true;
|
|
97
|
+
options.push(entry);
|
|
98
|
+
}
|
|
99
|
+
if (o.selected) selectedLabels.push(label);
|
|
100
|
+
}
|
|
101
|
+
} catch (_) {}
|
|
102
|
+
return {
|
|
103
|
+
control: 'select',
|
|
104
|
+
multiple: multiple,
|
|
105
|
+
current: multiple ? selectedLabels : (selectedLabels[0] || ''),
|
|
106
|
+
options: options,
|
|
107
|
+
options_total: total,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { COMPOUND_INFO_JS, COMPOUND_LABEL_CAP, COMPOUND_SELECT_OPTIONS_CAP, } from './compound.js';
|
|
3
|
+
/**
|
|
4
|
+
* Tests run the JS source in a sandbox via `new Function`, feeding it
|
|
5
|
+
* minimal mock elements shaped like the DOM elements the real code sees
|
|
6
|
+
* at runtime. Avoids a full jsdom setup while still exercising the logic
|
|
7
|
+
* end-to-end instead of only snapshotting string markers.
|
|
8
|
+
*/
|
|
9
|
+
function runCompound(mockEl) {
|
|
10
|
+
const fn = new Function('el', `${COMPOUND_INFO_JS}\nreturn compoundInfoOf(el);`);
|
|
11
|
+
return fn(mockEl);
|
|
12
|
+
}
|
|
13
|
+
function mockInput(attrs, extras = {}) {
|
|
14
|
+
return {
|
|
15
|
+
tagName: 'INPUT',
|
|
16
|
+
value: extras.value,
|
|
17
|
+
multiple: extras.multiple,
|
|
18
|
+
files: extras.files,
|
|
19
|
+
getAttribute(name) {
|
|
20
|
+
return attrs[name] ?? null;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function mockSelect(options, multiple = false) {
|
|
25
|
+
const opts = options.map(o => ({ ...o, selected: !!o.selected }));
|
|
26
|
+
return {
|
|
27
|
+
tagName: 'SELECT',
|
|
28
|
+
multiple,
|
|
29
|
+
options: opts,
|
|
30
|
+
getAttribute: () => null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
describe('compoundInfoOf — date-like inputs', () => {
|
|
34
|
+
it('returns { control, format, current } for <input type=date>', () => {
|
|
35
|
+
const info = runCompound(mockInput({ type: 'date' }, { value: '2026-04-21' }));
|
|
36
|
+
expect(info).toEqual({ control: 'date', format: 'YYYY-MM-DD', current: '2026-04-21' });
|
|
37
|
+
});
|
|
38
|
+
it('surfaces min + max when present', () => {
|
|
39
|
+
const info = runCompound(mockInput({ type: 'date', min: '2026-01-01', max: '2026-12-31' }, { value: '2026-04-21' }));
|
|
40
|
+
expect(info).toMatchObject({ min: '2026-01-01', max: '2026-12-31' });
|
|
41
|
+
});
|
|
42
|
+
it('handles time / datetime-local / month / week with correct format strings', () => {
|
|
43
|
+
const formats = {
|
|
44
|
+
time: 'HH:MM',
|
|
45
|
+
'datetime-local': 'YYYY-MM-DDTHH:MM',
|
|
46
|
+
month: 'YYYY-MM',
|
|
47
|
+
week: 'YYYY-W##',
|
|
48
|
+
};
|
|
49
|
+
for (const [type, fmt] of Object.entries(formats)) {
|
|
50
|
+
const info = runCompound(mockInput({ type }, { value: '' }));
|
|
51
|
+
expect(info.format).toBe(fmt);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
it('coerces null value into empty string instead of crashing', () => {
|
|
55
|
+
const info = runCompound(mockInput({ type: 'date' }));
|
|
56
|
+
expect(info).toMatchObject({ control: 'date', current: '' });
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe('compoundInfoOf — file inputs', () => {
|
|
60
|
+
it('returns { control: file, multiple, current[] }', () => {
|
|
61
|
+
const info = runCompound(mockInput({ type: 'file' }, {
|
|
62
|
+
multiple: true,
|
|
63
|
+
files: [{ name: 'a.png' }, { name: 'b.jpg' }],
|
|
64
|
+
}));
|
|
65
|
+
expect(info).toEqual({ control: 'file', multiple: true, current: ['a.png', 'b.jpg'] });
|
|
66
|
+
});
|
|
67
|
+
it('includes accept when present', () => {
|
|
68
|
+
const info = runCompound(mockInput({ type: 'file', accept: 'image/*' }, { multiple: false }));
|
|
69
|
+
expect(info).toMatchObject({ control: 'file', accept: 'image/*' });
|
|
70
|
+
});
|
|
71
|
+
it('returns empty current[] when nothing uploaded', () => {
|
|
72
|
+
const info = runCompound(mockInput({ type: 'file' }, { multiple: false }));
|
|
73
|
+
expect(info).toEqual({ control: 'file', multiple: false, current: [] });
|
|
74
|
+
});
|
|
75
|
+
it('caps file name at COMPOUND_LABEL_CAP', () => {
|
|
76
|
+
const longName = 'x'.repeat(COMPOUND_LABEL_CAP + 50);
|
|
77
|
+
const info = runCompound(mockInput({ type: 'file' }, { multiple: false, files: [{ name: longName }] }));
|
|
78
|
+
expect(info.current[0].length).toBe(COMPOUND_LABEL_CAP);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe('compoundInfoOf — select', () => {
|
|
82
|
+
it('returns full options list with labels, values, selected flag', () => {
|
|
83
|
+
const info = runCompound(mockSelect([
|
|
84
|
+
{ value: 'us', label: 'United States', selected: true },
|
|
85
|
+
{ value: 'ca', label: 'Canada' },
|
|
86
|
+
{ value: 'fr', label: 'France' },
|
|
87
|
+
]));
|
|
88
|
+
expect(info.options).toHaveLength(3);
|
|
89
|
+
expect(info.options[0]).toEqual({ label: 'United States', value: 'us', selected: true });
|
|
90
|
+
expect(info.options[2]).toEqual({ label: 'France', value: 'fr', selected: false });
|
|
91
|
+
});
|
|
92
|
+
it('sets current to the selected label (single-select)', () => {
|
|
93
|
+
const info = runCompound(mockSelect([
|
|
94
|
+
{ value: 'a', label: 'Alpha' },
|
|
95
|
+
{ value: 'b', label: 'Bravo', selected: true },
|
|
96
|
+
]));
|
|
97
|
+
expect(info).toMatchObject({ control: 'select', multiple: false, current: 'Bravo' });
|
|
98
|
+
});
|
|
99
|
+
it('sets current to an array of labels when multiple=true', () => {
|
|
100
|
+
const info = runCompound(mockSelect([
|
|
101
|
+
{ value: 'a', label: 'Alpha', selected: true },
|
|
102
|
+
{ value: 'b', label: 'Bravo' },
|
|
103
|
+
{ value: 'c', label: 'Charlie', selected: true },
|
|
104
|
+
], true));
|
|
105
|
+
expect(info).toMatchObject({ control: 'select', multiple: true, current: ['Alpha', 'Charlie'] });
|
|
106
|
+
});
|
|
107
|
+
it('falls back from option.label to option.text', () => {
|
|
108
|
+
const info = runCompound(mockSelect([
|
|
109
|
+
{ value: 'a', text: 'FromText' },
|
|
110
|
+
{ value: 'b', label: '', text: 'EmptyLabelFallback' },
|
|
111
|
+
]));
|
|
112
|
+
expect(info.options[0].label).toBe('FromText');
|
|
113
|
+
expect(info.options[1].label).toBe('EmptyLabelFallback');
|
|
114
|
+
});
|
|
115
|
+
it('marks disabled options', () => {
|
|
116
|
+
const info = runCompound(mockSelect([
|
|
117
|
+
{ value: 'a', label: 'A' },
|
|
118
|
+
{ value: 'b', label: 'B', disabled: true },
|
|
119
|
+
]));
|
|
120
|
+
expect(info.options[0].disabled).toBeUndefined();
|
|
121
|
+
expect(info.options[1].disabled).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
it('caps options[] at COMPOUND_SELECT_OPTIONS_CAP but keeps true options_total', () => {
|
|
124
|
+
const big = Array.from({ length: COMPOUND_SELECT_OPTIONS_CAP + 25 }, (_, i) => ({
|
|
125
|
+
value: 'v' + i,
|
|
126
|
+
label: 'L' + i,
|
|
127
|
+
}));
|
|
128
|
+
const info = runCompound(mockSelect(big));
|
|
129
|
+
expect(info.options.length).toBe(COMPOUND_SELECT_OPTIONS_CAP);
|
|
130
|
+
expect(info.options_total).toBe(COMPOUND_SELECT_OPTIONS_CAP + 25);
|
|
131
|
+
});
|
|
132
|
+
it('returns "" for current on single-select with no selected option', () => {
|
|
133
|
+
const info = runCompound(mockSelect([
|
|
134
|
+
{ value: 'a', label: 'A' },
|
|
135
|
+
{ value: 'b', label: 'B' },
|
|
136
|
+
]));
|
|
137
|
+
expect(info).toMatchObject({ current: '' });
|
|
138
|
+
});
|
|
139
|
+
// Regression: the previous loop stopped walking options once it hit
|
|
140
|
+
// COMPOUND_SELECT_OPTIONS_CAP, so a long country dropdown with the
|
|
141
|
+
// selected country sitting at index 80 would be reported with current="".
|
|
142
|
+
// Agents then thought nothing was selected and picked another country.
|
|
143
|
+
it('populates current even when the selected option sits past the serialization cap', () => {
|
|
144
|
+
const big = Array.from({ length: COMPOUND_SELECT_OPTIONS_CAP + 25 }, (_, i) => ({
|
|
145
|
+
value: 'v' + i,
|
|
146
|
+
label: 'L' + i,
|
|
147
|
+
selected: i === COMPOUND_SELECT_OPTIONS_CAP + 10,
|
|
148
|
+
}));
|
|
149
|
+
const info = runCompound(mockSelect(big));
|
|
150
|
+
expect(info.current).toBe('L' + (COMPOUND_SELECT_OPTIONS_CAP + 10));
|
|
151
|
+
expect(info.options.length).toBe(COMPOUND_SELECT_OPTIONS_CAP);
|
|
152
|
+
expect(info.options_total).toBe(COMPOUND_SELECT_OPTIONS_CAP + 25);
|
|
153
|
+
});
|
|
154
|
+
it('multi-select: current[] includes labels for selected options beyond the cap', () => {
|
|
155
|
+
const big = Array.from({ length: COMPOUND_SELECT_OPTIONS_CAP + 10 }, (_, i) => ({
|
|
156
|
+
value: 'v' + i,
|
|
157
|
+
label: 'L' + i,
|
|
158
|
+
selected: i === 3 || i === COMPOUND_SELECT_OPTIONS_CAP + 5,
|
|
159
|
+
}));
|
|
160
|
+
const info = runCompound(mockSelect(big, true));
|
|
161
|
+
expect(info.current).toEqual(['L3', 'L' + (COMPOUND_SELECT_OPTIONS_CAP + 5)]);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
describe('compoundInfoOf — unsupported shapes', () => {
|
|
165
|
+
it('returns null for plain text input', () => {
|
|
166
|
+
expect(runCompound(mockInput({ type: 'text' }, { value: 'hi' }))).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
it('returns null for non-form tags', () => {
|
|
169
|
+
expect(runCompound({ tagName: 'DIV', getAttribute: () => null })).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
it('returns null for null / missing element', () => {
|
|
172
|
+
expect(runCompound(null)).toBeNull();
|
|
173
|
+
expect(runCompound({})).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -6,11 +6,9 @@
|
|
|
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-current' | 'network-capture-start' | 'network-capture-read' | 'cdp';
|
|
10
|
-
/** Target page identity (targetId). Cross-layer contract
|
|
9
|
+
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp' | 'frames';
|
|
10
|
+
/** Target page identity (targetId). Cross-layer contract with the extension. */
|
|
11
11
|
page?: string;
|
|
12
|
-
/** @deprecated Legacy tab ID — use `page` (targetId) instead. */
|
|
13
|
-
tabId?: number;
|
|
14
12
|
code?: string;
|
|
15
13
|
workspace?: string;
|
|
16
14
|
url?: string;
|
|
@@ -34,6 +32,10 @@ export interface DaemonCommand {
|
|
|
34
32
|
cdpParams?: Record<string, unknown>;
|
|
35
33
|
/** When true, automation windows are created in the foreground */
|
|
36
34
|
windowFocused?: boolean;
|
|
35
|
+
/** Custom idle timeout in seconds for this workspace session. Overrides the default. */
|
|
36
|
+
idleTimeout?: number;
|
|
37
|
+
/** Frame index for cross-frame operations (0-based, from 'frames' action) */
|
|
38
|
+
frameIndex?: number;
|
|
37
39
|
}
|
|
38
40
|
export interface DaemonResult {
|
|
39
41
|
id: string;
|
|
@@ -11,7 +11,7 @@ const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
|
|
|
11
11
|
const OPENCLI_HEADERS = { 'X-OpenCLI': '1' };
|
|
12
12
|
let _idCounter = 0;
|
|
13
13
|
function generateId() {
|
|
14
|
-
return `cmd_${Date.now()}_${++_idCounter}`;
|
|
14
|
+
return `cmd_${process.pid}_${Date.now()}_${++_idCounter}`;
|
|
15
15
|
}
|
|
16
16
|
async function requestDaemon(pathname, init) {
|
|
17
17
|
const { timeout = 2000, headers, ...rest } = init ?? {};
|
|
@@ -85,6 +85,11 @@ async function sendCommandRaw(action, params) {
|
|
|
85
85
|
});
|
|
86
86
|
const result = (await res.json());
|
|
87
87
|
if (!result.ok) {
|
|
88
|
+
const isDuplicateCommandId = res.status === 409
|
|
89
|
+
|| (result.error ?? '').includes('Duplicate command id');
|
|
90
|
+
if (isDuplicateCommandId && attempt < maxRetries) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
88
93
|
const advice = classifyBrowserError(new Error(result.error ?? ''));
|
|
89
94
|
if (advice.retryable && attempt < maxRetries) {
|
|
90
95
|
await sleep(advice.delayMs);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, } from './daemon-client.js';
|
|
2
|
+
import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, sendCommand, } from './daemon-client.js';
|
|
3
3
|
describe('daemon-client', () => {
|
|
4
4
|
beforeEach(() => {
|
|
5
5
|
vi.stubGlobal('fetch', vi.fn());
|
|
@@ -78,4 +78,43 @@ describe('daemon-client', () => {
|
|
|
78
78
|
});
|
|
79
79
|
await expect(getDaemonHealth()).resolves.toEqual({ state: 'ready', status });
|
|
80
80
|
});
|
|
81
|
+
it('sendCommand includes the current pid in generated command ids', async () => {
|
|
82
|
+
vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
|
|
83
|
+
vi.mocked(fetch).mockResolvedValue({
|
|
84
|
+
status: 200,
|
|
85
|
+
json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }),
|
|
86
|
+
});
|
|
87
|
+
await expect(sendCommand('exec', { code: '1 + 1' })).resolves.toBe('ok');
|
|
88
|
+
await expect(sendCommand('exec', { code: '2 + 2' })).resolves.toBe('ok');
|
|
89
|
+
const ids = vi.mocked(fetch).mock.calls.map(([, init]) => {
|
|
90
|
+
const body = JSON.parse(String(init?.body));
|
|
91
|
+
return body.id;
|
|
92
|
+
});
|
|
93
|
+
expect(ids).toHaveLength(2);
|
|
94
|
+
expect(ids[0]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`));
|
|
95
|
+
expect(ids[1]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`));
|
|
96
|
+
expect(ids[0]).not.toBe(ids[1]);
|
|
97
|
+
});
|
|
98
|
+
it('sendCommand retries with a new id when daemon reports a duplicate pending id', async () => {
|
|
99
|
+
vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_123);
|
|
100
|
+
const fetchMock = vi.mocked(fetch);
|
|
101
|
+
fetchMock
|
|
102
|
+
.mockResolvedValueOnce({
|
|
103
|
+
ok: false,
|
|
104
|
+
status: 409,
|
|
105
|
+
json: () => Promise.resolve({ ok: false, error: 'Duplicate command id already pending; retry' }),
|
|
106
|
+
})
|
|
107
|
+
.mockResolvedValueOnce({
|
|
108
|
+
ok: true,
|
|
109
|
+
status: 200,
|
|
110
|
+
json: () => Promise.resolve({ id: 'server', ok: true, data: 42 }),
|
|
111
|
+
});
|
|
112
|
+
await expect(sendCommand('exec', { code: '6 * 7' })).resolves.toBe(42);
|
|
113
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
114
|
+
const ids = fetchMock.mock.calls.map(([, init]) => {
|
|
115
|
+
const body = JSON.parse(String(init?.body));
|
|
116
|
+
return body.id;
|
|
117
|
+
});
|
|
118
|
+
expect(ids[0]).not.toBe(ids[1]);
|
|
119
|
+
});
|
|
81
120
|
});
|
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
* Additional tools:
|
|
23
23
|
* - scrollToRefJs(ref) — scroll to a data-opencli-ref element
|
|
24
24
|
* - getFormStateJs() — extract all form fields as structured JSON
|
|
25
|
+
*
|
|
26
|
+
* Compound sidecar:
|
|
27
|
+
* After the tree, a `compounds:` section lists rich JSON for every
|
|
28
|
+
* date/select/file ref — format, full option list (up to cap) with
|
|
29
|
+
* `options_total` reflecting the true count, file `accept` + `multiple`.
|
|
30
|
+
* This is what the snapshot's inline attr dump cannot express and what
|
|
31
|
+
* agents kept blowing turns on.
|
|
25
32
|
*/
|
|
26
33
|
export interface DomSnapshotOptions {
|
|
27
34
|
/** Extra pixels beyond viewport to include (default 800) */
|
|
@@ -22,7 +22,15 @@
|
|
|
22
22
|
* Additional tools:
|
|
23
23
|
* - scrollToRefJs(ref) — scroll to a data-opencli-ref element
|
|
24
24
|
* - getFormStateJs() — extract all form fields as structured JSON
|
|
25
|
+
*
|
|
26
|
+
* Compound sidecar:
|
|
27
|
+
* After the tree, a `compounds:` section lists rich JSON for every
|
|
28
|
+
* date/select/file ref — format, full option list (up to cap) with
|
|
29
|
+
* `options_total` reflecting the true count, file `accept` + `multiple`.
|
|
30
|
+
* This is what the snapshot's inline attr dump cannot express and what
|
|
31
|
+
* agents kept blowing turns on.
|
|
25
32
|
*/
|
|
33
|
+
import { COMPOUND_INFO_JS } from './compound.js';
|
|
26
34
|
// ─── Utility JS Generators ───────────────────────────────────────────
|
|
27
35
|
/**
|
|
28
36
|
* Generate JS to scroll to an element identified by data-opencli-ref.
|
|
@@ -155,6 +163,8 @@ export function generateSnapshotJs(opts = {}) {
|
|
|
155
163
|
(() => {
|
|
156
164
|
'use strict';
|
|
157
165
|
|
|
166
|
+
${COMPOUND_INFO_JS}
|
|
167
|
+
|
|
158
168
|
// ── Config ─────────────────────────────────────────────────────────
|
|
159
169
|
const VIEWPORT_EXPAND = ${viewportExpand};
|
|
160
170
|
const MAX_DEPTH = ${maxDepth};
|
|
@@ -222,6 +232,38 @@ export function generateSnapshotJs(opts = {}) {
|
|
|
222
232
|
|
|
223
233
|
const PROPAGATING_TAGS = new Set(['a', 'button']);
|
|
224
234
|
|
|
235
|
+
// Roles whose element wraps its own interactive descendants (icon spans
|
|
236
|
+
// inside a role=button, chevron inside role=link). When we see one of these,
|
|
237
|
+
// we propagate its bbox to children so we can suppress duplicate refs on
|
|
238
|
+
// undistinctive descendants that are ≥99% contained.
|
|
239
|
+
const PROPAGATING_ROLES = new Set(['button', 'link', 'menuitem', 'tab', 'option']);
|
|
240
|
+
|
|
241
|
+
function isBboxPropagator(el, tag) {
|
|
242
|
+
if (PROPAGATING_TAGS.has(tag)) return true;
|
|
243
|
+
const role = el.getAttribute('role');
|
|
244
|
+
return !!(role && PROPAGATING_ROLES.has(role));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// True when an interactive element still deserves its own [N] ref even
|
|
248
|
+
// though it's visually subsumed by a propagating ancestor. Anything with
|
|
249
|
+
// an aria-label, aria-labelledby, id, test id, name, or its own form
|
|
250
|
+
// semantics is treated as distinctive — everything else (naked spans /
|
|
251
|
+
// divs / svgs that merely inherit click from the parent button) gets
|
|
252
|
+
// folded into the parent so the snapshot doesn't ship [1]<button>[2]<svg>.
|
|
253
|
+
function isDistinctivelyInteractive(el) {
|
|
254
|
+
if (el.hasAttribute('aria-label')) return true;
|
|
255
|
+
if (el.hasAttribute('aria-labelledby')) return true;
|
|
256
|
+
if (el.id) return true;
|
|
257
|
+
if (el.getAttribute('data-testid') || el.getAttribute('data-test')) return true;
|
|
258
|
+
if (el.hasAttribute('name')) return true;
|
|
259
|
+
const tag = el.tagName.toLowerCase();
|
|
260
|
+
// Real form controls always stand on their own, even when nested in a label/button
|
|
261
|
+
if (tag === 'input' || tag === 'select' || tag === 'textarea') return true;
|
|
262
|
+
// Anchors with their own href are distinct targets
|
|
263
|
+
if (tag === 'a' && el.hasAttribute('href')) return true;
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
|
|
225
267
|
const AD_PATTERNS = [
|
|
226
268
|
'googleadservices.com', 'doubleclick.net', 'googlesyndication.com',
|
|
227
269
|
'facebook.com/tr', 'analytics.google.com', 'connect.facebook.net',
|
|
@@ -576,7 +618,9 @@ export function generateSnapshotJs(opts = {}) {
|
|
|
576
618
|
const hiddenInteractives = [];
|
|
577
619
|
const currentHashes = [];
|
|
578
620
|
const refIdentity = {};
|
|
621
|
+
const compoundInfos = {};
|
|
579
622
|
let iframeCount = 0;
|
|
623
|
+
let crossOriginIndex = 0;
|
|
580
624
|
|
|
581
625
|
function walk(el, depth, parentPropagatingRect) {
|
|
582
626
|
if (depth > MAX_DEPTH) return false;
|
|
@@ -626,7 +670,9 @@ export function generateSnapshotJs(opts = {}) {
|
|
|
626
670
|
if (!(tag === 'input' && el.type === 'file')) return false;
|
|
627
671
|
}
|
|
628
672
|
|
|
629
|
-
|
|
673
|
+
// \`interactive\` gets demoted below if bbox containment folds this node
|
|
674
|
+
// into a propagating ancestor — using \`let\` so the dedup pass can mutate it.
|
|
675
|
+
let interactive = isInteractive(el);
|
|
630
676
|
|
|
631
677
|
// Viewport threshold pruning
|
|
632
678
|
if (hasArea && !isInExpandedViewport(rect)) {
|
|
@@ -647,7 +693,7 @@ export function generateSnapshotJs(opts = {}) {
|
|
|
647
693
|
const scrollInfo = getScrollInfo(el);
|
|
648
694
|
const isScrollable = scrollInfo !== null;
|
|
649
695
|
|
|
650
|
-
// BBox dedup
|
|
696
|
+
// BBox dedup — tier 1 (non-interactive descendants, 0.95 threshold)
|
|
651
697
|
let excludedByParent = false;
|
|
652
698
|
if (BBOX_DEDUP && parentPropagatingRect && !interactive) {
|
|
653
699
|
if (hasArea && isContainedBy(rect, parentPropagatingRect, 0.95)) {
|
|
@@ -659,8 +705,19 @@ export function generateSnapshotJs(opts = {}) {
|
|
|
659
705
|
}
|
|
660
706
|
}
|
|
661
707
|
|
|
708
|
+
// BBox dedup — tier 2 (interactive descendants, 0.99 threshold, browser-use style).
|
|
709
|
+
// This kills the "[1]<button> [2]<svg> [3]<span>" noise on icon-buttons by
|
|
710
|
+
// folding the icon / chevron into the button's ref. The 0.99 threshold + the
|
|
711
|
+
// isDistinctivelyInteractive gate together ensure we only drop nodes that
|
|
712
|
+
// add no new actionable surface — a nested <input> or <a href> stays.
|
|
713
|
+
if (BBOX_DEDUP && parentPropagatingRect && interactive && hasArea) {
|
|
714
|
+
if (isContainedBy(rect, parentPropagatingRect, 0.99) && !isDistinctivelyInteractive(el)) {
|
|
715
|
+
interactive = false;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
662
719
|
let propagateRect = parentPropagatingRect;
|
|
663
|
-
if (BBOX_DEDUP &&
|
|
720
|
+
if (BBOX_DEDUP && hasArea && isBboxPropagator(el, tag)) propagateRect = rect;
|
|
664
721
|
|
|
665
722
|
// Process children
|
|
666
723
|
const origLen = lines.length;
|
|
@@ -724,6 +781,10 @@ export function generateSnapshotJs(opts = {}) {
|
|
|
724
781
|
id: el.id || '',
|
|
725
782
|
testId: el.getAttribute('data-testid') || el.getAttribute('data-test') || '',
|
|
726
783
|
};
|
|
784
|
+
// Compound contract for date/select/file — captured per-ref so the
|
|
785
|
+
// sidecar maps one-to-one with the [N] tokens in the tree.
|
|
786
|
+
const compound = compoundInfoOf(el);
|
|
787
|
+
if (compound) compoundInfos['' + interactiveIndex] = compound;
|
|
727
788
|
}
|
|
728
789
|
|
|
729
790
|
// Tag + attributes
|
|
@@ -757,7 +818,9 @@ export function generateSnapshotJs(opts = {}) {
|
|
|
757
818
|
const doc = el.contentDocument;
|
|
758
819
|
if (!doc || !doc.body) {
|
|
759
820
|
const attrs = serializeAttrs(el);
|
|
760
|
-
|
|
821
|
+
const frameLabel = '[F' + crossOriginIndex + ']';
|
|
822
|
+
lines.push(indent + '|iframe|' + frameLabel + '<iframe' + (attrs ? ' ' + attrs : '') + ' /> (cross-origin, use: opencli browser frames + browser eval --frame <index>)');
|
|
823
|
+
crossOriginIndex++;
|
|
761
824
|
return false;
|
|
762
825
|
}
|
|
763
826
|
iframeCount++;
|
|
@@ -770,7 +833,9 @@ export function generateSnapshotJs(opts = {}) {
|
|
|
770
833
|
return has;
|
|
771
834
|
} catch {
|
|
772
835
|
const attrs = serializeAttrs(el);
|
|
773
|
-
|
|
836
|
+
const frameLabel = '[F' + crossOriginIndex + ']';
|
|
837
|
+
lines.push(indent + '|iframe|' + frameLabel + '<iframe' + (attrs ? ' ' + attrs : '') + ' /> (blocked, use: opencli browser frames + browser eval --frame <index>)');
|
|
838
|
+
crossOriginIndex++;
|
|
774
839
|
return false;
|
|
775
840
|
}
|
|
776
841
|
}
|
|
@@ -801,6 +866,19 @@ export function generateSnapshotJs(opts = {}) {
|
|
|
801
866
|
if (hiddenInteractives.length > 10) lines.push(' …' + (hiddenInteractives.length - 10) + ' more');
|
|
802
867
|
}
|
|
803
868
|
|
|
869
|
+
// Compound sidecar — rich JSON for date/select/file refs. Keys align with [N] tokens in the tree.
|
|
870
|
+
const compoundRefs = Object.keys(compoundInfos);
|
|
871
|
+
if (compoundRefs.length > 0) {
|
|
872
|
+
lines.push('---');
|
|
873
|
+
lines.push('compounds (' + compoundRefs.length + '):');
|
|
874
|
+
compoundRefs.sort(function (a, b) { return parseInt(a, 10) - parseInt(b, 10); });
|
|
875
|
+
for (const ref of compoundRefs) {
|
|
876
|
+
try {
|
|
877
|
+
lines.push(' [' + ref + '] ' + JSON.stringify(compoundInfos[ref]));
|
|
878
|
+
} catch {}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
804
882
|
// Footer
|
|
805
883
|
lines.push('---');
|
|
806
884
|
lines.push('interactive: ' + interactiveIndex + ' | iframes: ' + iframeCount);
|