/ src / browser / target-resolver.test.ts
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  });