target-resolver.test.ts
1 import { describe, expect, it } from 'vitest'; 2 import { resolveTargetJs } from './target-resolver.js'; 3 4 /** 5 * Tests for the target resolver JS generator. 6 * 7 * Since resolveTargetJs() produces JS strings for browser evaluate(), 8 * we test the generated JS by running it in a simulated DOM-like context 9 * and verifying the structure of the output. 10 */ 11 12 describe('resolveTargetJs', () => { 13 it('generates JS that returns structured resolution for numeric ref', () => { 14 const js = resolveTargetJs('12'); 15 expect(js).toContain('data-opencli-ref'); 16 expect(js).toContain('__opencli_ref_identity'); 17 expect(js).toContain('"12"'); 18 }); 19 20 it('generates JS that handles CSS selector input', () => { 21 const js = resolveTargetJs('#submit-btn'); 22 expect(js).toContain('querySelectorAll'); 23 expect(js).toContain('"#submit-btn"'); 24 }); 25 26 it('generates JS with stale_ref detection for numeric refs', () => { 27 const js = resolveTargetJs('5'); 28 expect(js).toContain('stale_ref'); 29 expect(js).toContain('__opencli_ref_identity'); 30 }); 31 32 it('generates JS with ambiguity detection for CSS selectors', () => { 33 const js = resolveTargetJs('.btn'); 34 expect(js).toContain('selector_ambiguous'); 35 expect(js).toContain('candidates'); 36 }); 37 38 it('generates JS that propagates --nth option into the CSS branch', () => { 39 const js = resolveTargetJs('.btn', { nth: 2 }); 40 expect(js).toContain('selector_nth_out_of_range'); 41 // opt.nth=2 should be inlined so the runtime picks matches[2] 42 expect(js).toMatch(/const nth = 2;?/); 43 }); 44 45 it('generates JS that enables firstOnMulti for read commands', () => { 46 const js = resolveTargetJs('.btn', { firstOnMulti: true }); 47 expect(js).toContain('firstOnMulti = true'); 48 }); 49 50 it('generates JS with invalid_selector branch for CSS syntax errors', () => { 51 const js = resolveTargetJs('.btn'); 52 expect(js).toContain('invalid_selector'); 53 }); 54 55 it('generates JS with selector_not_found branch for 0 matches', () => { 56 const js = resolveTargetJs('#does-not-exist'); 57 expect(js).toContain('selector_not_found'); 58 }); 59 60 it('hands every non-numeric input to querySelectorAll (no regex shortlist)', () => { 61 // Inputs that the old isCssLike regex rejected — must all flow into the 62 // CSS branch so `find --css` and `get/click/type/select` accept the same surface. 63 for (const sel of [':root', '*', ':has(.foo)', '::shadow-root', '???']) { 64 const js = resolveTargetJs(sel); 65 expect(js).toContain('querySelectorAll'); 66 // invalid selectors still route through invalid_selector at runtime, 67 // never through a frontend "Cannot parse target" rejection. 68 expect(js).not.toContain('Cannot parse target'); 69 } 70 }); 71 72 it('escapes ref value safely', () => { 73 const js = resolveTargetJs('"; alert(1); "'); 74 // JSON.stringify should handle escaping 75 expect(js).not.toContain('alert(1); "'); 76 expect(js).toContain('\\"'); 77 }); 78 79 it('tags every success envelope with match_level so agents can tell tiers apart', () => { 80 const numericJs = resolveTargetJs('7'); 81 const cssJs = resolveTargetJs('.btn'); 82 // Exact / reidentified emit the literal directly; stable flows through the 83 // classifier's `level` variable. All three strings must appear in the JS. 84 expect(numericJs).toContain("match_level: 'exact'"); 85 expect(numericJs).toContain("match_level: 'reidentified'"); 86 expect(numericJs).toContain("return 'stable'"); 87 // Stable + exact share the same emit site (match_level: level) — make sure 88 // we didn't hardcode one of them and drop the other. 89 expect(numericJs).toContain('match_level: level'); 90 // CSS path is always exact (selector ran successfully). 91 expect(cssJs).toContain("match_level: 'exact'"); 92 }); 93 94 it('cascading ref path — classifier + reidentifier are both wired in', () => { 95 const js = resolveTargetJs('3'); 96 // Classifier distinguishes the three tiers 97 expect(js).toContain('function classifyMatch'); 98 expect(js).toContain("return 'exact'"); 99 expect(js).toContain("return 'stable'"); 100 expect(js).toContain("return 'mismatch'"); 101 // Strong id is the only thing that can rescue a drifted fingerprint 102 expect(js).toContain('hadStrongId'); 103 // Reidentify searches live DOM with the same fingerprint shape the 104 // snapshot / find writers emit — id / testId / aria-label only. 105 expect(js).toContain('function reidentify'); 106 expect(js).toContain('getElementById'); 107 expect(js).toContain('[data-testid="'); 108 expect(js).toContain('[aria-label="'); 109 // Unique match required — never silently picks one of many candidates. 110 expect(js).toContain('candidates.length === 1'); 111 // Recovered element is re-tagged + identity map refreshed so subsequent 112 // resolves land on 'exact' instead of re-walking the cascade. 113 expect(js).toContain("setAttribute('data-opencli-ref', ref)"); 114 expect(js).toContain('identity[ref] = fingerprintOf(recovered)'); 115 }); 116 117 it('reidentify runs both when data-opencli-ref is missing AND when fingerprint is mismatched', () => { 118 const js = resolveTargetJs('9'); 119 // Two call sites: one in the !el branch, one after classifyMatch returns mismatch. 120 const count = js.split('reidentify(fp)').length - 1; 121 expect(count).toBeGreaterThanOrEqual(2); 122 }); 123 124 it('falls through to stale_ref only after reidentify exhausts', () => { 125 const js = resolveTargetJs('4'); 126 // The stale_ref emit must sit *below* a reidentify attempt so the cascade 127 // is what produces the error — not the original strict check. 128 const reidentifyIdx = js.indexOf('const recovered = reidentify(fp);'); 129 const staleIdx = js.indexOf("code: 'stale_ref'"); 130 expect(reidentifyIdx).toBeGreaterThan(-1); 131 expect(staleIdx).toBeGreaterThan(reidentifyIdx); 132 }); 133 });