@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.
Files changed (69) hide show
  1. package/README.md +5 -2
  2. package/README.zh-CN.md +5 -2
  3. package/cli-manifest.json +77 -1
  4. package/clis/bilibili/video.js +61 -0
  5. package/clis/bilibili/video.test.js +81 -0
  6. package/clis/deepseek/ask.js +21 -1
  7. package/clis/deepseek/ask.test.js +73 -0
  8. package/clis/deepseek/utils.js +84 -1
  9. package/clis/deepseek/utils.test.js +37 -0
  10. package/clis/jianyu/search.js +139 -3
  11. package/clis/jianyu/search.test.js +25 -0
  12. package/clis/jianyu/shared/procurement-detail.js +15 -0
  13. package/clis/jianyu/shared/procurement-detail.test.js +12 -0
  14. package/clis/twitter/shared.js +7 -2
  15. package/clis/twitter/tweets.js +218 -0
  16. package/clis/twitter/tweets.test.js +125 -0
  17. package/clis/youtube/channel.js +35 -0
  18. package/dist/src/browser/base-page.d.ts +13 -3
  19. package/dist/src/browser/base-page.js +35 -25
  20. package/dist/src/browser/cdp.d.ts +1 -0
  21. package/dist/src/browser/cdp.js +12 -3
  22. package/dist/src/browser/compound.d.ts +59 -0
  23. package/dist/src/browser/compound.js +112 -0
  24. package/dist/src/browser/compound.test.d.ts +1 -0
  25. package/dist/src/browser/compound.test.js +175 -0
  26. package/dist/src/browser/dom-snapshot.d.ts +7 -0
  27. package/dist/src/browser/dom-snapshot.js +76 -3
  28. package/dist/src/browser/dom-snapshot.test.js +65 -0
  29. package/dist/src/browser/extract.d.ts +69 -0
  30. package/dist/src/browser/extract.js +132 -0
  31. package/dist/src/browser/extract.test.d.ts +1 -0
  32. package/dist/src/browser/extract.test.js +129 -0
  33. package/dist/src/browser/find.d.ts +76 -0
  34. package/dist/src/browser/find.js +179 -0
  35. package/dist/src/browser/find.test.d.ts +1 -0
  36. package/dist/src/browser/find.test.js +120 -0
  37. package/dist/src/browser/html-tree.d.ts +75 -0
  38. package/dist/src/browser/html-tree.js +112 -0
  39. package/dist/src/browser/html-tree.test.d.ts +1 -0
  40. package/dist/src/browser/html-tree.test.js +181 -0
  41. package/dist/src/browser/network-cache.d.ts +48 -0
  42. package/dist/src/browser/network-cache.js +66 -0
  43. package/dist/src/browser/network-cache.test.d.ts +1 -0
  44. package/dist/src/browser/network-cache.test.js +58 -0
  45. package/dist/src/browser/network-key.d.ts +22 -0
  46. package/dist/src/browser/network-key.js +66 -0
  47. package/dist/src/browser/network-key.test.d.ts +1 -0
  48. package/dist/src/browser/network-key.test.js +49 -0
  49. package/dist/src/browser/shape-filter.d.ts +52 -0
  50. package/dist/src/browser/shape-filter.js +101 -0
  51. package/dist/src/browser/shape-filter.test.d.ts +1 -0
  52. package/dist/src/browser/shape-filter.test.js +101 -0
  53. package/dist/src/browser/shape.d.ts +23 -0
  54. package/dist/src/browser/shape.js +95 -0
  55. package/dist/src/browser/shape.test.d.ts +1 -0
  56. package/dist/src/browser/shape.test.js +82 -0
  57. package/dist/src/browser/target-errors.d.ts +14 -1
  58. package/dist/src/browser/target-errors.js +13 -0
  59. package/dist/src/browser/target-errors.test.js +39 -6
  60. package/dist/src/browser/target-resolver.d.ts +57 -10
  61. package/dist/src/browser/target-resolver.js +195 -75
  62. package/dist/src/browser/target-resolver.test.js +80 -5
  63. package/dist/src/cli.js +630 -125
  64. package/dist/src/cli.test.js +794 -0
  65. package/dist/src/execution.js +7 -2
  66. package/dist/src/execution.test.js +54 -0
  67. package/dist/src/main.js +16 -0
  68. package/dist/src/types.d.ts +18 -3
  69. package/package.json +1 -1
@@ -1,42 +1,168 @@
1
1
  /**
2
2
  * Unified target resolver for browser actions.
3
3
  *
4
- * Replaces the ad-hoc 4-strategy fallback in dom-helpers.ts with a
5
- * principled resolution pipeline:
4
+ * Resolution pipeline:
6
5
  *
7
- * 1. Input classification: numeric → ref path, CSS-like → CSS path
8
- * 2. Ref path: lookup by data-opencli-ref, then verify fingerprint
9
- * 3. CSS path: querySelectorAll + uniqueness check
10
- * 4. Structured errors: stale_ref / ambiguous / not_found
6
+ * 1. Input classification: all-digitnumeric 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, el: Element } — success (el is assigned to `__resolved`)
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
- * The resolved element is stored in `__resolved` for the caller to use.
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
- // ── Fingerprint verification (identity vector) ──
49
- const fp = identity[ref];
50
- if (fp) {
51
- const tag = el.tagName.toLowerCase();
52
- const text = (el.textContent || '').trim().slice(0, 30);
53
- const role = el.getAttribute('role') || '';
54
- const ariaLabel = el.getAttribute('aria-label') || '';
55
- const id = el.id || '';
56
- const testId = el.getAttribute('data-testid') || el.getAttribute('data-test') || '';
57
-
58
- // Hard fail: tag must always match
59
- const tagMatch = fp.tag === tag;
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
- // Soft signals: each non-empty stored field that mismatches counts against
62
- var mismatches = 0;
63
- var checks = 0;
64
- if (fp.id) { checks++; if (fp.id !== id) mismatches++; }
65
- if (fp.testId) { checks++; if (fp.testId !== testId) mismatches++; }
66
- if (fp.ariaLabel) { checks++; if (fp.ariaLabel !== ariaLabel) mismatches++; }
67
- if (fp.role) { checks++; if (fp.role !== role) mismatches++; }
68
- if (fp.text) {
69
- checks++;
70
- // Text: allow prefix match (page text can grow), but empty current text never matches
71
- if (!text || (!text.startsWith(fp.text) && !fp.text.startsWith(text))) mismatches++;
72
- }
180
+ const liveFp = fingerprintOf(el);
181
+ const level = classifyMatch(fp, liveFp);
73
182
 
74
- // Stale if tag changed, or if any uniquely identifying field (id/testId) changed,
75
- // or if majority of soft signals mismatch
76
- var isStale = !tagMatch;
77
- if (!isStale && checks > 0) {
78
- // id and testId are strong identifiers — any mismatch on these is decisive
79
- if (fp.id && fp.id !== id) isStale = true;
80
- else if (fp.testId && fp.testId !== testId) isStale = true;
81
- // For remaining signals, stale if more than half mismatch
82
- else if (mismatches > checks / 2) isStale = true;
83
- }
183
+ if (level === 'exact' || level === 'stable') {
184
+ window.__resolved = el;
185
+ return { ok: true, matches_n: 1, match_level: level };
186
+ }
84
187
 
85
- if (isStale) {
86
- return {
87
- ok: false,
88
- code: 'stale_ref',
89
- message: 'ref=' + ref + ' was <' + fp.tag + '>' + (fp.text ? '"' + fp.text + '"' : '')
90
- + ' but now points to <' + tag + '>' + (text ? '"' + text.slice(0, 30) + '"' : ''),
91
- hint: 'The page has changed since the last snapshot. Re-run \`opencli browser state\` to refresh.',
92
- };
93
- }
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
- window.__resolved = el;
97
- return { ok: true };
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
- if (isCssLike) {
101
- // ── CSS selector path ──
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: 'not_found',
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: 'not_found',
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 (matches.length > 1) {
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: 'ambiguous',
261
+ code: 'selector_ambiguous',
136
262
  message: 'CSS selector "' + ref + '" matched ' + matches.length + ' elements',
137
- hint: 'Use a more specific selector, or use ref numbers from \`opencli browser state\` snapshot.',
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('ambiguous');
29
+ expect(js).toContain('selector_ambiguous');
30
30
  expect(js).toContain('candidates');
31
31
  });
32
- it('generates JS that rejects unrecognized input', () => {
33
- const js = resolveTargetJs('???');
34
- expect(js).toContain('not_found');
35
- expect(js).toContain('Cannot parse target');
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
  });