@jackwener/opencli 1.7.5 → 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 +5 -2
- package/README.zh-CN.md +5 -2
- package/cli-manifest.json +77 -1
- package/clis/bilibili/video.js +61 -0
- package/clis/bilibili/video.test.js +81 -0
- package/clis/deepseek/ask.js +21 -1
- package/clis/deepseek/ask.test.js +73 -0
- package/clis/deepseek/utils.js +84 -1
- package/clis/deepseek/utils.test.js +37 -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/twitter/shared.js +7 -2
- package/clis/twitter/tweets.js +218 -0
- package/clis/twitter/tweets.test.js +125 -0
- package/clis/youtube/channel.js +35 -0
- package/dist/src/browser/base-page.d.ts +13 -3
- package/dist/src/browser/base-page.js +35 -25
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +12 -3
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.d.ts +1 -0
- package/dist/src/browser/compound.test.js +175 -0
- package/dist/src/browser/dom-snapshot.d.ts +7 -0
- package/dist/src/browser/dom-snapshot.js +76 -3
- 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.d.ts +1 -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.d.ts +1 -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/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 +630 -125
- package/dist/src/cli.test.js +794 -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/types.d.ts +18 -3
- package/package.json +1 -1
|
@@ -1,42 +1,168 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unified target resolver for browser actions.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* principled resolution pipeline:
|
|
4
|
+
* Resolution pipeline:
|
|
6
5
|
*
|
|
7
|
-
* 1. Input classification:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* 1. Input classification: all-digit → numeric ref path, otherwise → CSS path.
|
|
7
|
+
* The CSS path passes the raw string to `querySelectorAll` and lets the
|
|
8
|
+
* browser parser decide what's valid. No frontend regex whitelist — the
|
|
9
|
+
* goal is that any selector accepted by `browser find --css` is accepted
|
|
10
|
+
* by the same selector on `get/click/type/select`.
|
|
11
|
+
* 2. Ref path: cascading match levels (see below), using data-opencli-ref
|
|
12
|
+
* plus the fingerprint map populated by snapshot + find.
|
|
13
|
+
* 3. CSS path: querySelectorAll + match-count policy (see ResolveOptions)
|
|
14
|
+
* 4. Structured errors:
|
|
15
|
+
* - numeric: not_found / stale_ref
|
|
16
|
+
* - CSS: invalid_selector / selector_not_found / selector_ambiguous
|
|
17
|
+
* / selector_nth_out_of_range
|
|
11
18
|
*
|
|
12
19
|
* All JS is generated as strings for page.evaluate() — runs in the browser.
|
|
20
|
+
*
|
|
21
|
+
* ── Cascading stale-ref (browser-use style) ──────────────────────────
|
|
22
|
+
* Strict equality on the fingerprint rejected too many live pages — SPA
|
|
23
|
+
* re-renders swap text / role while keeping id + testId. The resolver
|
|
24
|
+
* now walks three tiers before giving up:
|
|
25
|
+
*
|
|
26
|
+
* 1. EXACT — tag + strong id (id or testId) agree, ≤1 soft mismatch
|
|
27
|
+
* 2. STABLE — tag + strong id agree, soft signals drifted (aria-label,
|
|
28
|
+
* role, text) — agent gets a warning but the action
|
|
29
|
+
* proceeds so dynamic pages don't stall
|
|
30
|
+
* 3. REIDENTIFIED — original ref either missing from the DOM or fully
|
|
31
|
+
* mismatched, but the fingerprint uniquely identifies
|
|
32
|
+
* a single other live element via id / testId /
|
|
33
|
+
* aria-label. Re-tag that element with the old ref and
|
|
34
|
+
* surface match_level so the caller knows we swapped.
|
|
35
|
+
*
|
|
36
|
+
* Only when all three fail do we emit `stale_ref`. Every success envelope
|
|
37
|
+
* carries `match_level` so downstream CLIs can surface the weakest tier
|
|
38
|
+
* a caller actually traversed.
|
|
13
39
|
*/
|
|
14
40
|
/**
|
|
15
41
|
* Generate JS that resolves a target to a single DOM element.
|
|
16
42
|
*
|
|
17
43
|
* Returns a JS expression that evaluates to:
|
|
18
|
-
* { ok: true,
|
|
19
|
-
* { ok: false, code, message, hint, candidates } — structured error
|
|
44
|
+
* { ok: true, matches_n, match_level } — success (el stored in `__resolved`)
|
|
45
|
+
* { ok: false, code, message, hint, candidates, matches_n? } — structured error
|
|
20
46
|
*
|
|
21
|
-
*
|
|
47
|
+
* `match_level` is always set on success:
|
|
48
|
+
* - CSS path → 'exact'
|
|
49
|
+
* - numeric ref path → whichever tier matched ('exact' / 'stable' / 'reidentified')
|
|
50
|
+
*
|
|
51
|
+
* The resolved element is stored in `window.__resolved` for downstream helpers.
|
|
22
52
|
*/
|
|
23
|
-
export function resolveTargetJs(ref) {
|
|
53
|
+
export function resolveTargetJs(ref, opts = {}) {
|
|
24
54
|
const safeRef = JSON.stringify(ref);
|
|
55
|
+
const nthJs = opts.nth !== undefined ? String(opts.nth | 0) : 'null';
|
|
56
|
+
const firstOnMulti = opts.firstOnMulti === true ? 'true' : 'false';
|
|
25
57
|
return `
|
|
26
58
|
(() => {
|
|
27
59
|
const ref = ${safeRef};
|
|
60
|
+
const nth = ${nthJs};
|
|
61
|
+
const firstOnMulti = ${firstOnMulti};
|
|
28
62
|
const identity = window.__opencli_ref_identity || {};
|
|
29
63
|
|
|
30
64
|
// ── Classify input ──
|
|
65
|
+
// Numeric = snapshot ref. Everything else is handed to querySelectorAll
|
|
66
|
+
// and whatever the browser parser accepts is a valid selector. No regex
|
|
67
|
+
// shortlist up front: \`find --css\` and \`get/click/type/select\` must agree
|
|
68
|
+
// on the same selector surface (see contract note at the top of this file).
|
|
31
69
|
const isNumeric = /^\\d+$/.test(ref);
|
|
32
|
-
const isCssLike = !isNumeric && /^[a-zA-Z#.\\[]/.test(ref);
|
|
33
70
|
|
|
34
71
|
if (isNumeric) {
|
|
35
|
-
// ── Ref path ──
|
|
72
|
+
// ── Ref path (cascading match levels) ──
|
|
73
|
+
|
|
74
|
+
// Shared helper: compute a fingerprint off a live element, same shape
|
|
75
|
+
// snapshot + find populate into \`__opencli_ref_identity\`. Kept inline
|
|
76
|
+
// (not imported) because this source string is compiled standalone.
|
|
77
|
+
function fingerprintOf(node) {
|
|
78
|
+
return {
|
|
79
|
+
tag: node.tagName.toLowerCase(),
|
|
80
|
+
role: node.getAttribute('role') || '',
|
|
81
|
+
text: (node.textContent || '').trim().slice(0, 30),
|
|
82
|
+
ariaLabel: node.getAttribute('aria-label') || '',
|
|
83
|
+
id: node.id || '',
|
|
84
|
+
testId: node.getAttribute('data-testid') || node.getAttribute('data-test') || '',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Classify how strongly a live element matches a stored fingerprint.
|
|
89
|
+
// Returns one of 'exact' | 'stable' | 'mismatch'.
|
|
90
|
+
//
|
|
91
|
+
// 'exact' — tag + every non-empty stored field agrees (±text prefix).
|
|
92
|
+
// 'stable' — tag agrees AND at least one strong id (id or testId) still
|
|
93
|
+
// matches; soft signals (aria-label, role, text) may have
|
|
94
|
+
// drifted. This covers SPA re-render / i18n label swaps.
|
|
95
|
+
// 'mismatch' otherwise.
|
|
96
|
+
function classifyMatch(fp, liveFp) {
|
|
97
|
+
if (fp.tag !== liveFp.tag) return 'mismatch';
|
|
98
|
+
|
|
99
|
+
const idMatch = !fp.id || fp.id === liveFp.id;
|
|
100
|
+
const testIdMatch = !fp.testId || fp.testId === liveFp.testId;
|
|
101
|
+
const roleMatch = !fp.role || fp.role === liveFp.role;
|
|
102
|
+
const ariaMatch = !fp.ariaLabel || fp.ariaLabel === liveFp.ariaLabel;
|
|
103
|
+
const textMatch = !fp.text || (
|
|
104
|
+
!!liveFp.text && (liveFp.text.startsWith(fp.text) || fp.text.startsWith(liveFp.text))
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (idMatch && testIdMatch && roleMatch && ariaMatch && textMatch) return 'exact';
|
|
108
|
+
|
|
109
|
+
// Strong id decides: if id + testId still agree and we had at least one
|
|
110
|
+
// of them, accept as stable regardless of soft-signal drift.
|
|
111
|
+
const hadStrongId = !!fp.id || !!fp.testId;
|
|
112
|
+
if (hadStrongId && idMatch && testIdMatch) return 'stable';
|
|
113
|
+
|
|
114
|
+
return 'mismatch';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Try to recover a stale ref by searching the page for a live element
|
|
118
|
+
// whose fingerprint still matches. Uniqueness is required — if two
|
|
119
|
+
// candidates match equally well, we refuse rather than silently pick
|
|
120
|
+
// the wrong one. Covers ref annotations lost to a re-mount.
|
|
121
|
+
function reidentify(fp) {
|
|
122
|
+
if (!fp) return null;
|
|
123
|
+
const candidates = [];
|
|
124
|
+
function tryAdd(el) {
|
|
125
|
+
if (el && el.nodeType === 1 && classifyMatch(fp, fingerprintOf(el)) !== 'mismatch') {
|
|
126
|
+
if (candidates.indexOf(el) === -1) candidates.push(el);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Prefer strong-id lookups. If id / testId is present and yields a
|
|
130
|
+
// unique element, that's our hit.
|
|
131
|
+
try {
|
|
132
|
+
if (fp.id) {
|
|
133
|
+
const byId = document.getElementById(fp.id);
|
|
134
|
+
if (byId) tryAdd(byId);
|
|
135
|
+
}
|
|
136
|
+
if (fp.testId) {
|
|
137
|
+
const byTestIdA = document.querySelectorAll('[data-testid="' + fp.testId.replace(/"/g, '\\\\"') + '"]');
|
|
138
|
+
for (let i = 0; i < byTestIdA.length; i++) tryAdd(byTestIdA[i]);
|
|
139
|
+
const byTestIdB = document.querySelectorAll('[data-test="' + fp.testId.replace(/"/g, '\\\\"') + '"]');
|
|
140
|
+
for (let i = 0; i < byTestIdB.length; i++) tryAdd(byTestIdB[i]);
|
|
141
|
+
}
|
|
142
|
+
// aria-label is only a useful shortlist when nothing stronger is set
|
|
143
|
+
if (candidates.length === 0 && fp.ariaLabel) {
|
|
144
|
+
const byAria = document.querySelectorAll('[aria-label="' + fp.ariaLabel.replace(/"/g, '\\\\"') + '"]');
|
|
145
|
+
for (let i = 0; i < byAria.length; i++) tryAdd(byAria[i]);
|
|
146
|
+
}
|
|
147
|
+
} catch (_) { /* bad selectors from weird fp values — skip */ }
|
|
148
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const fp = identity[ref];
|
|
36
152
|
let el = document.querySelector('[data-opencli-ref="' + ref + '"]');
|
|
37
153
|
if (!el) el = document.querySelector('[data-ref="' + ref + '"]');
|
|
38
154
|
|
|
155
|
+
// If the ref tag is gone from the DOM, last-chance reidentify.
|
|
39
156
|
if (!el) {
|
|
157
|
+
const recovered = reidentify(fp);
|
|
158
|
+
if (recovered) {
|
|
159
|
+
try {
|
|
160
|
+
recovered.setAttribute('data-opencli-ref', ref);
|
|
161
|
+
identity[ref] = fingerprintOf(recovered);
|
|
162
|
+
} catch (_) {}
|
|
163
|
+
window.__resolved = recovered;
|
|
164
|
+
return { ok: true, matches_n: 1, match_level: 'reidentified' };
|
|
165
|
+
}
|
|
40
166
|
return {
|
|
41
167
|
ok: false,
|
|
42
168
|
code: 'not_found',
|
|
@@ -45,68 +171,53 @@ export function resolveTargetJs(ref) {
|
|
|
45
171
|
};
|
|
46
172
|
}
|
|
47
173
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const role = el.getAttribute('role') || '';
|
|
54
|
-
const ariaLabel = el.getAttribute('aria-label') || '';
|
|
55
|
-
const id = el.id || '';
|
|
56
|
-
const testId = el.getAttribute('data-testid') || el.getAttribute('data-test') || '';
|
|
57
|
-
|
|
58
|
-
// Hard fail: tag must always match
|
|
59
|
-
const tagMatch = fp.tag === tag;
|
|
174
|
+
// No stored fingerprint (older page / unknown ref) — accept as exact.
|
|
175
|
+
if (!fp) {
|
|
176
|
+
window.__resolved = el;
|
|
177
|
+
return { ok: true, matches_n: 1, match_level: 'exact' };
|
|
178
|
+
}
|
|
60
179
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
var checks = 0;
|
|
64
|
-
if (fp.id) { checks++; if (fp.id !== id) mismatches++; }
|
|
65
|
-
if (fp.testId) { checks++; if (fp.testId !== testId) mismatches++; }
|
|
66
|
-
if (fp.ariaLabel) { checks++; if (fp.ariaLabel !== ariaLabel) mismatches++; }
|
|
67
|
-
if (fp.role) { checks++; if (fp.role !== role) mismatches++; }
|
|
68
|
-
if (fp.text) {
|
|
69
|
-
checks++;
|
|
70
|
-
// Text: allow prefix match (page text can grow), but empty current text never matches
|
|
71
|
-
if (!text || (!text.startsWith(fp.text) && !fp.text.startsWith(text))) mismatches++;
|
|
72
|
-
}
|
|
180
|
+
const liveFp = fingerprintOf(el);
|
|
181
|
+
const level = classifyMatch(fp, liveFp);
|
|
73
182
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// id and testId are strong identifiers — any mismatch on these is decisive
|
|
79
|
-
if (fp.id && fp.id !== id) isStale = true;
|
|
80
|
-
else if (fp.testId && fp.testId !== testId) isStale = true;
|
|
81
|
-
// For remaining signals, stale if more than half mismatch
|
|
82
|
-
else if (mismatches > checks / 2) isStale = true;
|
|
83
|
-
}
|
|
183
|
+
if (level === 'exact' || level === 'stable') {
|
|
184
|
+
window.__resolved = el;
|
|
185
|
+
return { ok: true, matches_n: 1, match_level: level };
|
|
186
|
+
}
|
|
84
187
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
188
|
+
// Tag / strong-id mismatch — try to find the real element elsewhere
|
|
189
|
+
// before giving up. Covers e.g. a modal re-mount that discarded the
|
|
190
|
+
// data-opencli-ref attribute on the surviving node.
|
|
191
|
+
const recovered = reidentify(fp);
|
|
192
|
+
if (recovered && recovered !== el) {
|
|
193
|
+
try {
|
|
194
|
+
el.removeAttribute('data-opencli-ref');
|
|
195
|
+
recovered.setAttribute('data-opencli-ref', ref);
|
|
196
|
+
identity[ref] = fingerprintOf(recovered);
|
|
197
|
+
} catch (_) {}
|
|
198
|
+
window.__resolved = recovered;
|
|
199
|
+
return { ok: true, matches_n: 1, match_level: 'reidentified' };
|
|
94
200
|
}
|
|
95
201
|
|
|
96
|
-
|
|
97
|
-
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
code: 'stale_ref',
|
|
205
|
+
message: 'ref=' + ref + ' was <' + fp.tag + '>' + (fp.text ? '"' + fp.text + '"' : '')
|
|
206
|
+
+ ' but now points to <' + liveFp.tag + '>' + (liveFp.text ? '"' + liveFp.text.slice(0, 30) + '"' : ''),
|
|
207
|
+
hint: 'The page has changed since the last snapshot. Re-run \`opencli browser state\` to refresh.',
|
|
208
|
+
};
|
|
98
209
|
}
|
|
99
210
|
|
|
100
|
-
|
|
101
|
-
|
|
211
|
+
// ── CSS selector path (any non-numeric input) ──
|
|
212
|
+
{
|
|
102
213
|
let matches;
|
|
103
214
|
try {
|
|
104
215
|
matches = document.querySelectorAll(ref);
|
|
105
216
|
} catch (e) {
|
|
106
217
|
return {
|
|
107
218
|
ok: false,
|
|
108
|
-
code: '
|
|
109
|
-
message: 'Invalid CSS selector: ' + ref,
|
|
219
|
+
code: 'invalid_selector',
|
|
220
|
+
message: 'Invalid CSS selector: ' + ref + ' (' + ((e && e.message) || String(e)) + ')',
|
|
110
221
|
hint: 'Check the selector syntax. Use ref numbers from snapshot for reliable targeting.',
|
|
111
222
|
};
|
|
112
223
|
}
|
|
@@ -114,13 +225,28 @@ export function resolveTargetJs(ref) {
|
|
|
114
225
|
if (matches.length === 0) {
|
|
115
226
|
return {
|
|
116
227
|
ok: false,
|
|
117
|
-
code: '
|
|
228
|
+
code: 'selector_not_found',
|
|
118
229
|
message: 'CSS selector "' + ref + '" matched 0 elements',
|
|
119
|
-
hint: 'The element may not exist or may be hidden. Re-run \`opencli browser state\` to check.',
|
|
230
|
+
hint: 'The element may not exist or may be hidden. Re-run \`opencli browser state\` to check, or use \`opencli browser find --css\` to explore candidates.',
|
|
231
|
+
matches_n: 0,
|
|
120
232
|
};
|
|
121
233
|
}
|
|
122
234
|
|
|
123
|
-
if (
|
|
235
|
+
if (nth !== null) {
|
|
236
|
+
if (nth < 0 || nth >= matches.length) {
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
code: 'selector_nth_out_of_range',
|
|
240
|
+
message: 'CSS selector "' + ref + '" matched ' + matches.length + ' elements, but --nth=' + nth + ' is out of range',
|
|
241
|
+
hint: 'Use --nth between 0 and ' + (matches.length - 1) + ', or omit --nth to target the first match (read ops) or require explicit disambiguation (write ops).',
|
|
242
|
+
matches_n: matches.length,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
window.__resolved = matches[nth];
|
|
246
|
+
return { ok: true, matches_n: matches.length, match_level: 'exact' };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (matches.length > 1 && !firstOnMulti) {
|
|
124
250
|
const candidates = [];
|
|
125
251
|
const limit = Math.min(matches.length, 5);
|
|
126
252
|
for (let i = 0; i < limit; i++) {
|
|
@@ -132,24 +258,18 @@ export function resolveTargetJs(ref) {
|
|
|
132
258
|
}
|
|
133
259
|
return {
|
|
134
260
|
ok: false,
|
|
135
|
-
code: '
|
|
261
|
+
code: 'selector_ambiguous',
|
|
136
262
|
message: 'CSS selector "' + ref + '" matched ' + matches.length + ' elements',
|
|
137
|
-
hint: '
|
|
263
|
+
hint: 'Pass --nth <n> (0-based) to pick one, or use a more specific selector. Use \`opencli browser find --css\` to list all candidates.',
|
|
138
264
|
candidates: candidates,
|
|
265
|
+
matches_n: matches.length,
|
|
139
266
|
};
|
|
140
267
|
}
|
|
141
268
|
|
|
269
|
+
// Single match, OR multi-match with firstOnMulti (read path)
|
|
142
270
|
window.__resolved = matches[0];
|
|
143
|
-
return { ok: true };
|
|
271
|
+
return { ok: true, matches_n: matches.length, match_level: 'exact' };
|
|
144
272
|
}
|
|
145
|
-
|
|
146
|
-
// ── Unrecognized input ──
|
|
147
|
-
return {
|
|
148
|
-
ok: false,
|
|
149
|
-
code: 'not_found',
|
|
150
|
-
message: 'Cannot parse target: ' + ref,
|
|
151
|
-
hint: 'Use a numeric ref from snapshot (e.g. "12") or a CSS selector (e.g. "#submit").',
|
|
152
|
-
};
|
|
153
273
|
})()
|
|
154
274
|
`;
|
|
155
275
|
}
|
|
@@ -26,13 +26,37 @@ describe('resolveTargetJs', () => {
|
|
|
26
26
|
});
|
|
27
27
|
it('generates JS with ambiguity detection for CSS selectors', () => {
|
|
28
28
|
const js = resolveTargetJs('.btn');
|
|
29
|
-
expect(js).toContain('
|
|
29
|
+
expect(js).toContain('selector_ambiguous');
|
|
30
30
|
expect(js).toContain('candidates');
|
|
31
31
|
});
|
|
32
|
-
it('generates JS that
|
|
33
|
-
const js = resolveTargetJs('
|
|
34
|
-
expect(js).toContain('
|
|
35
|
-
|
|
32
|
+
it('generates JS that propagates --nth option into the CSS branch', () => {
|
|
33
|
+
const js = resolveTargetJs('.btn', { nth: 2 });
|
|
34
|
+
expect(js).toContain('selector_nth_out_of_range');
|
|
35
|
+
// opt.nth=2 should be inlined so the runtime picks matches[2]
|
|
36
|
+
expect(js).toMatch(/const nth = 2;?/);
|
|
37
|
+
});
|
|
38
|
+
it('generates JS that enables firstOnMulti for read commands', () => {
|
|
39
|
+
const js = resolveTargetJs('.btn', { firstOnMulti: true });
|
|
40
|
+
expect(js).toContain('firstOnMulti = true');
|
|
41
|
+
});
|
|
42
|
+
it('generates JS with invalid_selector branch for CSS syntax errors', () => {
|
|
43
|
+
const js = resolveTargetJs('.btn');
|
|
44
|
+
expect(js).toContain('invalid_selector');
|
|
45
|
+
});
|
|
46
|
+
it('generates JS with selector_not_found branch for 0 matches', () => {
|
|
47
|
+
const js = resolveTargetJs('#does-not-exist');
|
|
48
|
+
expect(js).toContain('selector_not_found');
|
|
49
|
+
});
|
|
50
|
+
it('hands every non-numeric input to querySelectorAll (no regex shortlist)', () => {
|
|
51
|
+
// Inputs that the old isCssLike regex rejected — must all flow into the
|
|
52
|
+
// CSS branch so `find --css` and `get/click/type/select` accept the same surface.
|
|
53
|
+
for (const sel of [':root', '*', ':has(.foo)', '::shadow-root', '???']) {
|
|
54
|
+
const js = resolveTargetJs(sel);
|
|
55
|
+
expect(js).toContain('querySelectorAll');
|
|
56
|
+
// invalid selectors still route through invalid_selector at runtime,
|
|
57
|
+
// never through a frontend "Cannot parse target" rejection.
|
|
58
|
+
expect(js).not.toContain('Cannot parse target');
|
|
59
|
+
}
|
|
36
60
|
});
|
|
37
61
|
it('escapes ref value safely', () => {
|
|
38
62
|
const js = resolveTargetJs('"; alert(1); "');
|
|
@@ -40,4 +64,55 @@ describe('resolveTargetJs', () => {
|
|
|
40
64
|
expect(js).not.toContain('alert(1); "');
|
|
41
65
|
expect(js).toContain('\\"');
|
|
42
66
|
});
|
|
67
|
+
it('tags every success envelope with match_level so agents can tell tiers apart', () => {
|
|
68
|
+
const numericJs = resolveTargetJs('7');
|
|
69
|
+
const cssJs = resolveTargetJs('.btn');
|
|
70
|
+
// Exact / reidentified emit the literal directly; stable flows through the
|
|
71
|
+
// classifier's `level` variable. All three strings must appear in the JS.
|
|
72
|
+
expect(numericJs).toContain("match_level: 'exact'");
|
|
73
|
+
expect(numericJs).toContain("match_level: 'reidentified'");
|
|
74
|
+
expect(numericJs).toContain("return 'stable'");
|
|
75
|
+
// Stable + exact share the same emit site (match_level: level) — make sure
|
|
76
|
+
// we didn't hardcode one of them and drop the other.
|
|
77
|
+
expect(numericJs).toContain('match_level: level');
|
|
78
|
+
// CSS path is always exact (selector ran successfully).
|
|
79
|
+
expect(cssJs).toContain("match_level: 'exact'");
|
|
80
|
+
});
|
|
81
|
+
it('cascading ref path — classifier + reidentifier are both wired in', () => {
|
|
82
|
+
const js = resolveTargetJs('3');
|
|
83
|
+
// Classifier distinguishes the three tiers
|
|
84
|
+
expect(js).toContain('function classifyMatch');
|
|
85
|
+
expect(js).toContain("return 'exact'");
|
|
86
|
+
expect(js).toContain("return 'stable'");
|
|
87
|
+
expect(js).toContain("return 'mismatch'");
|
|
88
|
+
// Strong id is the only thing that can rescue a drifted fingerprint
|
|
89
|
+
expect(js).toContain('hadStrongId');
|
|
90
|
+
// Reidentify searches live DOM with the same fingerprint shape the
|
|
91
|
+
// snapshot / find writers emit — id / testId / aria-label only.
|
|
92
|
+
expect(js).toContain('function reidentify');
|
|
93
|
+
expect(js).toContain('getElementById');
|
|
94
|
+
expect(js).toContain('[data-testid="');
|
|
95
|
+
expect(js).toContain('[aria-label="');
|
|
96
|
+
// Unique match required — never silently picks one of many candidates.
|
|
97
|
+
expect(js).toContain('candidates.length === 1');
|
|
98
|
+
// Recovered element is re-tagged + identity map refreshed so subsequent
|
|
99
|
+
// resolves land on 'exact' instead of re-walking the cascade.
|
|
100
|
+
expect(js).toContain("setAttribute('data-opencli-ref', ref)");
|
|
101
|
+
expect(js).toContain('identity[ref] = fingerprintOf(recovered)');
|
|
102
|
+
});
|
|
103
|
+
it('reidentify runs both when data-opencli-ref is missing AND when fingerprint is mismatched', () => {
|
|
104
|
+
const js = resolveTargetJs('9');
|
|
105
|
+
// Two call sites: one in the !el branch, one after classifyMatch returns mismatch.
|
|
106
|
+
const count = js.split('reidentify(fp)').length - 1;
|
|
107
|
+
expect(count).toBeGreaterThanOrEqual(2);
|
|
108
|
+
});
|
|
109
|
+
it('falls through to stale_ref only after reidentify exhausts', () => {
|
|
110
|
+
const js = resolveTargetJs('4');
|
|
111
|
+
// The stale_ref emit must sit *below* a reidentify attempt so the cascade
|
|
112
|
+
// is what produces the error — not the original strict check.
|
|
113
|
+
const reidentifyIdx = js.indexOf('const recovered = reidentify(fp);');
|
|
114
|
+
const staleIdx = js.indexOf("code: 'stale_ref'");
|
|
115
|
+
expect(reidentifyIdx).toBeGreaterThan(-1);
|
|
116
|
+
expect(staleIdx).toBeGreaterThan(reidentifyIdx);
|
|
117
|
+
});
|
|
43
118
|
});
|